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:
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()