obsidian-sync

a short description
git clone https://git.skysyrup.dev/obsidian-sync.git
Log | Files | Refs | README

commit d5cafe5acd9623f686a59bb62168bd4ff698f8d4
Author: dronesflier <accounts@skysyrup.dev>
Date:   Fri, 20 Feb 2026 11:49:41 +0000

Initial implementation of obsidian-sync Python client

Fully reversed from Obsidian 1.11.7 app.js:
- REST API client (signin, vault list, regions)
- Encryption v0/v2/v3 (scrypt + AES-GCM + AES-SIV)
- WebSocket sync client (pull, push, heartbeat)
- Sync daemon with polling file watcher and reconnect backoff
- CLI: login, logout, vaults, setup, run, status
- Textual TUI setup wizard (login, vault config, systemd service)
- systemd user service unit file template

Diffstat:
A.gitignore | 32++++++++++++++++++++++++++++++++
Acli.py | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aobsidian-sync@.service | 24++++++++++++++++++++++++
Aobsidian_sync/__init__.py | 16++++++++++++++++
Aobsidian_sync/api.py | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aobsidian_sync/crypto.py | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aobsidian_sync/daemon.py | 740+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aobsidian_sync/websocket_client.py | 531+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asetup.py | 20++++++++++++++++++++
Asetup_tui.py | 1046+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 3282 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,32 @@ +# Extracted AppImage contents — not for distribution +squashfs-root/ +app_extracted/ +obsidian_extracted/ +extracted/ +*.AppImage + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +env/ + +# Config / credentials — never commit these +~/.config/obsidian-sync/ +*.json +!obsidian-sync@.service + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ +*.swp diff --git a/cli.py b/cli.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +obsidian-sync CLI + +Commands: + login Sign in and save credentials + logout Sign out and remove credentials + vaults List vaults for the signed-in account + setup Interactively configure a vault for syncing + run Start the sync daemon for a configured vault + status Show current sync status / storage usage + info Show signed-in account info +""" + +from __future__ import annotations + +import argparse +import asyncio +import getpass +import json +import logging +import os +import sys +from pathlib import Path + +# Config/credentials are stored in ~/.config/obsidian-sync/ +CONFIG_DIR = Path.home() / ".config" / "obsidian-sync" +CREDENTIALS_FILE = CONFIG_DIR / "credentials.json" + + +# --------------------------------------------------------------------------- +# Credential helpers +# --------------------------------------------------------------------------- + +def _save_credentials(data: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CREDENTIALS_FILE.write_text(json.dumps(data, indent=2)) + CREDENTIALS_FILE.chmod(0o600) + + +def _load_credentials() -> dict: + if not CREDENTIALS_FILE.exists(): + return {} + try: + return json.loads(CREDENTIALS_FILE.read_text()) + except Exception: + return {} + + +def _require_token() -> str: + creds = _load_credentials() + token = creds.get("token", "") + if not token: + print("Not logged in. Run: obsidian-sync login", file=sys.stderr) + sys.exit(1) + return token + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +async def cmd_login(args: argparse.Namespace) -> None: + from obsidian_sync.api import signin, ObsidianAPIError + + email = args.email or input("Email: ").strip() + password = args.password or getpass.getpass("Password: ") + mfa = args.mfa or "" + + try: + session = await signin(email, password, mfa) + except ObsidianAPIError as e: + if "mfa" in str(e).lower() or "two-factor" in str(e).lower(): + mfa = input("MFA code: ").strip() + session = await signin(email, password, mfa) + else: + print(f"Login failed: {e}", file=sys.stderr) + sys.exit(1) + + _save_credentials({ + "token": session.token, + "email": session.email, + "name": session.name, + "license": session.license, + }) + print(f"Logged in as {session.email} ({session.name})") + if session.license: + print(f"License: {session.license}") + + +async def cmd_logout(args: argparse.Namespace) -> None: + from obsidian_sync.api import signout, ObsidianAPIError + + creds = _load_credentials() + token = creds.get("token", "") + if token: + try: + await signout(token) + except ObsidianAPIError: + pass + if CREDENTIALS_FILE.exists(): + CREDENTIALS_FILE.unlink() + print("Logged out.") + + +async def cmd_info(args: argparse.Namespace) -> None: + from obsidian_sync.api import get_user_info, ObsidianAPIError + + token = _require_token() + try: + info = await get_user_info(token) + except ObsidianAPIError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Email: {info.get('email', '?')}") + print(f"Name: {info.get('name', '?')}") + print(f"License: {info.get('license', '?')}") + + +async def cmd_vaults(args: argparse.Namespace) -> None: + from obsidian_sync.api import list_vaults, ObsidianAPIError + + token = _require_token() + try: + vaults = await list_vaults(token) + except ObsidianAPIError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if not vaults: + print("No vaults found.") + return + + print(f"{'#':<3} {'Name':<30} {'ID':<36} {'Host':<40} {'Enc'}") + print("-" * 120) + for i, v in enumerate(vaults): + print(f"{i:<3} {v.name:<30} {v.id:<36} {v.host:<40} v{v.encryption_version}") + + +async def cmd_setup(args: argparse.Namespace) -> None: + """Interactively create a vault config file.""" + from obsidian_sync.api import list_vaults, ObsidianAPIError + + token = _require_token() + + # List vaults + try: + vaults = await list_vaults(token) + except ObsidianAPIError as e: + print(f"Error fetching vaults: {e}", file=sys.stderr) + sys.exit(1) + + if not vaults: + print("No vaults found on your account.", file=sys.stderr) + sys.exit(1) + + print("\nAvailable vaults:") + for i, v in enumerate(vaults): + print(f" [{i}] {v.name} (enc v{v.encryption_version})") + + idx_str = input("\nVault number: ").strip() + try: + idx = int(idx_str) + vault = vaults[idx] + except (ValueError, IndexError): + print("Invalid selection.", file=sys.stderr) + sys.exit(1) + + local_path = input(f"Local vault path [~/vaults/{vault.name}]: ").strip() + if not local_path: + local_path = str(Path.home() / "vaults" / vault.name) + + password = getpass.getpass(f"Vault encryption password: ") + + device_name = input("Device name [obsidian-sync-daemon]: ").strip() or "obsidian-sync-daemon" + + out_path = args.output or str(CONFIG_DIR / f"{vault.name.replace(' ', '_')}.json") + + config_data = { + "token": token, + "vault_id": vault.id, + "vault_host": vault.host, + "vault_password": password, + "vault_salt": vault.salt, + "encryption_version": vault.encryption_version, + "vault_path": local_path, + "device_name": device_name, + "sync_images": False, + "sync_audio": False, + "sync_video": False, + "sync_pdf": False, + "sync_app_settings": True, + "ignore_folders": [], + "state_file": "", + } + + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(config_data, indent=2)) + out.chmod(0o600) + print(f"\nConfig saved to: {out_path}") + print(f"Start syncing with: obsidian-sync run {out_path}") + + +async def cmd_run(args: argparse.Namespace) -> None: + """Start the sync daemon.""" + from obsidian_sync.daemon import SyncDaemon, SyncConfig + + if not args.config: + print("Usage: obsidian-sync run <config.json>", file=sys.stderr) + sys.exit(1) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + try: + config = SyncConfig.from_file(args.config) + except Exception as e: + print(f"Failed to load config: {e}", file=sys.stderr) + sys.exit(1) + + daemon = SyncDaemon(config) + + import signal + + loop = asyncio.get_event_loop() + + def _stop(): + print("\nStopping...") + asyncio.create_task(daemon.stop()) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, _stop) + + await daemon.run() + + +async def cmd_status(args: argparse.Namespace) -> None: + """Connect to vault and print storage usage.""" + from obsidian_sync.daemon import SyncConfig, SyncState + from obsidian_sync.crypto import create_encryption_provider + from obsidian_sync.websocket_client import ObsidianSyncClient + + if not args.config: + print("Usage: obsidian-sync status <config.json>", file=sys.stderr) + sys.exit(1) + + try: + config = SyncConfig.from_file(args.config) + except Exception as e: + print(f"Failed to load config: {e}", file=sys.stderr) + sys.exit(1) + + enc = create_encryption_provider(config.vault_password, config.vault_salt, config.encryption_version) + client = ObsidianSyncClient( + host=config.vault_host, + token=config.token, + vault_id=config.vault_id, + encryption_provider=enc, + device_name=config.device_name, + last_version=0, + initial=False, + ) + + await client.connect() + try: + size_info = await client.get_size() + used = size_info.get("vault_size", -1) + limit = size_info.get("limit", -1) + total = size_info.get("size", -1) + print(f"Vault storage: {_fmt_bytes(used)} / {_fmt_bytes(limit)}") + print(f"Account total: {_fmt_bytes(total)}") + finally: + await client.disconnect() + + +def _fmt_bytes(n: int) -> str: + if n < 0: + return "?" + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + prog="obsidian-sync", + description="Lightweight Obsidian Sync client daemon", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # login + p_login = sub.add_parser("login", help="Sign in to Obsidian account") + p_login.add_argument("--email", default="") + p_login.add_argument("--password", default="") + p_login.add_argument("--mfa", default="", help="MFA/2FA code if required") + + # logout + sub.add_parser("logout", help="Sign out and remove saved credentials") + + # info + sub.add_parser("info", help="Show account info") + + # vaults + sub.add_parser("vaults", help="List vaults") + + # setup + p_setup = sub.add_parser("setup", help="Interactively configure a vault") + p_setup.add_argument("--output", default="", help="Output config file path") + + # run + p_run = sub.add_parser("run", help="Start sync daemon") + p_run.add_argument("config", nargs="?", default="", help="Path to vault config JSON") + p_run.add_argument("-v", "--verbose", action="store_true") + + # status + p_status = sub.add_parser("status", help="Show vault storage usage") + p_status.add_argument("config", nargs="?", default="", help="Path to vault config JSON") + + args = parser.parse_args() + + cmd_map = { + "login": cmd_login, + "logout": cmd_logout, + "info": cmd_info, + "vaults": cmd_vaults, + "setup": cmd_setup, + "run": cmd_run, + "status": cmd_status, + } + + coro = cmd_map[args.command](args) + asyncio.run(coro) + + +if __name__ == "__main__": + main() diff --git a/obsidian-sync@.service b/obsidian-sync@.service @@ -0,0 +1,24 @@ +[Unit] +Description=Obsidian Sync Daemon +Documentation=https://github.com/your-repo/obsidian-sync +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%i +ExecStart=/usr/local/bin/obsidian-sync run /home/%i/.config/obsidian-sync/vault.json +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=obsidian-sync + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/home/%i + +[Install] +WantedBy=multi-user.target diff --git a/obsidian_sync/__init__.py b/obsidian_sync/__init__.py @@ -0,0 +1,16 @@ +""" +obsidian_sync — lightweight Obsidian Sync client (reversed from v1.11.7). +""" + +from .api import signin, signout, list_vaults, get_user_info, ObsidianAPIError, UserSession, Vault +from .crypto import create_encryption_provider, EncryptionV0, EncryptionV2, derive_key +from .websocket_client import ObsidianSyncClient, SyncError, SyncConnectionError, SyncAuthError +from .daemon import SyncDaemon, SyncConfig, SyncState + +__all__ = [ + "signin", "signout", "list_vaults", "get_user_info", + "ObsidianAPIError", "UserSession", "Vault", + "create_encryption_provider", "EncryptionV0", "EncryptionV2", "derive_key", + "ObsidianSyncClient", "SyncError", "SyncConnectionError", "SyncAuthError", + "SyncDaemon", "SyncConfig", "SyncState", +] diff --git a/obsidian_sync/api.py b/obsidian_sync/api.py @@ -0,0 +1,134 @@ +""" +Obsidian Sync REST API client. + +Reversed from Obsidian 1.11.7 app.js. + +Base URL: https://api.obsidian.md (SZ = "https://" + ["api","obsidian","md"].join(".")) +All POST requests send JSON and receive JSON. + +Endpoints: + POST /user/signin {email, password, mfa?} -> {token, email, name, license} + POST /user/signout {token} -> {} + POST /user/info {token} -> {email, name, license} + POST /user/authtoken {token} -> {token, email, name, license} + POST /vault/list {token, supported_encryption_version: 3} -> {vaults: [...], shared: [...]} + POST /vault/create {token, name, keyhash, salt, region, encryption_version} -> vault + POST /vault/access {token, vault_uid, keyhash, host, encryption_version} -> {} + POST /vault/regions {token, host} -> {regions: [...]} + POST /subscription/list {token} -> {subscriptions: [...]} + +Vault object fields: + id, name, salt, host, encryption_version, keyhash, ... +""" + +from __future__ import annotations + +import asyncio +import aiohttp +from dataclasses import dataclass, field +from typing import Optional, Any + +API_BASE = "https://api.obsidian.md" + + +@dataclass +class Vault: + id: str + name: str + salt: str + host: str + encryption_version: int + keyhash: str = "" + password: str = "" # Set by user, not from API + + +@dataclass +class UserSession: + token: str + email: str = "" + name: str = "" + license: str = "" + + +class ObsidianAPIError(Exception): + def __init__(self, error: str, response: Optional[dict] = None): + super().__init__(error) + self.error = error + self.response = response + + +_DEFAULT_HEADERS = { + "Content-Type": "application/json", + "Referer": "https://obsidian.md/", + "Origin": "https://obsidian.md", +} + + +async def _api_post(session: aiohttp.ClientSession, endpoint: str, payload: dict) -> dict: + """Make an authenticated POST request to the Obsidian API.""" + url = API_BASE + endpoint + async with session.post(url, json=payload, headers=_DEFAULT_HEADERS) as resp: + data = await resp.json(content_type=None) + if "error" in data: + raise ObsidianAPIError(data["error"], data) + return data + + +async def signin(email: str, password: str, mfa: str = "") -> UserSession: + """Sign in and return a session token.""" + async with aiohttp.ClientSession() as session: + payload: dict[str, Any] = {"email": email, "password": password} + if mfa: + payload["mfa"] = mfa + data = await _api_post(session, "/user/signin", payload) + return UserSession( + token=data.get("token", ""), + email=data.get("email", ""), + name=data.get("name", ""), + license=data.get("license", ""), + ) + + +async def signout(token: str) -> None: + """Sign out.""" + async with aiohttp.ClientSession() as session: + try: + await _api_post(session, "/user/signout", {"token": token}) + except ObsidianAPIError as e: + if "Not logged in" not in str(e): + raise + + +async def get_user_info(token: str) -> dict: + """Get current user info.""" + async with aiohttp.ClientSession() as session: + return await _api_post(session, "/user/info", {"token": token}) + + +async def list_vaults(token: str) -> list[Vault]: + """List all vaults for the current user.""" + async with aiohttp.ClientSession() as session: + data = await _api_post( + session, "/vault/list", + {"token": token, "supported_encryption_version": 3} + ) + vaults = [] + for v in data.get("vaults", []) + data.get("shared", []): + if "encryption_version" not in v: + v["encryption_version"] = 0 + vaults.append(Vault( + id=v["id"], + name=v["name"], + salt=v.get("salt", ""), + host=v.get("host", ""), + encryption_version=v["encryption_version"], + keyhash=v.get("keyhash", ""), + )) + return vaults + + +async def list_regions(token: str, host: str = "") -> list[str]: + """List available sync regions.""" + async with aiohttp.ClientSession() as session: + data = await _api_post(session, "/vault/regions", {"token": token, "host": host}) + return data.get("regions", []) diff --git a/obsidian_sync/crypto.py b/obsidian_sync/crypto.py @@ -0,0 +1,393 @@ +""" +Obsidian Sync Encryption - Reversed from Obsidian 1.11.7 app.js + +Three encryption versions are supported: + Version 0: AES-GCM with raw key (no salt). Key derived via scrypt(password, salt, N=32768, r=8, p=1, dklen=32). + Path/filename encryption uses AES-GCM with IV = first 12 bytes of SHA-256(plaintext). + KeyHash = hex(SHA-256(key)). + + Version 2: AES-GCM for file content + AES-SIV for deterministic path encoding. + Key derived via scrypt as above. HKDF(key, salt, "ObsidianKeyHash") -> keyHash. + Path encoding: AES-SIV seal/open (using AES-CMAC-SIV). + File content: AES-GCM encrypt (random IV). + + Version 3: Same as version 2 but different SIV implementation detail (handled identically in Python). + +Scrypt parameters: N=32768, r=8, p=1, dklen=32. +""" + +import os +import hashlib +import hmac +import struct +from typing import Optional + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend + + +# --------------------------------------------------------------------------- +# Scrypt key derivation +# --------------------------------------------------------------------------- + +def derive_key(password: str, salt: str) -> bytes: + """Derive 32-byte encryption key from password and salt using scrypt. + + Matches wZ() in app.js: + scrypt(password.normalize("NFKC"), salt.normalize("NFKC"), N=32768, r=8, p=1, dklen=32) + """ + import unicodedata + password = unicodedata.normalize("NFKC", password) + salt_str = unicodedata.normalize("NFKC", salt) + kdf = Scrypt( + salt=salt_str.encode("utf-8"), + length=32, + n=32768, + r=8, + p=1, + backend=default_backend(), + ) + return kdf.derive(password.encode("utf-8")) + + +# --------------------------------------------------------------------------- +# Low-level AES-GCM (version 0 and content encryption for v2/v3) +# --------------------------------------------------------------------------- + +def aes_gcm_encrypt(data: bytes, key: bytes, iv: Optional[bytes] = None) -> bytes: + """Encrypt data with AES-GCM. Returns iv (12 bytes) + ciphertext+tag. + + Matches iZ() in app.js. + """ + if iv is None: + iv = os.urandom(12) + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(iv, data, None) + return iv + ciphertext + + +def aes_gcm_decrypt(data: bytes, key: bytes) -> bytes: + """Decrypt data with AES-GCM. Input format: iv (12 bytes) + ciphertext+tag. + + Matches rZ() in app.js. + """ + if len(data) < 12: + raise ValueError("Encrypted data too short") + if len(data) == 12: + return b"" + iv = data[:12] + ciphertext = data[12:] + aesgcm = AESGCM(key) + return aesgcm.decrypt(iv, ciphertext, None) + + +# --------------------------------------------------------------------------- +# AES-SIV for deterministic path encoding (versions 2 & 3) +# --------------------------------------------------------------------------- + +class CMAC: + """AES-CMAC implementation matching the browser's SubtleCrypto CMAC.""" + + def __init__(self, key: bytes): + self.key = key + self._generate_subkeys() + self._buffer = bytearray(16) + self._buffer_pos = 0 + self._finished = False + # Initialize cipher + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + self._cipher_class = lambda k: Cipher(algorithms.AES(k), modes.ECB(), backend=default_backend()) + + def _encrypt_block(self, block: bytes) -> bytes: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + cipher = Cipher(algorithms.AES(self.key), modes.ECB(), backend=default_backend()) + enc = cipher.encryptor() + return enc.update(bytes(block)) + enc.finalize() + + def _generate_subkeys(self): + """Generate CMAC subkeys k1 and k2.""" + L = self._encrypt_block(bytes(16)) + + def dbl(b: bytes) -> bytes: + b = bytearray(b) + overflow = 0 + result = bytearray(16) + for i in range(15, -1, -1): + result[i] = ((b[i] << 1) | overflow) & 0xFF + overflow = b[i] >> 7 + if overflow: + result[15] ^= 0x87 + return bytes(result) + + self.k1 = dbl(L) + self.k2 = dbl(self.k1) + + def update(self, data: bytes) -> None: + for byte in data: + self._buffer[self._buffer_pos] ^= byte + self._buffer_pos += 1 + if self._buffer_pos == 16: + # Encrypt the block (not the last one) + encrypted = self._encrypt_block(bytes(self._buffer)) + self._buffer = bytearray(encrypted) + self._buffer_pos = 0 + + def finish(self) -> bytes: + if not self._finished: + subkey = self.k2 if self._buffer_pos < 16 else self.k1 + buf = bytearray(self._buffer) + for i, k in enumerate(subkey): + buf[i] ^= k + if self._buffer_pos < 16: + buf[self._buffer_pos] ^= 0x80 + encrypted = self._encrypt_block(bytes(buf)) + self._finished = True + return encrypted + return bytes(self._buffer) + + +def _aes_ctr_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes: + """AES-CTR encryption. Used by AES-SIV.""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + cipher = Cipher(algorithms.AES(key), modes.CTR(iv + b'\x00\x00\x00\x00'), backend=default_backend()) + enc = cipher.encryptor() + return enc.update(data) + enc.finalize() + + +class AESSIV: + """AES-SIV (Synthetic IV) implementation for deterministic encryption. + + Matches vZ (the SIV class) in app.js. + Used for versions 2 & 3 path encoding. + + Key setup: Two keys from HKDF: + - k1: for CMAC (AES-CMAC key) + - k2: for CTR (AES-CTR key) + Both keys imported from the same base HKDF-derived material. + """ + + def __init__(self, cmac_key: bytes, ctr_key: bytes): + self.cmac_key = cmac_key + self.ctr_key = ctr_key + + @classmethod + def from_raw_key(cls, key: bytes) -> "AESSIV": + """Create from a raw 32-byte key (used for both CMAC and CTR).""" + return cls(key, key) + + def _s2v(self, data: bytes) -> bytes: + """S2V computation.""" + # Initial CMAC of zeros + cmac1 = CMAC(self.cmac_key) + cmac1.update(bytes(16)) + d = bytearray(cmac1.finish()) + + # DBL(d) + d = bytearray(self._dbl(bytes(d))) + + if len(data) >= 16: + # XOR last 16 bytes of data with d + tail_start = len(data) - 16 + msg_prefix = data[:tail_start] + msg_tail = data[tail_start:] + + cmac2 = CMAC(self.cmac_key) + cmac2.update(msg_prefix) + # XOR last block with d + final_block = bytearray(msg_tail) + for i in range(16): + final_block[i] ^= d[i] + cmac2.update(bytes(final_block)) + return cmac2.finish() + else: + # Pad and XOR + padded = bytearray(data) + bytearray([0x80]) + bytearray(16 - len(data) - 1) + d = bytearray(self._dbl(bytes(d))) + for i in range(16): + padded[i] ^= d[i] + cmac2 = CMAC(self.cmac_key) + cmac2.update(bytes(padded)) + return cmac2.finish() + + def _dbl(self, b: bytes) -> bytes: + """Double in GF(2^128).""" + b = bytearray(b) + overflow = 0 + result = bytearray(16) + for i in range(15, -1, -1): + result[i] = ((b[i] << 1) | overflow) & 0xFF + overflow = b[i] >> 7 + if overflow: + result[15] ^= 0x87 + return bytes(result) + + def _clear_bits(self, iv: bytes) -> bytes: + """Clear bits 31 and 63 counting from right (gZ function).""" + iv = bytearray(iv) + # iv[length-8] &= 127 (clear bit 7 of 8th-from-last byte) + # iv[length-4] &= 127 (clear bit 7 of 4th-from-last byte) + iv[-8] &= 0x7F + iv[-4] &= 0x7F + return bytes(iv) + + def seal(self, plaintext: bytes) -> bytes: + """Encrypt with AES-SIV. Returns tag (16 bytes) + ciphertext.""" + siv = self._s2v(plaintext) + iv = self._clear_bits(siv) + ciphertext = _aes_ctr_encrypt(self.ctr_key, iv, plaintext) + return siv + ciphertext + + def open(self, ciphertext: bytes) -> bytes: + """Decrypt with AES-SIV. Input: tag (16 bytes) + ciphertext.""" + if len(ciphertext) < 16: + raise ValueError("AES-SIV: ciphertext is truncated") + tag = ciphertext[:16] + ct = ciphertext[16:] + iv = self._clear_bits(tag) + plaintext = _aes_ctr_encrypt(self.ctr_key, iv, ct) + + # Verify tag + expected = self._s2v(plaintext) + if not hmac.compare_digest(tag, expected): + raise ValueError("AES-SIV: ciphertext verification failure!") + return plaintext + + +# --------------------------------------------------------------------------- +# Encryption providers (matching EZ and MZ classes in app.js) +# --------------------------------------------------------------------------- + +class EncryptionV0: + """Version 0: AES-GCM with raw 32-byte key. + + - keyHash = hex(SHA-256(key)) + - deterministicEncodeStr: AES-GCM with IV = first 12 bytes of SHA-256(plaintext) + - encrypt/decrypt: AES-GCM (random IV for encrypt) + """ + + encryption_version = 0 + + def __init__(self, key: bytes): + if len(key) != 32: + raise ValueError("Invalid encryption key, must be 32 bytes") + self.key = key + self.key_hash = hashlib.sha256(key).hexdigest() + + @classmethod + def from_password(cls, password: str, salt: str) -> "EncryptionV0": + key = derive_key(password, salt) + return cls(key) + + def deterministic_encode_str(self, text: str) -> str: + """Deterministically encrypt a string (used for file paths). + + IV = first 12 bytes of SHA-256(utf8(text)). + Returns hex string. + """ + data = text.encode("utf-8") + iv = hashlib.sha256(data).digest()[:12] + return aes_gcm_encrypt(data, self.key, iv).hex() + + def deterministic_decode_str(self, hex_str: str) -> str: + """Decrypt a deterministically encoded string.""" + data = bytes.fromhex(hex_str) + return aes_gcm_decrypt(data, self.key).decode("utf-8") + + def encrypt(self, data: bytes) -> bytes: + """Encrypt data (random IV).""" + return aes_gcm_encrypt(data, self.key) + + def decrypt(self, data: bytes) -> bytes: + """Decrypt data.""" + return aes_gcm_decrypt(data, self.key) + + +class EncryptionV2: + """Version 2/3: AES-SIV for paths + AES-GCM for content. + + Matches MZ class in app.js. + + Key derivation: + raw_key = scrypt(password, salt) + hkdf_key = HKDF(raw_key, "HKDF") + key_hash = hex(HKDF(raw_key, salt, "ObsidianKeyHash", AES-CBC-256)) + siv = AES-SIV from HKDF (same hkdf_key base) + aes_gcm_key = HKDF(raw_key, [], "ObsidianAesGcm", AES-GCM-256) + """ + + def __init__(self, encryption_version: int, raw_key: bytes, salt: str): + if encryption_version not in (2, 3): + raise ValueError("Invalid encryption version, must be 2 or 3") + self.encryption_version = encryption_version + + salt_bytes = salt.encode("utf-8") + + # Import raw key for HKDF + backend = default_backend() + + # Derive key hash: HKDF(raw_key, salt, "ObsidianKeyHash") -> AES-CBC-256 -> export -> hex + hkdf_keyhash = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=salt_bytes, + info=b"ObsidianKeyHash", + backend=backend, + ).derive(raw_key) + self.key_hash = hkdf_keyhash.hex() + + # Derive AES-GCM key: HKDF(raw_key, [], "ObsidianAesGcm") + self.gcm_key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"", + info=b"ObsidianAesGcm", + backend=backend, + ).derive(raw_key) + + # AES-SIV: uses the HKDF base key material directly + # vZ.importKey() in app.js uses the same hkdf_key for both CMAC and CTR + # The importKey takes (r, key) where r is HKDF key handle and key is raw HKDF input + # Looking at vZ.importKey: it imports `r` (the HKDF key) and derives for CMAC and CTR + # Actually looking more carefully: vZ.importKey(r, o) where r is hkdf imported key and o is salt_bytes + # CMAC key: importKey("raw", key, ...) - the r param is already the hkdf_key handle + # This is the HKDF base key material (raw_key) used for CMAC and CTR separately + # The app uses the same imported HKDF key for both mZ (CMAC) and pZ (CTR) + # Both use the same raw key bytes + self.siv = AESSIV.from_raw_key(raw_key) + + @classmethod + def from_password(cls, password: str, salt: str, encryption_version: int = 2) -> "EncryptionV2": + raw_key = derive_key(password, salt) + return cls(encryption_version, raw_key, salt) + + def deterministic_encode_str(self, text: str) -> str: + """Encrypt path with AES-SIV (deterministic). Returns hex string.""" + data = text.encode("utf-8") + sealed = self.siv.seal(data) + return sealed.hex() + + def deterministic_decode_str(self, hex_str: str) -> str: + """Decrypt SIV-encrypted path from hex string.""" + data = bytes.fromhex(hex_str) + return self.siv.open(data).decode("utf-8") + + def encrypt(self, data: bytes) -> bytes: + """Encrypt file content with AES-GCM (random IV).""" + return aes_gcm_encrypt(data, self.gcm_key) + + def decrypt(self, data: bytes) -> bytes: + """Decrypt file content.""" + return aes_gcm_decrypt(data, self.gcm_key) + + +def create_encryption_provider(password: str, salt: str, encryption_version: int): + """Factory function to create the appropriate encryption provider.""" + if encryption_version == 0: + return EncryptionV0.from_password(password, salt) + elif encryption_version in (2, 3): + return EncryptionV2.from_password(password, salt, encryption_version) + else: + raise ValueError(f"Unsupported encryption version: {encryption_version}") diff --git a/obsidian_sync/daemon.py b/obsidian_sync/daemon.py @@ -0,0 +1,740 @@ +""" +Obsidian Sync daemon - manages vault sync as a system service. + +This module handles: + - File system watching (using inotify / polling) + - Local file state tracking (path, mtime, ctime, size, hash) + - Bidirectional sync with the Obsidian Sync server + - Conflict detection and resolution + - Automatic reconnect with exponential backoff + +Usage: + daemon = SyncDaemon(config) + asyncio.run(daemon.run()) +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import stat +import time +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional, Callable + +from .api import list_vaults, Vault +from .crypto import create_encryption_provider, EncryptionV0, EncryptionV2 +from .websocket_client import ObsidianSyncClient, SyncConnectionError, SyncAuthError + +logger = logging.getLogger(__name__) + +# File types allowed by default (matching Obsidian defaults) +DEFAULT_ALLOWED_TYPES = {"md", "canvas", "base"} + +# Image/audio/video/pdf types (user-configurable) +IMAGE_TYPES = { + "avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp", + "tiff", "tif", "ico", +} +AUDIO_TYPES = {"flac", "m4a", "mp3", "ogg", "wav", "webm", "3gp"} +VIDEO_TYPES = {"mkv", "mov", "mp4", "ogv", "webm"} +PDF_TYPES = {"pdf"} + + +def compute_file_hash(data: bytes) -> str: + """Compute SHA-256 hash of file content as hex string.""" + return hashlib.sha256(data).hexdigest() + + +@dataclass +class LocalFileInfo: + path: str + mtime: int = 0 + ctime: int = 0 + size: int = 0 + folder: bool = False + hash: str = "" + sync_time: int = 0 + sync_hash: str = "" + previous_path: str = "" + + +@dataclass +class ServerFileInfo: + path: str + uid: int = 0 + mtime: int = 0 + ctime: int = 0 + size: int = 0 + folder: bool = False + hash: str = "" + deleted: bool = False + device: str = "" + user: int = 0 + + +@dataclass +class SyncConfig: + """Configuration for the sync daemon.""" + # Authentication + token: str + + # Vault settings + vault_id: str + vault_host: str + vault_password: str + vault_salt: str + encryption_version: int + + # Local vault path + vault_path: str + + # Device identification + device_name: str = "obsidian-sync-daemon" + + # Sync options + allowed_types: set = field(default_factory=lambda: set(DEFAULT_ALLOWED_TYPES)) + sync_images: bool = False + sync_audio: bool = False + sync_video: bool = False + sync_pdf: bool = False + sync_app_settings: bool = True + + # Ignored folders (list of path prefixes) + ignore_folders: list = field(default_factory=list) + + # State file path (for persisting sync state) + state_file: str = "" + + @property + def effective_allowed_types(self) -> set: + types = set(DEFAULT_ALLOWED_TYPES) + if self.sync_images: + types |= IMAGE_TYPES + if self.sync_audio: + types |= AUDIO_TYPES + if self.sync_video: + types |= VIDEO_TYPES + if self.sync_pdf: + types |= PDF_TYPES + return types + + @classmethod + def from_file(cls, path: str) -> "SyncConfig": + """Load config from a JSON file.""" + with open(path) as f: + data = json.load(f) + config = cls( + token=data["token"], + vault_id=data["vault_id"], + vault_host=data["vault_host"], + vault_password=data["vault_password"], + vault_salt=data["vault_salt"], + encryption_version=data.get("encryption_version", 2), + vault_path=data["vault_path"], + device_name=data.get("device_name", "obsidian-sync-daemon"), + sync_images=data.get("sync_images", False), + sync_audio=data.get("sync_audio", False), + sync_video=data.get("sync_video", False), + sync_pdf=data.get("sync_pdf", False), + sync_app_settings=data.get("sync_app_settings", True), + ignore_folders=data.get("ignore_folders", []), + state_file=data.get("state_file", ""), + ) + return config + + def to_file(self, path: str) -> None: + """Save config to a JSON file.""" + data = { + "token": self.token, + "vault_id": self.vault_id, + "vault_host": self.vault_host, + "vault_password": self.vault_password, + "vault_salt": self.vault_salt, + "encryption_version": self.encryption_version, + "vault_path": self.vault_path, + "device_name": self.device_name, + "sync_images": self.sync_images, + "sync_audio": self.sync_audio, + "sync_video": self.sync_video, + "sync_pdf": self.sync_pdf, + "sync_app_settings": self.sync_app_settings, + "ignore_folders": self.ignore_folders, + "state_file": self.state_file, + } + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +@dataclass +class SyncState: + """Persisted sync state.""" + version: int = 0 + initial: bool = True + local_files: dict = field(default_factory=dict) + server_files: dict = field(default_factory=dict) + + def save(self, path: str) -> None: + data = { + "version": self.version, + "initial": self.initial, + "local_files": self.local_files, + "server_files": self.server_files, + } + with open(path, "w") as f: + json.dump(data, f) + + @classmethod + def load(cls, path: str) -> "SyncState": + if not os.path.exists(path): + return cls() + try: + with open(path) as f: + data = json.load(f) + return cls( + version=data.get("version", 0), + initial=data.get("initial", True), + local_files=data.get("local_files", {}), + server_files=data.get("server_files", {}), + ) + except Exception: + return cls() + + +class BackoffTimer: + """Exponential backoff timer for reconnection.""" + + def __init__(self, base: float = 5.0, max_delay: float = 300.0): + self.base = base + self.max_delay = max_delay + self._count = 0 + + def fail(self) -> None: + self._count += 1 + + def success(self) -> None: + self._count = 0 + + def is_ready(self) -> bool: + return True # We handle sleep in the main loop + + async def wait(self) -> None: + if self._count > 0: + delay = min(self.base * (2 ** self._count), self.max_delay) + logger.info(f"Waiting {delay:.0f}s before reconnect (attempt {self._count})") + await asyncio.sleep(delay) + + +class SyncDaemon: + """ + Main sync daemon that manages bidirectional sync with Obsidian Sync. + + Designed to run as a systemd service or background process. + """ + + def __init__(self, config: SyncConfig): + self.config = config + self.vault_path = Path(config.vault_path) + self.state_file = config.state_file or str(self.vault_path / ".obsidian" / "sync-state.json") + + self.state = SyncState.load(self.state_file) + self.backoff = BackoffTimer() + self._client: Optional[ObsidianSyncClient] = None + self._running = False + self._pending_server_files: list[dict] = [] + self._sync_lock = asyncio.Lock() + + # Setup encryption provider + self._enc: EncryptionV0 | EncryptionV2 = create_encryption_provider( + config.vault_password, + config.vault_salt, + config.encryption_version, + ) + + # ------------------------------------------------------------------ + # File system helpers + # ------------------------------------------------------------------ + + def _should_sync(self, path: str, is_folder: bool = False) -> bool: + """Check if a file/folder should be synced. Matches _allowSyncFile() in app.js.""" + if path == "/": + return False + + # Check ignored folders + for folder in self.config.ignore_folders: + if (is_folder and path == folder) or path.startswith(folder + "/"): + return False + + # Skip hidden files and special dirs + parts = path.split("/") + if any(p.startswith(".") or p == "node_modules" for p in parts[:-1]): + return False + + if is_folder: + return True + + # Skip files starting with '.' + filename = parts[-1] + if filename.startswith("."): + return False + + # Check config directory files + config_dir = ".obsidian" + if path.startswith(config_dir + "/"): + if not self.config.sync_app_settings: + return False + relative = path[len(config_dir) + 1:] + # Skip workspace files + if relative in ("workspace.json", "workspace-mobile.json"): + return False + return True + + # Regular files: check extension + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + return ext in self.config.effective_allowed_types + + async def _scan_local_files(self) -> dict[str, LocalFileInfo]: + """Scan local vault for all syncable files.""" + files: dict[str, LocalFileInfo] = {} + + def scan_dir(dir_path: Path, rel_prefix: str = "") -> None: + try: + entries = list(dir_path.iterdir()) + except PermissionError: + return + + for entry in sorted(entries): + rel = (rel_prefix + "/" + entry.name).lstrip("/") if rel_prefix else entry.name + + if entry.name.startswith(".") and rel_prefix == "": + # Allow .obsidian but skip other hidden top-level dirs + if entry.name != ".obsidian": + continue + + if entry.is_dir(follow_symlinks=False): + if self._should_sync(rel, is_folder=True): + files[rel] = LocalFileInfo( + path=rel, + folder=True, + mtime=0, + ctime=0, + size=0, + ) + scan_dir(entry, rel) + elif entry.is_file(follow_symlinks=False): + if self._should_sync(rel, is_folder=False): + try: + st = entry.stat() + info = files.get(rel) or LocalFileInfo(path=rel) + mtime = int(st.st_mtime * 1000) + ctime = int(st.st_ctime * 1000) + size = st.st_size + # Check if file changed + if info.mtime != mtime or info.size != size: + info.hash = "" # Will be computed on upload + info.mtime = mtime + info.ctime = ctime + info.size = size + info.folder = False + files[rel] = info + except (OSError, PermissionError): + pass + + # Restore existing local file info + for path, data in self.state.local_files.items(): + if isinstance(data, dict): + files[path] = LocalFileInfo(**data) + + # Scan actual filesystem + await asyncio.get_event_loop().run_in_executor(None, scan_dir, self.vault_path) + return files + + def _get_file_ext(self, path: str) -> str: + filename = path.split("/")[-1] + if "." in filename: + return filename.rsplit(".", 1)[-1].lower() + return "" + + async def _read_file(self, path: str) -> bytes: + """Read a local file.""" + full_path = self.vault_path / path + loop = asyncio.get_event_loop() + + def _read(): + with open(full_path, "rb") as f: + return f.read() + + return await loop.run_in_executor(None, _read) + + async def _write_file(self, path: str, data: bytes, mtime: int = 0, ctime: int = 0) -> None: + """Write a local file, creating parent directories as needed.""" + full_path = self.vault_path / path + loop = asyncio.get_event_loop() + + def _write(): + full_path.parent.mkdir(parents=True, exist_ok=True) + with open(full_path, "wb") as f: + f.write(data) + if mtime: + atime = time.time() + mtime_s = mtime / 1000.0 + os.utime(full_path, (atime, mtime_s)) + + await loop.run_in_executor(None, _write) + + async def _delete_file(self, path: str) -> None: + """Delete a local file.""" + full_path = self.vault_path / path + loop = asyncio.get_event_loop() + + def _delete(): + if full_path.is_file(): + full_path.unlink() + elif full_path.is_dir(): + import shutil + shutil.rmtree(full_path, ignore_errors=True) + + await loop.run_in_executor(None, _delete) + + async def _create_dir(self, path: str) -> None: + """Create a local directory.""" + full_path = self.vault_path / path + await asyncio.get_event_loop().run_in_executor( + None, lambda: full_path.mkdir(parents=True, exist_ok=True) + ) + + # ------------------------------------------------------------------ + # Sync logic + # ------------------------------------------------------------------ + + async def _on_server_push(self, msg: dict) -> None: + """Callback for server-pushed file updates.""" + self.state.version = msg.get("uid", self.state.version) + file_info = { + "path": msg.get("path", ""), + "uid": msg.get("uid", 0), + "size": msg.get("size", 0), + "hash": msg.get("hash", ""), + "mtime": msg.get("mtime", 0), + "ctime": msg.get("ctime", 0), + "folder": msg.get("folder", False), + "deleted": msg.get("deleted", False), + "device": msg.get("device", ""), + "user": msg.get("user", 0), + } + logger.debug(f"Server push: {file_info['path']} (deleted={file_info['deleted']})") + self._pending_server_files.append(file_info) + + async def _on_ready(self, version: int) -> None: + """Called when server sends 'ready' after initial handshake.""" + if version > self.state.version: + self.state.version = version + self.state.initial = False + logger.info(f"Server ready. Version: {version}") + self._save_state() + + async def _sync_iteration(self) -> bool: + """ + Perform one sync iteration. Returns True if more work to do. + + Process order: + 1. Apply pending server-pushed files + 2. Delete local files that server removed + 3. Upload new/changed local files + """ + if not self._client or not self._client.is_connected(): + return False + + local_files = await self._scan_local_files() + server_files = {k: v for k, v in self.state.server_files.items()} + + # -- Step 1: Apply pending server pushes -- + while self._pending_server_files: + pushed = self._pending_server_files.pop(0) + path = pushed["path"] + + if not path or not self._should_sync(path, pushed.get("folder", False)): + continue + + uid = pushed.get("uid", 0) + if uid: + self.state.version = max(self.state.version, uid) + + server_files[path] = pushed + self.state.server_files[path] = pushed + + local = local_files.get(path) + + if pushed.get("deleted"): + if local: + logger.info(f"Deleting local file (server deleted): {path}") + await self._delete_file(path) + del local_files[path] + self._save_state() + continue + + if pushed.get("folder"): + if not (self.vault_path / path).is_dir(): + logger.info(f"Creating local folder: {path}") + await self._create_dir(path) + continue + + # Download file + if not local or local.hash != pushed.get("hash", ""): + logger.info(f"Downloading: {path}") + try: + data = await self._client.pull(uid) + if data is not None: + await self._write_file( + path, data, + mtime=pushed.get("mtime", 0), + ctime=pushed.get("ctime", 0), + ) + file_hash = compute_file_hash(data) + info = local_files.get(path, LocalFileInfo(path=path)) + info.hash = file_hash + info.sync_hash = file_hash + info.sync_time = int(time.time() * 1000) + local_files[path] = info + self._save_state_with_files(local_files, server_files) + except Exception as e: + logger.error(f"Failed to download {path}: {e}") + + # -- Step 2: Find local files that server removed -- + for path, server_info in list(server_files.items()): + if server_info.get("deleted"): + continue + if path not in local_files: + logger.info(f"Deleting remote (no longer exists locally): {path}") + try: + folder = server_info.get("folder", False) + await self._client.push(path, None, folder=folder, deleted=True) + server_info["deleted"] = True + self.state.server_files[path] = server_info + self._save_state() + return True + except Exception as e: + logger.error(f"Failed to delete remote {path}: {e}") + + # -- Step 3: Upload new or changed local files -- + # Find the best candidate to upload (smallest changed file first) + best: Optional[tuple[str, LocalFileInfo]] = None + + for path, local in local_files.items(): + if not self._should_sync(path, local.folder): + continue + + server = server_files.get(path) + needs_upload = False + + if server is None or server.get("deleted"): + needs_upload = True + elif local.folder != server.get("folder", False): + needs_upload = True + elif not local.folder: + if not local.hash: + # Compute hash + try: + data = await self._read_file(path) + local.hash = compute_file_hash(data) + local_files[path] = local + except Exception: + continue + if local.hash != server.get("hash", ""): + needs_upload = True + + if needs_upload: + if best is None or (not local.folder and local.size < (best[1].size if best else 0)): + best = (path, local) + + if best: + path, local = best + logger.info(f"Uploading: {path} (folder={local.folder})") + try: + if local.folder: + await self._client.push(path, None, folder=True) + sv = server_files.get(path, {}) + sv.update({"path": path, "folder": True, "deleted": False, "uid": 0, + "size": 0, "hash": "", "ctime": 0, "mtime": 0}) + server_files[path] = sv + self.state.server_files[path] = sv + else: + data = await self._read_file(path) + local.hash = compute_file_hash(data) + await self._client.push( + path, data, + related_path=local.previous_path or None, + ctime=local.ctime, + mtime=local.mtime, + file_hash=local.hash, + ) + local.sync_hash = local.hash + local.sync_time = int(time.time() * 1000) + local.previous_path = "" + local_files[path] = local + sv = server_files.get(path, {}) + sv.update({"path": path, "folder": False, "deleted": False, + "hash": local.hash, "size": local.size, + "mtime": local.mtime, "ctime": local.ctime}) + server_files[path] = sv + self.state.server_files[path] = sv + + self._save_state_with_files(local_files, server_files) + return True + except Exception as e: + logger.error(f"Failed to upload {path}: {e}") + + # All synced + logger.debug("Fully synced") + self._save_state_with_files(local_files, server_files) + return False + + def _save_state(self) -> None: + """Save sync state to disk.""" + try: + os.makedirs(os.path.dirname(self.state_file), exist_ok=True) + self.state.save(self.state_file) + except Exception as e: + logger.warning(f"Failed to save state: {e}") + + def _save_state_with_files( + self, local_files: dict, server_files: dict + ) -> None: + self.state.local_files = { + k: asdict(v) if isinstance(v, LocalFileInfo) else v + for k, v in local_files.items() + } + self.state.server_files = server_files + self._save_state() + + # ------------------------------------------------------------------ + # File watching (simple polling) + # ------------------------------------------------------------------ + + async def _watch_files(self) -> None: + """ + Watch for local file changes using polling. + Triggers sync when changes are detected. + + For production use, replace with inotify-based watcher. + """ + last_scan: dict[str, tuple[int, int]] = {} # path -> (mtime, size) + + while self._running: + await asyncio.sleep(5) # Poll every 5 seconds + + try: + changed = False + for root, dirs, files in os.walk(self.vault_path): + # Skip hidden directories (except .obsidian) + dirs[:] = [d for d in dirs if not d.startswith(".") or d == ".obsidian"] + + for fname in files: + full = os.path.join(root, fname) + rel = os.path.relpath(full, self.vault_path) + + if not self._should_sync(rel): + continue + + try: + st = os.stat(full) + key = (int(st.st_mtime * 1000), st.st_size) + if last_scan.get(rel) != key: + last_scan[rel] = key + changed = True + except OSError: + pass + + if changed: + logger.debug("Local changes detected, triggering sync") + # Sync will happen in the main loop + except Exception as e: + logger.debug(f"File watch error: {e}") + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + async def run(self) -> None: + """Main run loop. Connects, syncs, and reconnects on failure.""" + self._running = True + self.vault_path.mkdir(parents=True, exist_ok=True) + + logger.info(f"Starting Obsidian sync daemon for vault at {self.vault_path}") + logger.info(f"Vault ID: {self.config.vault_id}, Host: {self.config.vault_host}") + logger.info(f"Encryption version: {self.config.encryption_version}") + + # Start file watcher + watch_task = asyncio.create_task(self._watch_files()) + + try: + while self._running: + await self.backoff.wait() + + try: + logger.info("Connecting to sync server...") + self._client = ObsidianSyncClient( + host=self.config.vault_host, + token=self.config.token, + vault_id=self.config.vault_id, + encryption_provider=self._enc, + device_name=self.config.device_name, + last_version=self.state.version, + initial=self.state.initial, + ) + + self._client.on_push(self._on_server_push) + self._client.on_ready(self._on_ready) + + await self._client.connect() + self.backoff.success() + logger.info("Connected to sync server") + + # Start heartbeat + heartbeat_task = asyncio.create_task(self._client.heartbeat_loop()) + + try: + # Main sync loop + while self._running and self._client.is_connected(): + more_work = await self._sync_iteration() + if more_work: + await asyncio.sleep(0.05) # Small delay between iterations + else: + await asyncio.sleep(1.0) # Wait 1s when fully synced + finally: + heartbeat_task.cancel() + + except SyncAuthError as e: + logger.error(f"Authentication error: {e}") + self.backoff.fail() + if "expired" in str(e).lower(): + logger.error("Subscription expired. Stopping.") + break + + except SyncConnectionError as e: + logger.warning(f"Connection error: {e}") + self.backoff.fail() + + except Exception as e: + logger.error(f"Sync error: {e}", exc_info=True) + self.backoff.fail() + + finally: + if self._client: + await self._client.disconnect() + self._client = None + + finally: + watch_task.cancel() + self._running = False + logger.info("Sync daemon stopped") + + async def stop(self) -> None: + """Gracefully stop the daemon.""" + self._running = False + if self._client: + await self._client.disconnect() diff --git a/obsidian_sync/websocket_client.py b/obsidian_sync/websocket_client.py @@ -0,0 +1,531 @@ +""" +Obsidian Sync WebSocket client. + +Reversed from Obsidian 1.11.7 app.js (N9 class). + +Protocol: + 1. Connect to wss://<host> (host comes from vault.host in API response) + 2. Send init message: + {"op":"init", "token":<token>, "id":<vault_id>, "keyhash":<keyhash>, + "version":<last_uid>, "initial":<bool>, "device":<device_name>, + "encryption_version":<int>} + 3. Receive response: {"res":"ok", "version":<uid>, "userId":<int>, "perFileMax":<int>} + OR {"res":"err", ...} / {"status":"err", ...} + 4. After successful init, server sends "ready" message: + {"op":"ready", "version":<uid>} + 5. Server may push file updates: {"op":"push", "path":<enc_path>, "hash":<hash>, ...} + +Operations (sent as JSON): + pull: {"op":"pull", "uid":<int>} + -> response: {"size":<int>, "pieces":<int>, "deleted":<bool>, ...} + -> then <pieces> binary ArrayBuffer messages with the file data + + push: {"op":"push", "path":<enc>, "relatedpath":<enc|null>, "extension":<str>, + "hash":<hash>, "ctime":<int>, "mtime":<int>, "folder":<bool>, + "deleted":<bool>, "size":<int>, "pieces":<int>} + -> response: {"res":"ok"} or chunk acknowledgements + -> then send <pieces> binary chunks of 2MB each, waiting for {"res":"ok"} after each + + deleted: {"op":"deleted", "suppressrenames":true} -> {items:[{uid, path, ...}]} + history: {"op":"history", "path":<enc>, "last":<uid>} -> {items:[...]} + restore: {"op":"restore", "uid":<int>} -> {res:"ok"} + usernames:{"op":"usernames"} -> {usernames:[...]} + purge: {"op":"purge"} -> {res:"ok"} + size: {"op":"size"} -> {size:<int>, limit:<int>, vault_size:<int>} + ping: {"op":"ping"} -> {"op":"pong"} + +File data in pull/push is encrypted (encrypt/decrypt via encryption provider). +File paths are encoded deterministically (deterministicEncodeStr/deterministicDecodeStr). +""" + +from __future__ import annotations + +import asyncio +import json +import hashlib +import os +from typing import Optional, Callable, Any +from websockets.asyncio.client import connect as ws_connect +from websockets.protocol import State as WsState +from websockets.exceptions import ConnectionClosed + +from .crypto import EncryptionV0, EncryptionV2, create_encryption_provider + +CHUNK_SIZE = 2 * 1024 * 1024 # 2MB chunks per push +HEARTBEAT_INTERVAL = 20 # seconds +HEARTBEAT_TIMEOUT = 120 # disconnect if no message for this long +REQUEST_TIMEOUT = 60 # seconds + + +def _get_ws_url(host: str) -> str: + """Convert host to WebSocket URL. Matches getHost() in app.js.""" + if host.startswith("127.0.0.1") or host.startswith("localhost"): + return f"ws://{host}" + return f"wss://{host}" + + +def _validate_host(url: str) -> bool: + """Validate that the host ends with .obsidian.md or is localhost. + Matches the security check in connect() in app.js. + """ + from urllib.parse import urlparse + parsed = urlparse(url) + hostname = parsed.hostname or "" + return hostname.endswith(".obsidian.md") or hostname == "127.0.0.1" + + +class SyncError(Exception): + pass + + +class SyncConnectionError(SyncError): + pass + + +class SyncAuthError(SyncError): + pass + + +class ObsidianSyncClient: + """ + WebSocket client for Obsidian Sync. + + Manages connection, authentication, and file sync operations. + """ + + def __init__( + self, + host: str, + token: str, + vault_id: str, + encryption_provider: EncryptionV0 | EncryptionV2, + device_name: str = "obsidian-sync-client", + last_version: int = 0, + initial: bool = True, + ): + self.host = host + self.token = token + self.vault_id = vault_id + self.enc = encryption_provider + self.device_name = device_name + self.last_version = last_version + self.initial = initial + + self._ws = None # websockets connection + self._response_queue: asyncio.Queue = asyncio.Queue() + self._data_queue: asyncio.Queue = asyncio.Queue() + self._push_callbacks: list[Callable] = [] + self._ready_callbacks: list[Callable] = [] + self._running = False + self._receive_task: Optional[asyncio.Task] = None + self.user_id: int = -1 + self.per_file_max: int = 200 * 1024 * 1024 # default 200MB + self.last_message_ts: float = 0 + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + async def connect(self) -> None: + """Connect and authenticate with the sync server.""" + url = _get_ws_url(self.host) + if not _validate_host(url): + raise SyncConnectionError(f"Invalid sync host: {self.host}") + + try: + self._ws = await ws_connect( + url, + ping_interval=None, # We handle heartbeats manually + close_timeout=10, + max_size=256 * 1024 * 1024, # 256MB max message + ) + except Exception as e: + raise SyncConnectionError(f"Unable to connect to server: {e}") from e + + # Send init message + init_msg = { + "op": "init", + "token": self.token, + "id": self.vault_id, + "keyhash": self.enc.key_hash, + "version": self.last_version, + "initial": self.initial, + "device": self.device_name, + "encryption_version": self.enc.encryption_version, + } + await self._ws.send(json.dumps(init_msg)) + + # Wait for auth response + try: + raw = await asyncio.wait_for(self._ws.recv(), timeout=REQUEST_TIMEOUT) + except asyncio.TimeoutError: + raise SyncConnectionError("Timeout waiting for auth response") + + if not isinstance(raw, str): + raise SyncConnectionError("Server returned binary instead of JSON auth response") + + try: + msg = json.loads(raw) + except json.JSONDecodeError as e: + raise SyncConnectionError(f"Server JSON failed to parse: {raw}") from e + + if msg.get("op") == "pong": + # Unexpected pong, try again + raw = await asyncio.wait_for(self._ws.recv(), timeout=REQUEST_TIMEOUT) + msg = json.loads(raw) + + if msg.get("status") == "err" or msg.get("res") == "err": + err = msg.get("msg", "Authentication failed") + if "expired" in err: + raise SyncAuthError(f"Subscription expired: {err}") + raise SyncAuthError(f"Failed to authenticate: {err}") + + if msg.get("res") != "ok": + raise SyncConnectionError(f"Did not respond to login request: {raw}") + + # Parse server capabilities + if "perFileMax" in msg: + val = msg["perFileMax"] + if isinstance(val, int) and val >= 0: + self.per_file_max = val + + if msg.get("userId"): + self.user_id = msg["userId"] + + # Start background receive loop + self._running = True + self._receive_task = asyncio.create_task(self._receive_loop()) + + async def disconnect(self) -> None: + """Disconnect from the server.""" + self._running = False + if self._receive_task: + self._receive_task.cancel() + try: + await self._receive_task + except asyncio.CancelledError: + pass + self._receive_task = None + if self._ws: + await self._ws.close() + self._ws = None + # Drain queues with disconnect errors + error = SyncConnectionError("Disconnected") + try: + while True: + future = self._response_queue.get_nowait() + if hasattr(future, 'set_exception'): + future.set_exception(error) + except asyncio.QueueEmpty: + pass + + def is_connected(self) -> bool: + return self._ws is not None and self._ws.state == WsState.OPEN + + # ------------------------------------------------------------------ + # Internal message handling + # ------------------------------------------------------------------ + + async def _receive_loop(self) -> None: + """Background task to receive and dispatch messages.""" + import time + if self._ws is None: + return + try: + async for raw in self._ws: + self.last_message_ts = time.time() + if isinstance(raw, str): + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + op = msg.get("op") + if op == "pong": + continue + elif op == "ready": + version = msg.get("version", 0) + for cb in self._ready_callbacks: + asyncio.create_task(cb(version)) + elif op == "push": + # Server is pushing a file update to us + asyncio.create_task(self._handle_server_push(msg)) + else: + # Response to a request we made + await self._response_queue.put(msg) + else: + # Binary data + await self._data_queue.put(raw) + except ConnectionClosed: + pass + except Exception: + pass + finally: + self._running = False + + async def _handle_server_push(self, msg: dict) -> None: + """Handle a server-pushed file update.""" + # Decode encrypted path + try: + path = await asyncio.get_event_loop().run_in_executor( + None, self.enc.deterministic_decode_str, msg.get("path", "") + ) + msg["path"] = path + + if msg.get("hash"): + decoded_hash = await asyncio.get_event_loop().run_in_executor( + None, self.enc.deterministic_decode_str, msg["hash"] + ) + msg["hash"] = decoded_hash + except Exception: + pass # Best-effort decoding + + for cb in self._push_callbacks: + try: + await cb(msg) + except Exception: + pass + + async def _get_response(self, timeout: float = REQUEST_TIMEOUT) -> dict: + try: + return await asyncio.wait_for(self._response_queue.get(), timeout=timeout) + except asyncio.TimeoutError: + await self.disconnect() + raise SyncError("Request timed out") + + async def _get_data(self, timeout: float = REQUEST_TIMEOUT) -> bytes: + try: + return await asyncio.wait_for(self._data_queue.get(), timeout=timeout) + except asyncio.TimeoutError: + await self.disconnect() + raise SyncError("Data receive timed out") + + async def _send(self, msg: dict) -> None: + if not self._ws: + raise SyncConnectionError("Not connected") + await self._ws.send(json.dumps(msg)) + + async def _send_binary(self, data: bytes) -> None: + if not self._ws: + raise SyncConnectionError("Not connected") + await self._ws.send(data) + + async def _send_ping(self) -> None: + await self._send({"op": "ping"}) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def on_push(self, callback: Callable) -> None: + """Register callback for server-pushed file updates.""" + self._push_callbacks.append(callback) + + def on_ready(self, callback: Callable) -> None: + """Register callback for when server sends 'ready' (after initial sync version exchange).""" + self._ready_callbacks.append(callback) + + async def pull(self, uid: int) -> Optional[bytes]: + """Download a file version by UID. Returns decrypted content or None if deleted.""" + await self._send({"op": "pull", "uid": uid}) + response = await self._get_response() + + if response.get("deleted"): + return None + + size = response.get("size", 0) + pieces = response.get("pieces", 0) + + if size == 0 or pieces == 0: + return b"" + + # Receive all binary pieces + buffer = bytearray(size) + offset = 0 + for _ in range(pieces): + chunk = await self._get_data() + n = len(chunk) + buffer[offset:offset + n] = chunk + offset += n + + encrypted = bytes(buffer) + if len(encrypted) == 0: + return b"" + + # Decrypt + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self.enc.decrypt, encrypted) + + async def push( + self, + path: str, + data: Optional[bytes], + related_path: Optional[str] = None, + folder: bool = False, + deleted: bool = False, + ctime: int = 0, + mtime: int = 0, + file_hash: str = "", + ) -> None: + """Upload or delete a file. + + - For folders or deletions, data should be None. + - file_hash should be hex(SHA-256(raw_data)) before encryption. + """ + loop = asyncio.get_event_loop() + + # Encode path deterministically + enc_path = await loop.run_in_executor( + None, self.enc.deterministic_encode_str, path + ) + + enc_related = None + if related_path: + enc_related = await loop.run_in_executor( + None, self.enc.deterministic_encode_str, related_path + ) + + # Get extension + if not folder: + ext = path.rsplit(".", 1)[-1] if "." in path.split("/")[-1] else "" + else: + ext = "" + + if folder or deleted or data is None: + # Folder or delete - no content + await self._send({ + "op": "push", + "path": enc_path, + "relatedpath": enc_related, + "extension": ext, + "hash": "", + "ctime": 0, + "mtime": 0, + "folder": folder, + "deleted": deleted, + }) + await self._get_response() + return + + # Encrypt content + encrypted = await loop.run_in_executor(None, self.enc.encrypt, data) + + # Encode hash + enc_hash = "" + if file_hash: + enc_hash = await loop.run_in_executor( + None, self.enc.deterministic_encode_str, file_hash + ) + + size = len(encrypted) + pieces = max(1, (size + CHUNK_SIZE - 1) // CHUNK_SIZE) if size > 0 else 1 + + await self._send({ + "op": "push", + "path": enc_path, + "relatedpath": enc_related, + "extension": ext, + "hash": enc_hash, + "ctime": ctime, + "mtime": mtime, + "folder": False, + "deleted": False, + "size": size, + "pieces": pieces, + }) + + response = await self._get_response() + + # If server says ok already, we're done + if response.get("res") == "ok": + return + + # Send binary chunks + for i in range(pieces): + start = i * CHUNK_SIZE + end = min(start + CHUNK_SIZE, size) + chunk = encrypted[start:end] + await self._send_binary(bytes(chunk)) + await self._get_response() # Wait for ack per chunk + + async def list_deleted(self) -> list[dict]: + """List deleted/renamed files on the server.""" + await self._send({"op": "deleted", "suppressrenames": True}) + response = await self._get_response() + items = response.get("items", []) + loop = asyncio.get_event_loop() + for item in items: + try: + item["path"] = await loop.run_in_executor( + None, self.enc.deterministic_decode_str, item["path"] + ) + except Exception: + pass + return items + + async def list_history(self, path: str, last: int = 0) -> dict: + """List version history for a file.""" + loop = asyncio.get_event_loop() + enc_path = await loop.run_in_executor( + None, self.enc.deterministic_encode_str, path + ) + await self._send({"op": "history", "path": enc_path, "last": last}) + response = await self._get_response() + + for item in response.get("items", []): + try: + item["path"] = await loop.run_in_executor( + None, self.enc.deterministic_decode_str, item.get("path", "") + ) + except Exception: + pass + if item.get("relatedpath"): + try: + item["relatedpath"] = await loop.run_in_executor( + None, self.enc.deterministic_decode_str, item["relatedpath"] + ) + except Exception: + pass + return response + + async def restore(self, uid: int) -> None: + """Restore a file to a previous version by UID.""" + await self._send({"op": "restore", "uid": uid}) + await self._get_response() + + async def get_usernames(self) -> dict: + """Get usernames of vault collaborators.""" + await self._send({"op": "usernames"}) + return await self._get_response() + + async def purge(self) -> None: + """Purge version history.""" + await self._send({"op": "purge"}) + await self._get_response() + + async def get_size(self) -> dict: + """Get storage usage. Returns {size, limit, vault_size}.""" + await self._send({"op": "size"}) + response = await self._get_response() + return { + "size": response.get("size", -1), + "limit": response.get("limit", -1), + "vault_size": response.get("vault_size", -1), + } + + async def heartbeat_loop(self) -> None: + """Run periodic heartbeats (call as background task).""" + import time + while self._running and self.is_connected(): + await asyncio.sleep(HEARTBEAT_INTERVAL) + if not self.is_connected(): + break + elapsed = time.time() - self.last_message_ts + if elapsed > HEARTBEAT_TIMEOUT: + await self.disconnect() + break + elif elapsed > 10: + try: + await self._send_ping() + except Exception: + break diff --git a/setup.py b/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup, find_packages + +setup( + name="obsidian-sync", + version="0.1.0", + description="Lightweight Obsidian Sync client daemon (reversed from v1.11.7)", + packages=find_packages(), + python_requires=">=3.11", + install_requires=[ + "websockets>=16.0", + "aiohttp>=3.9", + "aiofiles>=23.0", + "cryptography>=41.0", + ], + entry_points={ + "console_scripts": [ + "obsidian-sync=cli:main", + ], + }, +) diff --git a/setup_tui.py b/setup_tui.py @@ -0,0 +1,1046 @@ +#!/usr/bin/env python3 +""" +Obsidian Sync Setup TUI +~~~~~~~~~~~~~~~~~~~~~~~ +Interactive setup wizard for obsidian-sync using Textual. + +Screens: + MainMenu -> Login, Vaults, Service, Quit + Login -> Email / Password / Sign In + Vaults -> List configured vaults; Add / Edit / Remove + AddVault -> Pick server vault, local path, vault password, device name + Service -> Install / show status of systemd service +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +from textual import on, work +from textual.app import App, ComposeResult, Screen +from textual.binding import Binding +from textual.containers import Center, Horizontal, Vertical, ScrollableContainer +from textual.css.query import NoMatches +from textual.reactive import reactive +from textual.screen import ModalScreen +from textual.widgets import ( + Button, + Footer, + Header, + Input, + Label, + ListItem, + ListView, + LoadingIndicator, + Markdown, + Rule, + Static, + Switch, +) + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +CONFIG_DIR = Path.home() / ".config" / "obsidian-sync" +CREDENTIALS_FILE = CONFIG_DIR / "credentials.json" +VAULTS_FILE = CONFIG_DIR / "vaults.json" +SERVICE_SRC = Path(__file__).parent / "obsidian-sync@.service" +SERVICE_SYSTEM_DIR = Path("/etc/systemd/system") +SERVICE_USER_DIR = Path.home() / ".config" / "systemd" / "user" + + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + +def load_credentials() -> dict: + if CREDENTIALS_FILE.exists(): + try: + return json.loads(CREDENTIALS_FILE.read_text()) + except Exception: + pass + return {} + + +def save_credentials(data: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CREDENTIALS_FILE.write_text(json.dumps(data, indent=2)) + CREDENTIALS_FILE.chmod(0o600) + + +def load_vaults_config() -> list[dict]: + if VAULTS_FILE.exists(): + try: + return json.loads(VAULTS_FILE.read_text()) + except Exception: + pass + return [] + + +def save_vaults_config(vaults: list[dict]) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + VAULTS_FILE.write_text(json.dumps(vaults, indent=2)) + VAULTS_FILE.chmod(0o600) + + +def vault_config_path(vault_name: str) -> Path: + safe = vault_name.replace(" ", "_").replace("/", "_") + return CONFIG_DIR / f"{safe}.json" + + +def write_vault_config(entry: dict) -> Path: + path = vault_config_path(entry["vault_name"]) + path.write_text(json.dumps(entry["config"], indent=2)) + path.chmod(0o600) + return path + + +# --------------------------------------------------------------------------- +# Systemd helpers +# --------------------------------------------------------------------------- + +def systemd_available() -> bool: + try: + result = subprocess.run( + ["systemctl", "--version"], capture_output=True, timeout=3 + ) + return result.returncode == 0 + except Exception: + return False + + +def systemd_running() -> bool: + try: + result = subprocess.run( + ["systemctl", "is-system-running"], capture_output=True, timeout=3 + ) + return True # even degraded is running + except Exception: + return False + + +def service_installed(username: str) -> bool: + system_path = SERVICE_SYSTEM_DIR / f"obsidian-sync@{username}.service" + user_path = SERVICE_USER_DIR / "obsidian-sync.service" + return system_path.exists() or user_path.exists() + + +def install_service_user() -> tuple[bool, str]: + """Install as a systemd user service.""" + try: + SERVICE_USER_DIR.mkdir(parents=True, exist_ok=True) + dest = SERVICE_USER_DIR / "obsidian-sync.service" + # Write a user-variant unit (no %i substitution needed) + username = os.environ.get("USER", "user") + unit = f"""[Unit] +Description=Obsidian Sync Daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={shutil.which('python3') or 'python3'} {Path(__file__).parent / 'cli.py'} run {CONFIG_DIR / 'vault.json'} +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=obsidian-sync + +[Install] +WantedBy=default.target +""" + dest.write_text(unit) + return True, str(dest) + except Exception as e: + return False, str(e) + + +def install_service_system(username: str) -> tuple[bool, str]: + """Install as a systemd system service (requires sudo).""" + try: + dest = SERVICE_SYSTEM_DIR / f"obsidian-sync@{username}.service" + src = SERVICE_SRC + if not src.exists(): + return False, f"Service template not found: {src}" + result = subprocess.run( + ["sudo", "cp", str(src), str(dest)], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return False, result.stderr.strip() + subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True, timeout=10) + return True, str(dest) + except Exception as e: + return False, str(e) + + +def service_status(service_name: str) -> str: + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", service_name], + capture_output=True, text=True, timeout=3, + ) + return result.stdout.strip() or "unknown" + except Exception: + return "unknown" + + +# --------------------------------------------------------------------------- +# CSS +# --------------------------------------------------------------------------- + +CSS = """ +Screen { + background: $background; +} + +#title-bar { + height: 3; + background: $primary; + content-align: center middle; + color: $text; + text-style: bold; +} + +.screen-container { + margin: 1 2; +} + +.section-title { + text-style: bold; + color: $accent; + margin-bottom: 1; +} + +.field-label { + margin-top: 1; + color: $text-muted; +} + +.status-ok { + color: $success; +} + +.status-err { + color: $error; +} + +.status-warn { + color: $warning; +} + +.status-box { + height: 3; + content-align: left middle; + padding: 0 1; + margin-top: 1; +} + +.action-row { + height: 3; + margin-top: 1; +} + +.btn-primary { + margin-right: 1; +} + +.vault-item { + height: 3; + padding: 0 1; +} + +.vault-name { + text-style: bold; +} + +.vault-meta { + color: $text-muted; +} + +MainMenuScreen { + align: center middle; +} + +#menu-box { + width: 50; + height: auto; + border: round $primary; + padding: 1 2; +} + +#menu-title { + text-align: center; + text-style: bold; + color: $accent; + margin-bottom: 1; +} + +#menu-subtitle { + text-align: center; + color: $text-muted; + margin-bottom: 1; +} + +.menu-btn { + width: 100%; + margin-bottom: 1; +} + +ConfirmScreen { + align: center middle; +} + +#confirm-box { + width: 60; + height: auto; + border: round $warning; + padding: 1 2; +} + +#confirm-message { + margin-bottom: 1; +} + +LoadingScreen { + align: center middle; +} +""" + + +# --------------------------------------------------------------------------- +# Confirm modal +# --------------------------------------------------------------------------- + +class ConfirmScreen(ModalScreen[bool]): + BINDINGS = [ + Binding("escape", "dismiss(False)", "Cancel"), + Binding("enter", "confirm", "Confirm"), + ] + + def __init__(self, message: str, **kwargs): + super().__init__(**kwargs) + self._message = message + + def compose(self) -> ComposeResult: + with Vertical(id="confirm-box"): + yield Label(self._message, id="confirm-message") + with Horizontal(classes="action-row"): + yield Button("Yes", variant="error", id="yes-btn") + yield Button("No", variant="default", id="no-btn") + + @on(Button.Pressed, "#yes-btn") + def _yes(self) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#no-btn") + def _no(self) -> None: + self.dismiss(False) + + def action_confirm(self) -> None: + self.dismiss(True) + + +# --------------------------------------------------------------------------- +# Main Menu +# --------------------------------------------------------------------------- + +class MainMenuScreen(Screen): + BINDINGS = [Binding("q", "app.quit", "Quit")] + + def compose(self) -> ComposeResult: + creds = load_credentials() + logged_in = bool(creds.get("token")) + subtitle = ( + f"Logged in as {creds.get('email', '?')}" + if logged_in + else "Not logged in" + ) + + with Vertical(id="menu-box"): + yield Label("Obsidian Sync Setup", id="menu-title") + yield Label(subtitle, id="menu-subtitle", + classes="status-ok" if logged_in else "status-warn") + yield Rule() + yield Button(" Login / Account", classes="menu-btn", id="btn-login") + yield Button(" Vault Configuration", classes="menu-btn", id="btn-vaults") + yield Button(" Systemd Service", classes="menu-btn", id="btn-service") + yield Rule() + yield Button(" Quit", classes="menu-btn", variant="default", id="btn-quit") + + yield Footer() + + @on(Button.Pressed, "#btn-login") + def _login(self) -> None: + self.app.push_screen(LoginScreen()) + + @on(Button.Pressed, "#btn-vaults") + def _vaults(self) -> None: + self.app.push_screen(VaultsScreen()) + + @on(Button.Pressed, "#btn-service") + def _service(self) -> None: + self.app.push_screen(ServiceScreen()) + + @on(Button.Pressed, "#btn-quit") + def _quit(self) -> None: + self.app.exit() + + def on_screen_resume(self) -> None: + # Refresh subtitle when returning from child screens + try: + creds = load_credentials() + logged_in = bool(creds.get("token")) + subtitle = self.query_one("#menu-subtitle", Label) + subtitle.update( + f"Logged in as {creds.get('email', '?')}" + if logged_in + else "Not logged in" + ) + subtitle.remove_class("status-ok", "status-warn") + subtitle.add_class("status-ok" if logged_in else "status-warn") + except NoMatches: + pass + + +# --------------------------------------------------------------------------- +# Login Screen +# --------------------------------------------------------------------------- + +class LoginScreen(Screen): + BINDINGS = [Binding("escape", "app.pop_screen", "Back")] + + def compose(self) -> ComposeResult: + creds = load_credentials() + yield Header(show_clock=False) + + with ScrollableContainer(classes="screen-container"): + yield Label("Account Login", classes="section-title") + + if creds.get("token"): + yield Static( + f"Currently logged in as {creds.get('name', '')} <{creds.get('email', '')}>", + classes="status-box status-ok", + ) + yield Button("Log out", variant="error", id="btn-logout") + yield Rule() + + yield Label("Email", classes="field-label") + yield Input( + value=creds.get("email", ""), + placeholder="you@example.com", + id="input-email", + ) + + yield Label("Password", classes="field-label") + yield Input( + placeholder="Vault / account password", + password=True, + id="input-password", + ) + + yield Label("MFA code (leave blank if not enabled)", classes="field-label") + yield Input(placeholder="123456", id="input-mfa") + + with Horizontal(classes="action-row"): + yield Button("Sign In", variant="primary", id="btn-signin", + classes="btn-primary") + yield Button("Back", variant="default", id="btn-back") + + yield Static("", id="status-label", classes="status-box") + + yield Footer() + + @on(Button.Pressed, "#btn-signin") + def _signin(self) -> None: + email = self.query_one("#input-email", Input).value.strip() + password = self.query_one("#input-password", Input).value + mfa = self.query_one("#input-mfa", Input).value.strip() + if not email or not password: + self._set_status("Email and password are required.", error=True) + return + self._do_signin(email, password, mfa) + + @work(exclusive=True) + async def _do_signin(self, email: str, password: str, mfa: str) -> None: + self._set_status("Signing in…") + btn = self.query_one("#btn-signin", Button) + btn.disabled = True + try: + from obsidian_sync.api import signin, ObsidianAPIError + session = await signin(email, password, mfa) + save_credentials({ + "token": session.token, + "email": session.email, + "name": session.name, + "license": session.license, + }) + self._set_status( + f"Signed in as {session.name} <{session.email}>", + error=False, + ) + except Exception as e: + self._set_status(f"Error: {e}", error=True) + finally: + btn.disabled = False + + @on(Button.Pressed, "#btn-logout") + def _logout(self) -> None: + self._do_logout() + + @work(exclusive=True) + async def _do_logout(self) -> None: + self._set_status("Signing out…") + try: + from obsidian_sync.api import signout + creds = load_credentials() + if creds.get("token"): + await signout(creds["token"]) + if CREDENTIALS_FILE.exists(): + CREDENTIALS_FILE.unlink() + self._set_status("Logged out.") + # Refresh the logged-in banner + try: + self.query_one("#btn-logout").remove() + except NoMatches: + pass + except Exception as e: + self._set_status(f"Error: {e}", error=True) + + @on(Button.Pressed, "#btn-back") + def _back(self) -> None: + self.app.pop_screen() + + def _set_status(self, msg: str, error: Optional[bool] = None) -> None: + try: + lbl = self.query_one("#status-label", Static) + lbl.update(msg) + lbl.remove_class("status-ok", "status-err") + if error is True: + lbl.add_class("status-err") + elif error is False: + lbl.add_class("status-ok") + except NoMatches: + pass + + @on(Input.Submitted) + def _submit(self, event: Input.Submitted) -> None: + if event.input.id in ("input-email", "input-password", "input-mfa"): + self._signin() + + +# --------------------------------------------------------------------------- +# Vaults Screen +# --------------------------------------------------------------------------- + +class VaultsScreen(Screen): + BINDINGS = [Binding("escape", "app.pop_screen", "Back")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with ScrollableContainer(classes="screen-container"): + yield Label("Vault Configuration", classes="section-title") + yield Static( + "Configure which Obsidian vaults to sync and where to store them locally.", + classes="vault-meta", + ) + yield Rule() + yield Static("", id="vault-list-container") + yield Rule() + with Horizontal(classes="action-row"): + yield Button("Add Vault", variant="primary", id="btn-add", + classes="btn-primary") + yield Button("Back", variant="default", id="btn-back") + yield Footer() + + def on_mount(self) -> None: + self._refresh_list() + + def on_screen_resume(self) -> None: + self._refresh_list() + + def _refresh_list(self) -> None: + vaults = load_vaults_config() + container = self.query_one("#vault-list-container", Static) + if not vaults: + container.update("No vaults configured yet. Press [bold]Add Vault[/bold] to get started.") + return + + lines = [] + for i, v in enumerate(vaults): + name = v.get("vault_name", "?") + path = v.get("config", {}).get("vault_path", "?") + enc = v.get("config", {}).get("encryption_version", "?") + host = v.get("config", {}).get("vault_host", "?") + lines.append( + f"[bold]{i+1}. {name}[/bold] [dim](enc v{enc})[/dim]\n" + f" Path: {path}\n" + f" Host: {host}" + ) + container.update("\n\n".join(lines)) + + # Rebuild edit/remove buttons dynamically + try: + for btn in self.query(".vault-action-btn"): + btn.remove() + except Exception: + pass + + @on(Button.Pressed, "#btn-add") + def _add(self) -> None: + creds = load_credentials() + if not creds.get("token"): + self.app.push_screen( + InfoScreen("Not logged in", "Please log in first via the Account screen.") + ) + return + self.app.push_screen(AddVaultScreen()) + + @on(Button.Pressed, "#btn-back") + def _back(self) -> None: + self.app.pop_screen() + + +# --------------------------------------------------------------------------- +# Add / Edit Vault Screen +# --------------------------------------------------------------------------- + +class AddVaultScreen(Screen): + BINDINGS = [Binding("escape", "app.pop_screen", "Back")] + + _server_vaults: reactive[list] = reactive([]) + _selected_idx: reactive[int] = reactive(0) + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with ScrollableContainer(classes="screen-container"): + yield Label("Add Vault", classes="section-title") + + yield Label("Step 1 — Select a vault from your account", classes="field-label") + yield Static("Loading vaults from server…", id="vault-picker") + + yield Rule() + yield Label("Step 2 — Local sync path", classes="field-label") + yield Input( + placeholder=str(Path.home() / "vaults" / "my-vault"), + id="input-path", + ) + + yield Label("Step 3 — Vault encryption password", classes="field-label") + yield Input( + placeholder="The password you set in Obsidian Sync", + password=True, + id="input-vaultpwd", + ) + + yield Label("Device name (shown in Obsidian sync log)", classes="field-label") + yield Input( + value="obsidian-sync-daemon", + id="input-device", + ) + + yield Label("Sync options", classes="field-label") + with Horizontal(): + yield Label("Images ", classes="vault-meta") + yield Switch(id="sw-images") + yield Label(" Audio ", classes="vault-meta") + yield Switch(id="sw-audio") + yield Label(" Video ", classes="vault-meta") + yield Switch(id="sw-video") + yield Label(" PDF ", classes="vault-meta") + yield Switch(id="sw-pdf") + + with Horizontal(classes="action-row"): + yield Button("Save Vault", variant="primary", id="btn-save", + classes="btn-primary") + yield Button("Back", variant="default", id="btn-back") + + yield Static("", id="add-status", classes="status-box") + + yield Footer() + + def on_mount(self) -> None: + self._load_server_vaults() + + @work(exclusive=True) + async def _load_server_vaults(self) -> None: + creds = load_credentials() + try: + from obsidian_sync.api import list_vaults + vaults = await list_vaults(creds["token"]) + self._server_vaults = vaults + + if not vaults: + self.query_one("#vault-picker", Static).update("No vaults found on server.") + return + + # Build a simple numbered list + lines = [] + for i, v in enumerate(vaults): + marker = "▶ " if i == self._selected_idx else " " + lines.append(f"{marker}[{i}] {v.name} [dim](enc v{v.encryption_version}, {v.host})[/dim]") + + self.query_one("#vault-picker", Static).update("\n".join(lines)) + + # Pre-fill path with vault name + if vaults: + default_path = str(Path.home() / "vaults" / vaults[0].name) + self.query_one("#input-path", Input).value = default_path + + except Exception as e: + self.query_one("#vault-picker", Static).update(f"[red]Error loading vaults: {e}[/red]") + + def _update_picker(self) -> None: + vaults = self._server_vaults + if not vaults: + return + lines = [] + for i, v in enumerate(vaults): + marker = "▶ " if i == self._selected_idx else " " + lines.append(f"{marker}[{i}] {v.name} [dim](enc v{v.encryption_version}, {v.host})[/dim]") + try: + self.query_one("#vault-picker", Static).update("\n".join(lines)) + except NoMatches: + pass + + def on_key(self, event) -> None: + if not self._server_vaults: + return + if event.key == "up": + self._selected_idx = max(0, self._selected_idx - 1) + self._update_picker() + # Update path suggestion + v = self._server_vaults[self._selected_idx] + self.query_one("#input-path", Input).value = str(Path.home() / "vaults" / v.name) + elif event.key == "down": + self._selected_idx = min(len(self._server_vaults) - 1, self._selected_idx + 1) + self._update_picker() + v = self._server_vaults[self._selected_idx] + self.query_one("#input-path", Input).value = str(Path.home() / "vaults" / v.name) + + @on(Button.Pressed, "#btn-save") + def _save(self) -> None: + if not self._server_vaults: + self._set_status("No server vaults loaded.", error=True) + return + vault = self._server_vaults[self._selected_idx] + local_path = self.query_one("#input-path", Input).value.strip() + vault_pwd = self.query_one("#input-vaultpwd", Input).value + device = self.query_one("#input-device", Input).value.strip() or "obsidian-sync-daemon" + + if not local_path: + self._set_status("Local path is required.", error=True) + return + if not vault_pwd: + self._set_status("Vault password is required.", error=True) + return + + creds = load_credentials() + + config = { + "token": creds.get("token", ""), + "vault_id": vault.id, + "vault_host": vault.host, + "vault_password": vault_pwd, + "vault_salt": vault.salt, + "encryption_version": vault.encryption_version, + "vault_path": local_path, + "device_name": device, + "sync_images": self.query_one("#sw-images", Switch).value, + "sync_audio": self.query_one("#sw-audio", Switch).value, + "sync_video": self.query_one("#sw-video", Switch).value, + "sync_pdf": self.query_one("#sw-pdf", Switch).value, + "sync_app_settings": True, + "ignore_folders": [], + "state_file": "", + } + + entry = { + "vault_name": vault.name, + "vault_id": vault.id, + "config": config, + } + + # Validate encryption: derive key (will raise if password is wrong type) + self._validate_and_save(entry) + + @work(exclusive=True) + async def _validate_and_save(self, entry: dict) -> None: + self._set_status("Validating encryption key…") + btn = self.query_one("#btn-save", Button) + btn.disabled = True + try: + from obsidian_sync.crypto import create_encryption_provider + enc = create_encryption_provider( + entry["config"]["vault_password"], + entry["config"]["vault_salt"], + entry["config"]["encryption_version"], + ) + # Optionally verify keyhash matches server + server_keyhash = "" + for sv in self._server_vaults: + if sv.id == entry["vault_id"]: + server_keyhash = sv.keyhash + break + if server_keyhash and enc.key_hash != server_keyhash: + self._set_status( + "Warning: key hash does not match server. Wrong password?", + error=True, + ) + btn.disabled = False + return + + # Save config file + cfg_path = write_vault_config(entry) + + # Update vaults list + vaults = load_vaults_config() + # Replace existing entry with same vault_id + vaults = [v for v in vaults if v.get("vault_id") != entry["vault_id"]] + vaults.append(entry) + save_vaults_config(vaults) + + self._set_status( + f"Vault saved to {cfg_path}", + error=False, + ) + except Exception as e: + self._set_status(f"Error: {e}", error=True) + finally: + btn.disabled = False + + @on(Button.Pressed, "#btn-back") + def _back(self) -> None: + self.app.pop_screen() + + def _set_status(self, msg: str, error: Optional[bool] = None) -> None: + try: + lbl = self.query_one("#add-status", Static) + lbl.update(msg) + lbl.remove_class("status-ok", "status-err") + if error is True: + lbl.add_class("status-err") + elif error is False: + lbl.add_class("status-ok") + except NoMatches: + pass + + +# --------------------------------------------------------------------------- +# Service Screen +# --------------------------------------------------------------------------- + +SERVICE_HELP = """\ +The sync daemon can be installed as a **systemd user service** so it starts +automatically on login and runs in the background. + +**Install steps (run in terminal after saving):** + +``` +systemctl --user daemon-reload +systemctl --user enable obsidian-sync +systemctl --user start obsidian-sync +``` + +**View logs:** +``` +journalctl --user -u obsidian-sync -f +``` + +**Stop / disable:** +``` +systemctl --user stop obsidian-sync +systemctl --user disable obsidian-sync +``` +""" + + +class ServiceScreen(Screen): + BINDINGS = [Binding("escape", "app.pop_screen", "Back")] + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with ScrollableContainer(classes="screen-container"): + yield Label("Systemd Service", classes="section-title") + + # Detected vaults + vaults = load_vaults_config() + if not vaults: + yield Static( + "[yellow]No vaults configured. Set up a vault first.[/yellow]", + classes="status-box status-warn", + ) + else: + vault_names = ", ".join(v.get("vault_name", "?") for v in vaults) + yield Static( + f"Configured vaults: [bold]{vault_names}[/bold]", + classes="status-box", + ) + + yield Rule() + + # Service file status + user_unit = SERVICE_USER_DIR / "obsidian-sync.service" + installed = user_unit.exists() + if installed: + status_text = f"[green]Unit file installed:[/green] {user_unit}" + else: + status_text = "[yellow]Unit file not yet installed.[/yellow]" + yield Static(status_text, id="svc-status", classes="status-box") + + with Horizontal(classes="action-row"): + yield Button( + "Install / Update Unit File", + variant="primary", + id="btn-install", + classes="btn-primary", + ) + yield Button("Back", variant="default", id="btn-back") + + yield Static("", id="svc-action-status", classes="status-box") + yield Rule() + yield Markdown(SERVICE_HELP) + + yield Footer() + + @on(Button.Pressed, "#btn-install") + def _install(self) -> None: + vaults = load_vaults_config() + if not vaults: + self._set_status("No vaults configured yet.", error=True) + return + self._do_install(vaults) + + @work(exclusive=True) + async def _do_install(self, vaults: list[dict]) -> None: + self._set_status("Writing unit file…") + btn = self.query_one("#btn-install", Button) + btn.disabled = True + try: + SERVICE_USER_DIR.mkdir(parents=True, exist_ok=True) + unit_path = SERVICE_USER_DIR / "obsidian-sync.service" + + # Build ExecStart lines for each vault + python = shutil.which("python3") or "python3" + cli = str(Path(__file__).parent / "cli.py") + + if len(vaults) == 1: + cfg = vault_config_path(vaults[0]["vault_name"]) + exec_start = f"{python} {cli} run {cfg}" + else: + # Multiple vaults: write a wrapper script + wrapper = CONFIG_DIR / "run-all-vaults.sh" + lines = ["#!/bin/bash"] + for v in vaults: + cfg = vault_config_path(v["vault_name"]) + lines.append(f"{python} {cli} run {cfg} &") + lines.append("wait") + wrapper.write_text("\n".join(lines) + "\n") + wrapper.chmod(0o755) + exec_start = f"/bin/bash {wrapper}" + + unit = f"""[Unit] +Description=Obsidian Sync Daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={exec_start} +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=obsidian-sync + +[Install] +WantedBy=default.target +""" + unit_path.write_text(unit) + + # Refresh svc-status label + self.query_one("#svc-status", Static).update( + f"[green]Unit file installed:[/green] {unit_path}" + ) + self._set_status( + "Unit file written. Run the commands below to enable and start the service.", + error=False, + ) + except Exception as e: + self._set_status(f"Error: {e}", error=True) + finally: + btn.disabled = False + + @on(Button.Pressed, "#btn-back") + def _back(self) -> None: + self.app.pop_screen() + + def _set_status(self, msg: str, error: Optional[bool] = None) -> None: + try: + lbl = self.query_one("#svc-action-status", Static) + lbl.update(msg) + lbl.remove_class("status-ok", "status-err") + if error is True: + lbl.add_class("status-err") + elif error is False: + lbl.add_class("status-ok") + except NoMatches: + pass + + +# --------------------------------------------------------------------------- +# Generic info screen +# --------------------------------------------------------------------------- + +class InfoScreen(ModalScreen): + BINDINGS = [Binding("escape", "app.pop_screen", "Close"), + Binding("enter", "app.pop_screen", "Close")] + + def __init__(self, title: str, message: str, **kwargs): + super().__init__(**kwargs) + self._title = title + self._message = message + + def compose(self) -> ComposeResult: + with Vertical(id="confirm-box"): + yield Label(f"[bold]{self._title}[/bold]", id="confirm-message") + yield Static(self._message) + yield Button("OK", variant="primary", id="btn-ok") + + @on(Button.Pressed, "#btn-ok") + def _ok(self) -> None: + self.app.pop_screen() + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +class ObsidianSyncSetup(App): + CSS = CSS + TITLE = "Obsidian Sync Setup" + SCREENS = { + "main": MainMenuScreen, + "login": LoginScreen, + "vaults": VaultsScreen, + "add_vault": AddVaultScreen, + "service": ServiceScreen, + } + + def on_mount(self) -> None: + self.push_screen(MainMenuScreen()) + + +def main() -> None: + app = ObsidianSyncSetup() + app.run() + + +if __name__ == "__main__": + main()