obsidian-sync

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

setup_tui.py (33098B)


      1 #!/usr/bin/env python3
      2 """
      3 Obsidian Sync Setup TUI
      4 ~~~~~~~~~~~~~~~~~~~~~~~
      5 Interactive setup wizard for obsidian-sync using Textual.
      6 
      7 Screens:
      8   MainMenu  -> Login, Vaults, Service, Quit
      9   Login     -> Email / Password / Sign In
     10   Vaults    -> List configured vaults; Add / Edit / Remove
     11   AddVault  -> Pick server vault, local path, vault password, device name
     12   Service   -> Install / show status of systemd service
     13 """
     14 
     15 from __future__ import annotations
     16 
     17 import asyncio
     18 import json
     19 import os
     20 import shutil
     21 import subprocess
     22 from pathlib import Path
     23 from typing import Optional
     24 
     25 from textual import on, work
     26 from textual.app import App, ComposeResult, Screen
     27 from textual.binding import Binding
     28 from textual.containers import Center, Horizontal, Vertical, ScrollableContainer
     29 from textual.css.query import NoMatches
     30 from textual.reactive import reactive
     31 from textual.screen import ModalScreen
     32 from textual.widgets import (
     33     Button,
     34     Footer,
     35     Header,
     36     Input,
     37     Label,
     38     ListItem,
     39     ListView,
     40     LoadingIndicator,
     41     Markdown,
     42     Rule,
     43     Static,
     44     Switch,
     45 )
     46 
     47 # ---------------------------------------------------------------------------
     48 # Paths
     49 # ---------------------------------------------------------------------------
     50 CONFIG_DIR = Path.home() / ".config" / "obsidian-sync"
     51 CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
     52 VAULTS_FILE = CONFIG_DIR / "vaults.json"
     53 SERVICE_SRC = Path(__file__).parent / "obsidian-sync@.service"
     54 SERVICE_SYSTEM_DIR = Path("/etc/systemd/system")
     55 SERVICE_USER_DIR = Path.home() / ".config" / "systemd" / "user"
     56 
     57 
     58 # ---------------------------------------------------------------------------
     59 # Config helpers
     60 # ---------------------------------------------------------------------------
     61 
     62 def load_credentials() -> dict:
     63     if CREDENTIALS_FILE.exists():
     64         try:
     65             return json.loads(CREDENTIALS_FILE.read_text())
     66         except Exception:
     67             pass
     68     return {}
     69 
     70 
     71 def save_credentials(data: dict) -> None:
     72     CONFIG_DIR.mkdir(parents=True, exist_ok=True)
     73     CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
     74     CREDENTIALS_FILE.chmod(0o600)
     75 
     76 
     77 def load_vaults_config() -> list[dict]:
     78     if VAULTS_FILE.exists():
     79         try:
     80             return json.loads(VAULTS_FILE.read_text())
     81         except Exception:
     82             pass
     83     return []
     84 
     85 
     86 def save_vaults_config(vaults: list[dict]) -> None:
     87     CONFIG_DIR.mkdir(parents=True, exist_ok=True)
     88     VAULTS_FILE.write_text(json.dumps(vaults, indent=2))
     89     VAULTS_FILE.chmod(0o600)
     90 
     91 
     92 def vault_config_path(vault_name: str) -> Path:
     93     safe = vault_name.replace(" ", "_").replace("/", "_")
     94     return CONFIG_DIR / f"{safe}.json"
     95 
     96 
     97 def write_vault_config(entry: dict) -> Path:
     98     path = vault_config_path(entry["vault_name"])
     99     path.write_text(json.dumps(entry["config"], indent=2))
    100     path.chmod(0o600)
    101     return path
    102 
    103 
    104 # ---------------------------------------------------------------------------
    105 # Systemd helpers
    106 # ---------------------------------------------------------------------------
    107 
    108 def systemd_available() -> bool:
    109     try:
    110         result = subprocess.run(
    111             ["systemctl", "--version"], capture_output=True, timeout=3
    112         )
    113         return result.returncode == 0
    114     except Exception:
    115         return False
    116 
    117 
    118 def systemd_running() -> bool:
    119     try:
    120         result = subprocess.run(
    121             ["systemctl", "is-system-running"], capture_output=True, timeout=3
    122         )
    123         return True  # even degraded is running
    124     except Exception:
    125         return False
    126 
    127 
    128 def service_installed(username: str) -> bool:
    129     system_path = SERVICE_SYSTEM_DIR / f"obsidian-sync@{username}.service"
    130     user_path = SERVICE_USER_DIR / "obsidian-sync.service"
    131     return system_path.exists() or user_path.exists()
    132 
    133 
    134 def install_service_user() -> tuple[bool, str]:
    135     """Install as a systemd user service."""
    136     try:
    137         SERVICE_USER_DIR.mkdir(parents=True, exist_ok=True)
    138         dest = SERVICE_USER_DIR / "obsidian-sync.service"
    139         # Write a user-variant unit (no %i substitution needed)
    140         username = os.environ.get("USER", "user")
    141         unit = f"""[Unit]
    142 Description=Obsidian Sync Daemon
    143 After=network-online.target
    144 Wants=network-online.target
    145 
    146 [Service]
    147 Type=simple
    148 ExecStart={shutil.which('python3') or 'python3'} {Path(__file__).parent / 'cli.py'} run {CONFIG_DIR / 'vault.json'}
    149 Restart=on-failure
    150 RestartSec=10
    151 StandardOutput=journal
    152 StandardError=journal
    153 SyslogIdentifier=obsidian-sync
    154 
    155 [Install]
    156 WantedBy=default.target
    157 """
    158         dest.write_text(unit)
    159         return True, str(dest)
    160     except Exception as e:
    161         return False, str(e)
    162 
    163 
    164 def install_service_system(username: str) -> tuple[bool, str]:
    165     """Install as a systemd system service (requires sudo)."""
    166     try:
    167         dest = SERVICE_SYSTEM_DIR / f"obsidian-sync@{username}.service"
    168         src = SERVICE_SRC
    169         if not src.exists():
    170             return False, f"Service template not found: {src}"
    171         result = subprocess.run(
    172             ["sudo", "cp", str(src), str(dest)],
    173             capture_output=True, text=True, timeout=10,
    174         )
    175         if result.returncode != 0:
    176             return False, result.stderr.strip()
    177         subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True, timeout=10)
    178         return True, str(dest)
    179     except Exception as e:
    180         return False, str(e)
    181 
    182 
    183 def service_status(service_name: str) -> str:
    184     try:
    185         result = subprocess.run(
    186             ["systemctl", "--user", "is-active", service_name],
    187             capture_output=True, text=True, timeout=3,
    188         )
    189         return result.stdout.strip() or "unknown"
    190     except Exception:
    191         return "unknown"
    192 
    193 
    194 # ---------------------------------------------------------------------------
    195 # CSS
    196 # ---------------------------------------------------------------------------
    197 
    198 CSS = """
    199 Screen {
    200     background: $background;
    201 }
    202 
    203 #title-bar {
    204     height: 3;
    205     background: $primary;
    206     content-align: center middle;
    207     color: $text;
    208     text-style: bold;
    209 }
    210 
    211 .screen-container {
    212     margin: 1 2;
    213 }
    214 
    215 .section-title {
    216     text-style: bold;
    217     color: $accent;
    218     margin-bottom: 1;
    219 }
    220 
    221 .field-label {
    222     margin-top: 1;
    223     color: $text-muted;
    224 }
    225 
    226 .status-ok {
    227     color: $success;
    228 }
    229 
    230 .status-err {
    231     color: $error;
    232 }
    233 
    234 .status-warn {
    235     color: $warning;
    236 }
    237 
    238 .status-box {
    239     height: 3;
    240     content-align: left middle;
    241     padding: 0 1;
    242     margin-top: 1;
    243 }
    244 
    245 .action-row {
    246     height: 3;
    247     margin-top: 1;
    248 }
    249 
    250 .btn-primary {
    251     margin-right: 1;
    252 }
    253 
    254 .vault-item {
    255     height: 3;
    256     padding: 0 1;
    257 }
    258 
    259 .vault-name {
    260     text-style: bold;
    261 }
    262 
    263 .vault-meta {
    264     color: $text-muted;
    265 }
    266 
    267 MainMenuScreen {
    268     align: center middle;
    269 }
    270 
    271 #menu-box {
    272     width: 50;
    273     height: auto;
    274     border: round $primary;
    275     padding: 1 2;
    276 }
    277 
    278 #menu-title {
    279     text-align: center;
    280     text-style: bold;
    281     color: $accent;
    282     margin-bottom: 1;
    283 }
    284 
    285 #menu-subtitle {
    286     text-align: center;
    287     color: $text-muted;
    288     margin-bottom: 1;
    289 }
    290 
    291 .menu-btn {
    292     width: 100%;
    293     margin-bottom: 1;
    294 }
    295 
    296 ConfirmScreen {
    297     align: center middle;
    298 }
    299 
    300 #confirm-box {
    301     width: 60;
    302     height: auto;
    303     border: round $warning;
    304     padding: 1 2;
    305 }
    306 
    307 #confirm-message {
    308     margin-bottom: 1;
    309 }
    310 
    311 LoadingScreen {
    312     align: center middle;
    313 }
    314 """
    315 
    316 
    317 # ---------------------------------------------------------------------------
    318 # Confirm modal
    319 # ---------------------------------------------------------------------------
    320 
    321 class ConfirmScreen(ModalScreen[bool]):
    322     BINDINGS = [
    323         Binding("escape", "dismiss(False)", "Cancel"),
    324         Binding("enter", "confirm", "Confirm"),
    325     ]
    326 
    327     def __init__(self, message: str, **kwargs):
    328         super().__init__(**kwargs)
    329         self._message = message
    330 
    331     def compose(self) -> ComposeResult:
    332         with Vertical(id="confirm-box"):
    333             yield Label(self._message, id="confirm-message")
    334             with Horizontal(classes="action-row"):
    335                 yield Button("Yes", variant="error", id="yes-btn")
    336                 yield Button("No", variant="default", id="no-btn")
    337 
    338     @on(Button.Pressed, "#yes-btn")
    339     def _yes(self) -> None:
    340         self.dismiss(True)
    341 
    342     @on(Button.Pressed, "#no-btn")
    343     def _no(self) -> None:
    344         self.dismiss(False)
    345 
    346     def action_confirm(self) -> None:
    347         self.dismiss(True)
    348 
    349 
    350 # ---------------------------------------------------------------------------
    351 # Main Menu
    352 # ---------------------------------------------------------------------------
    353 
    354 class MainMenuScreen(Screen):
    355     BINDINGS = [Binding("q", "app.quit", "Quit")]
    356 
    357     def compose(self) -> ComposeResult:
    358         creds = load_credentials()
    359         logged_in = bool(creds.get("token"))
    360         subtitle = (
    361             f"Logged in as {creds.get('email', '?')}"
    362             if logged_in
    363             else "Not logged in"
    364         )
    365 
    366         with Vertical(id="menu-box"):
    367             yield Label("Obsidian Sync Setup", id="menu-title")
    368             yield Label(subtitle, id="menu-subtitle",
    369                         classes="status-ok" if logged_in else "status-warn")
    370             yield Rule()
    371             yield Button("  Login / Account", classes="menu-btn", id="btn-login")
    372             yield Button("  Vault Configuration", classes="menu-btn", id="btn-vaults")
    373             yield Button("  Systemd Service", classes="menu-btn", id="btn-service")
    374             yield Rule()
    375             yield Button("  Quit", classes="menu-btn", variant="default", id="btn-quit")
    376 
    377         yield Footer()
    378 
    379     @on(Button.Pressed, "#btn-login")
    380     def _login(self) -> None:
    381         self.app.push_screen(LoginScreen())
    382 
    383     @on(Button.Pressed, "#btn-vaults")
    384     def _vaults(self) -> None:
    385         self.app.push_screen(VaultsScreen())
    386 
    387     @on(Button.Pressed, "#btn-service")
    388     def _service(self) -> None:
    389         self.app.push_screen(ServiceScreen())
    390 
    391     @on(Button.Pressed, "#btn-quit")
    392     def _quit(self) -> None:
    393         self.app.exit()
    394 
    395     def on_screen_resume(self) -> None:
    396         # Refresh subtitle when returning from child screens
    397         try:
    398             creds = load_credentials()
    399             logged_in = bool(creds.get("token"))
    400             subtitle = self.query_one("#menu-subtitle", Label)
    401             subtitle.update(
    402                 f"Logged in as {creds.get('email', '?')}"
    403                 if logged_in
    404                 else "Not logged in"
    405             )
    406             subtitle.remove_class("status-ok", "status-warn")
    407             subtitle.add_class("status-ok" if logged_in else "status-warn")
    408         except NoMatches:
    409             pass
    410 
    411 
    412 # ---------------------------------------------------------------------------
    413 # Login Screen
    414 # ---------------------------------------------------------------------------
    415 
    416 class LoginScreen(Screen):
    417     BINDINGS = [Binding("escape", "app.pop_screen", "Back")]
    418 
    419     def compose(self) -> ComposeResult:
    420         creds = load_credentials()
    421         yield Header(show_clock=False)
    422 
    423         with ScrollableContainer(classes="screen-container"):
    424             yield Label("Account Login", classes="section-title")
    425 
    426             if creds.get("token"):
    427                 yield Static(
    428                     f"Currently logged in as  {creds.get('name', '')}  <{creds.get('email', '')}>",
    429                     classes="status-box status-ok",
    430                 )
    431                 yield Button("Log out", variant="error", id="btn-logout")
    432                 yield Rule()
    433 
    434             yield Label("Email", classes="field-label")
    435             yield Input(
    436                 value=creds.get("email", ""),
    437                 placeholder="you@example.com",
    438                 id="input-email",
    439             )
    440 
    441             yield Label("Password", classes="field-label")
    442             yield Input(
    443                 placeholder="Vault / account password",
    444                 password=True,
    445                 id="input-password",
    446             )
    447 
    448             yield Label("MFA code (leave blank if not enabled)", classes="field-label")
    449             yield Input(placeholder="123456", id="input-mfa")
    450 
    451             with Horizontal(classes="action-row"):
    452                 yield Button("Sign In", variant="primary", id="btn-signin",
    453                              classes="btn-primary")
    454                 yield Button("Back", variant="default", id="btn-back")
    455 
    456             yield Static("", id="status-label", classes="status-box")
    457 
    458         yield Footer()
    459 
    460     @on(Button.Pressed, "#btn-signin")
    461     def _signin(self) -> None:
    462         email = self.query_one("#input-email", Input).value.strip()
    463         password = self.query_one("#input-password", Input).value
    464         mfa = self.query_one("#input-mfa", Input).value.strip()
    465         if not email or not password:
    466             self._set_status("Email and password are required.", error=True)
    467             return
    468         self._do_signin(email, password, mfa)
    469 
    470     @work(exclusive=True)
    471     async def _do_signin(self, email: str, password: str, mfa: str) -> None:
    472         self._set_status("Signing in…")
    473         btn = self.query_one("#btn-signin", Button)
    474         btn.disabled = True
    475         try:
    476             from obsidian_sync.api import signin, ObsidianAPIError
    477             session = await signin(email, password, mfa)
    478             save_credentials({
    479                 "token": session.token,
    480                 "email": session.email,
    481                 "name": session.name,
    482                 "license": session.license,
    483             })
    484             self._set_status(
    485                 f"Signed in as {session.name} <{session.email}>",
    486                 error=False,
    487             )
    488         except Exception as e:
    489             self._set_status(f"Error: {e}", error=True)
    490         finally:
    491             btn.disabled = False
    492 
    493     @on(Button.Pressed, "#btn-logout")
    494     def _logout(self) -> None:
    495         self._do_logout()
    496 
    497     @work(exclusive=True)
    498     async def _do_logout(self) -> None:
    499         self._set_status("Signing out…")
    500         try:
    501             from obsidian_sync.api import signout
    502             creds = load_credentials()
    503             if creds.get("token"):
    504                 await signout(creds["token"])
    505             if CREDENTIALS_FILE.exists():
    506                 CREDENTIALS_FILE.unlink()
    507             self._set_status("Logged out.")
    508             # Refresh the logged-in banner
    509             try:
    510                 self.query_one("#btn-logout").remove()
    511             except NoMatches:
    512                 pass
    513         except Exception as e:
    514             self._set_status(f"Error: {e}", error=True)
    515 
    516     @on(Button.Pressed, "#btn-back")
    517     def _back(self) -> None:
    518         self.app.pop_screen()
    519 
    520     def _set_status(self, msg: str, error: Optional[bool] = None) -> None:
    521         try:
    522             lbl = self.query_one("#status-label", Static)
    523             lbl.update(msg)
    524             lbl.remove_class("status-ok", "status-err")
    525             if error is True:
    526                 lbl.add_class("status-err")
    527             elif error is False:
    528                 lbl.add_class("status-ok")
    529         except NoMatches:
    530             pass
    531 
    532     @on(Input.Submitted)
    533     def _submit(self, event: Input.Submitted) -> None:
    534         if event.input.id in ("input-email", "input-password", "input-mfa"):
    535             self._signin()
    536 
    537 
    538 # ---------------------------------------------------------------------------
    539 # Vaults Screen
    540 # ---------------------------------------------------------------------------
    541 
    542 class VaultsScreen(Screen):
    543     BINDINGS = [Binding("escape", "app.pop_screen", "Back")]
    544 
    545     def compose(self) -> ComposeResult:
    546         yield Header(show_clock=False)
    547         with ScrollableContainer(classes="screen-container"):
    548             yield Label("Vault Configuration", classes="section-title")
    549             yield Static(
    550                 "Configure which Obsidian vaults to sync and where to store them locally.",
    551                 classes="vault-meta",
    552             )
    553             yield Rule()
    554             yield Static("", id="vault-list-container")
    555             yield Rule()
    556             with Horizontal(classes="action-row"):
    557                 yield Button("Add Vault", variant="primary", id="btn-add",
    558                              classes="btn-primary")
    559                 yield Button("Back", variant="default", id="btn-back")
    560         yield Footer()
    561 
    562     def on_mount(self) -> None:
    563         self._refresh_list()
    564 
    565     def on_screen_resume(self) -> None:
    566         self._refresh_list()
    567 
    568     def _refresh_list(self) -> None:
    569         vaults = load_vaults_config()
    570         container = self.query_one("#vault-list-container", Static)
    571         if not vaults:
    572             container.update("No vaults configured yet. Press [bold]Add Vault[/bold] to get started.")
    573             return
    574 
    575         lines = []
    576         for i, v in enumerate(vaults):
    577             name = v.get("vault_name", "?")
    578             path = v.get("config", {}).get("vault_path", "?")
    579             enc = v.get("config", {}).get("encryption_version", "?")
    580             host = v.get("config", {}).get("vault_host", "?")
    581             lines.append(
    582                 f"[bold]{i+1}. {name}[/bold]  [dim](enc v{enc})[/dim]\n"
    583                 f"   Path: {path}\n"
    584                 f"   Host: {host}"
    585             )
    586         container.update("\n\n".join(lines))
    587 
    588         # Rebuild edit/remove buttons dynamically
    589         try:
    590             for btn in self.query(".vault-action-btn"):
    591                 btn.remove()
    592         except Exception:
    593             pass
    594 
    595     @on(Button.Pressed, "#btn-add")
    596     def _add(self) -> None:
    597         creds = load_credentials()
    598         if not creds.get("token"):
    599             self.app.push_screen(
    600                 InfoScreen("Not logged in", "Please log in first via the Account screen.")
    601             )
    602             return
    603         self.app.push_screen(AddVaultScreen())
    604 
    605     @on(Button.Pressed, "#btn-back")
    606     def _back(self) -> None:
    607         self.app.pop_screen()
    608 
    609 
    610 # ---------------------------------------------------------------------------
    611 # Add / Edit Vault Screen
    612 # ---------------------------------------------------------------------------
    613 
    614 class AddVaultScreen(Screen):
    615     BINDINGS = [Binding("escape", "app.pop_screen", "Back")]
    616 
    617     _server_vaults: reactive[list] = reactive([])
    618     _selected_idx: reactive[int] = reactive(0)
    619 
    620     def compose(self) -> ComposeResult:
    621         yield Header(show_clock=False)
    622         with ScrollableContainer(classes="screen-container"):
    623             yield Label("Add Vault", classes="section-title")
    624 
    625             yield Label("Step 1 — Select a vault from your account", classes="field-label")
    626             yield Static("Loading vaults from server…", id="vault-picker")
    627 
    628             yield Rule()
    629             yield Label("Step 2 — Local sync path", classes="field-label")
    630             yield Input(
    631                 placeholder=str(Path.home() / "vaults" / "my-vault"),
    632                 id="input-path",
    633             )
    634 
    635             yield Label("Step 3 — Vault encryption password", classes="field-label")
    636             yield Input(
    637                 placeholder="The password you set in Obsidian Sync",
    638                 password=True,
    639                 id="input-vaultpwd",
    640             )
    641 
    642             yield Label("Device name (shown in Obsidian sync log)", classes="field-label")
    643             yield Input(
    644                 value="obsidian-sync-daemon",
    645                 id="input-device",
    646             )
    647 
    648             yield Label("Sync options", classes="field-label")
    649             with Horizontal():
    650                 yield Label("Images  ", classes="vault-meta")
    651                 yield Switch(id="sw-images")
    652                 yield Label("  Audio  ", classes="vault-meta")
    653                 yield Switch(id="sw-audio")
    654                 yield Label("  Video  ", classes="vault-meta")
    655                 yield Switch(id="sw-video")
    656                 yield Label("  PDF  ", classes="vault-meta")
    657                 yield Switch(id="sw-pdf")
    658 
    659             with Horizontal(classes="action-row"):
    660                 yield Button("Save Vault", variant="primary", id="btn-save",
    661                              classes="btn-primary")
    662                 yield Button("Back", variant="default", id="btn-back")
    663 
    664             yield Static("", id="add-status", classes="status-box")
    665 
    666         yield Footer()
    667 
    668     def on_mount(self) -> None:
    669         self._load_server_vaults()
    670 
    671     @work(exclusive=True)
    672     async def _load_server_vaults(self) -> None:
    673         creds = load_credentials()
    674         try:
    675             from obsidian_sync.api import list_vaults
    676             vaults = await list_vaults(creds["token"])
    677             self._server_vaults = vaults
    678 
    679             if not vaults:
    680                 self.query_one("#vault-picker", Static).update("No vaults found on server.")
    681                 return
    682 
    683             # Build a simple numbered list
    684             lines = []
    685             for i, v in enumerate(vaults):
    686                 marker = "▶ " if i == self._selected_idx else "  "
    687                 lines.append(f"{marker}[{i}] {v.name}  [dim](enc v{v.encryption_version}, {v.host})[/dim]")
    688 
    689             self.query_one("#vault-picker", Static).update("\n".join(lines))
    690 
    691             # Pre-fill path with vault name
    692             if vaults:
    693                 default_path = str(Path.home() / "vaults" / vaults[0].name)
    694                 self.query_one("#input-path", Input).value = default_path
    695 
    696         except Exception as e:
    697             self.query_one("#vault-picker", Static).update(f"[red]Error loading vaults: {e}[/red]")
    698 
    699     def _update_picker(self) -> None:
    700         vaults = self._server_vaults
    701         if not vaults:
    702             return
    703         lines = []
    704         for i, v in enumerate(vaults):
    705             marker = "▶ " if i == self._selected_idx else "  "
    706             lines.append(f"{marker}[{i}] {v.name}  [dim](enc v{v.encryption_version}, {v.host})[/dim]")
    707         try:
    708             self.query_one("#vault-picker", Static).update("\n".join(lines))
    709         except NoMatches:
    710             pass
    711 
    712     def on_key(self, event) -> None:
    713         if not self._server_vaults:
    714             return
    715         if event.key == "up":
    716             self._selected_idx = max(0, self._selected_idx - 1)
    717             self._update_picker()
    718             # Update path suggestion
    719             v = self._server_vaults[self._selected_idx]
    720             self.query_one("#input-path", Input).value = str(Path.home() / "vaults" / v.name)
    721         elif event.key == "down":
    722             self._selected_idx = min(len(self._server_vaults) - 1, self._selected_idx + 1)
    723             self._update_picker()
    724             v = self._server_vaults[self._selected_idx]
    725             self.query_one("#input-path", Input).value = str(Path.home() / "vaults" / v.name)
    726 
    727     @on(Button.Pressed, "#btn-save")
    728     def _save(self) -> None:
    729         if not self._server_vaults:
    730             self._set_status("No server vaults loaded.", error=True)
    731             return
    732         vault = self._server_vaults[self._selected_idx]
    733         local_path = self.query_one("#input-path", Input).value.strip()
    734         vault_pwd = self.query_one("#input-vaultpwd", Input).value
    735         device = self.query_one("#input-device", Input).value.strip() or "obsidian-sync-daemon"
    736 
    737         if not local_path:
    738             self._set_status("Local path is required.", error=True)
    739             return
    740         if not vault_pwd:
    741             self._set_status("Vault password is required.", error=True)
    742             return
    743 
    744         creds = load_credentials()
    745 
    746         config = {
    747             "token": creds.get("token", ""),
    748             "vault_id": vault.id,
    749             "vault_host": vault.host,
    750             "vault_password": vault_pwd,
    751             "vault_salt": vault.salt,
    752             "encryption_version": vault.encryption_version,
    753             "vault_path": local_path,
    754             "device_name": device,
    755             "sync_images": self.query_one("#sw-images", Switch).value,
    756             "sync_audio": self.query_one("#sw-audio", Switch).value,
    757             "sync_video": self.query_one("#sw-video", Switch).value,
    758             "sync_pdf": self.query_one("#sw-pdf", Switch).value,
    759             "sync_app_settings": True,
    760             "ignore_folders": [],
    761             "state_file": "",
    762         }
    763 
    764         entry = {
    765             "vault_name": vault.name,
    766             "vault_id": vault.id,
    767             "config": config,
    768         }
    769 
    770         # Validate encryption: derive key (will raise if password is wrong type)
    771         self._validate_and_save(entry)
    772 
    773     @work(exclusive=True)
    774     async def _validate_and_save(self, entry: dict) -> None:
    775         self._set_status("Validating encryption key…")
    776         btn = self.query_one("#btn-save", Button)
    777         btn.disabled = True
    778         try:
    779             from obsidian_sync.crypto import create_encryption_provider
    780             enc = create_encryption_provider(
    781                 entry["config"]["vault_password"],
    782                 entry["config"]["vault_salt"],
    783                 entry["config"]["encryption_version"],
    784             )
    785             # Optionally verify keyhash matches server
    786             server_keyhash = ""
    787             for sv in self._server_vaults:
    788                 if sv.id == entry["vault_id"]:
    789                     server_keyhash = sv.keyhash
    790                     break
    791             if server_keyhash and enc.key_hash != server_keyhash:
    792                 self._set_status(
    793                     "Warning: key hash does not match server. Wrong password?",
    794                     error=True,
    795                 )
    796                 btn.disabled = False
    797                 return
    798 
    799             # Save config file
    800             cfg_path = write_vault_config(entry)
    801 
    802             # Update vaults list
    803             vaults = load_vaults_config()
    804             # Replace existing entry with same vault_id
    805             vaults = [v for v in vaults if v.get("vault_id") != entry["vault_id"]]
    806             vaults.append(entry)
    807             save_vaults_config(vaults)
    808 
    809             self._set_status(
    810                 f"Vault saved to {cfg_path}",
    811                 error=False,
    812             )
    813         except Exception as e:
    814             self._set_status(f"Error: {e}", error=True)
    815         finally:
    816             btn.disabled = False
    817 
    818     @on(Button.Pressed, "#btn-back")
    819     def _back(self) -> None:
    820         self.app.pop_screen()
    821 
    822     def _set_status(self, msg: str, error: Optional[bool] = None) -> None:
    823         try:
    824             lbl = self.query_one("#add-status", Static)
    825             lbl.update(msg)
    826             lbl.remove_class("status-ok", "status-err")
    827             if error is True:
    828                 lbl.add_class("status-err")
    829             elif error is False:
    830                 lbl.add_class("status-ok")
    831         except NoMatches:
    832             pass
    833 
    834 
    835 # ---------------------------------------------------------------------------
    836 # Service Screen
    837 # ---------------------------------------------------------------------------
    838 
    839 SERVICE_HELP = """\
    840 The sync daemon can be installed as a **systemd user service** so it starts
    841 automatically on login and runs in the background.
    842 
    843 **Install steps (run in terminal after saving):**
    844 
    845 ```
    846 systemctl --user daemon-reload
    847 systemctl --user enable obsidian-sync
    848 systemctl --user start obsidian-sync
    849 ```
    850 
    851 **View logs:**
    852 ```
    853 journalctl --user -u obsidian-sync -f
    854 ```
    855 
    856 **Stop / disable:**
    857 ```
    858 systemctl --user stop obsidian-sync
    859 systemctl --user disable obsidian-sync
    860 ```
    861 """
    862 
    863 
    864 class ServiceScreen(Screen):
    865     BINDINGS = [Binding("escape", "app.pop_screen", "Back")]
    866 
    867     def compose(self) -> ComposeResult:
    868         yield Header(show_clock=False)
    869         with ScrollableContainer(classes="screen-container"):
    870             yield Label("Systemd Service", classes="section-title")
    871 
    872             # Detected vaults
    873             vaults = load_vaults_config()
    874             if not vaults:
    875                 yield Static(
    876                     "[yellow]No vaults configured. Set up a vault first.[/yellow]",
    877                     classes="status-box status-warn",
    878                 )
    879             else:
    880                 vault_names = ", ".join(v.get("vault_name", "?") for v in vaults)
    881                 yield Static(
    882                     f"Configured vaults: [bold]{vault_names}[/bold]",
    883                     classes="status-box",
    884                 )
    885 
    886             yield Rule()
    887 
    888             # Service file status
    889             user_unit = SERVICE_USER_DIR / "obsidian-sync.service"
    890             installed = user_unit.exists()
    891             if installed:
    892                 status_text = f"[green]Unit file installed:[/green] {user_unit}"
    893             else:
    894                 status_text = "[yellow]Unit file not yet installed.[/yellow]"
    895             yield Static(status_text, id="svc-status", classes="status-box")
    896 
    897             with Horizontal(classes="action-row"):
    898                 yield Button(
    899                     "Install / Update Unit File",
    900                     variant="primary",
    901                     id="btn-install",
    902                     classes="btn-primary",
    903                 )
    904                 yield Button("Back", variant="default", id="btn-back")
    905 
    906             yield Static("", id="svc-action-status", classes="status-box")
    907             yield Rule()
    908             yield Markdown(SERVICE_HELP)
    909 
    910         yield Footer()
    911 
    912     @on(Button.Pressed, "#btn-install")
    913     def _install(self) -> None:
    914         vaults = load_vaults_config()
    915         if not vaults:
    916             self._set_status("No vaults configured yet.", error=True)
    917             return
    918         self._do_install(vaults)
    919 
    920     @work(exclusive=True)
    921     async def _do_install(self, vaults: list[dict]) -> None:
    922         self._set_status("Writing unit file…")
    923         btn = self.query_one("#btn-install", Button)
    924         btn.disabled = True
    925         try:
    926             SERVICE_USER_DIR.mkdir(parents=True, exist_ok=True)
    927             unit_path = SERVICE_USER_DIR / "obsidian-sync.service"
    928 
    929             # Build ExecStart lines for each vault
    930             python = shutil.which("python3") or "python3"
    931             cli = str(Path(__file__).parent / "cli.py")
    932 
    933             if len(vaults) == 1:
    934                 cfg = vault_config_path(vaults[0]["vault_name"])
    935                 exec_start = f"{python} {cli} run {cfg}"
    936             else:
    937                 # Multiple vaults: write a wrapper script
    938                 wrapper = CONFIG_DIR / "run-all-vaults.sh"
    939                 lines = ["#!/bin/bash"]
    940                 for v in vaults:
    941                     cfg = vault_config_path(v["vault_name"])
    942                     lines.append(f"{python} {cli} run {cfg} &")
    943                 lines.append("wait")
    944                 wrapper.write_text("\n".join(lines) + "\n")
    945                 wrapper.chmod(0o755)
    946                 exec_start = f"/bin/bash {wrapper}"
    947 
    948             unit = f"""[Unit]
    949 Description=Obsidian Sync Daemon
    950 After=network-online.target
    951 Wants=network-online.target
    952 
    953 [Service]
    954 Type=simple
    955 ExecStart={exec_start}
    956 Restart=on-failure
    957 RestartSec=10
    958 StandardOutput=journal
    959 StandardError=journal
    960 SyslogIdentifier=obsidian-sync
    961 
    962 [Install]
    963 WantedBy=default.target
    964 """
    965             unit_path.write_text(unit)
    966 
    967             # Refresh svc-status label
    968             self.query_one("#svc-status", Static).update(
    969                 f"[green]Unit file installed:[/green] {unit_path}"
    970             )
    971             self._set_status(
    972                 "Unit file written. Run the commands below to enable and start the service.",
    973                 error=False,
    974             )
    975         except Exception as e:
    976             self._set_status(f"Error: {e}", error=True)
    977         finally:
    978             btn.disabled = False
    979 
    980     @on(Button.Pressed, "#btn-back")
    981     def _back(self) -> None:
    982         self.app.pop_screen()
    983 
    984     def _set_status(self, msg: str, error: Optional[bool] = None) -> None:
    985         try:
    986             lbl = self.query_one("#svc-action-status", Static)
    987             lbl.update(msg)
    988             lbl.remove_class("status-ok", "status-err")
    989             if error is True:
    990                 lbl.add_class("status-err")
    991             elif error is False:
    992                 lbl.add_class("status-ok")
    993         except NoMatches:
    994             pass
    995 
    996 
    997 # ---------------------------------------------------------------------------
    998 # Generic info screen
    999 # ---------------------------------------------------------------------------
   1000 
   1001 class InfoScreen(ModalScreen):
   1002     BINDINGS = [Binding("escape", "app.pop_screen", "Close"),
   1003                 Binding("enter", "app.pop_screen", "Close")]
   1004 
   1005     def __init__(self, title: str, message: str, **kwargs):
   1006         super().__init__(**kwargs)
   1007         self._title = title
   1008         self._message = message
   1009 
   1010     def compose(self) -> ComposeResult:
   1011         with Vertical(id="confirm-box"):
   1012             yield Label(f"[bold]{self._title}[/bold]", id="confirm-message")
   1013             yield Static(self._message)
   1014             yield Button("OK", variant="primary", id="btn-ok")
   1015 
   1016     @on(Button.Pressed, "#btn-ok")
   1017     def _ok(self) -> None:
   1018         self.app.pop_screen()
   1019 
   1020 
   1021 # ---------------------------------------------------------------------------
   1022 # App
   1023 # ---------------------------------------------------------------------------
   1024 
   1025 class ObsidianSyncSetup(App):
   1026     CSS = CSS
   1027     TITLE = "Obsidian Sync Setup"
   1028     SCREENS = {
   1029         "main": MainMenuScreen,
   1030         "login": LoginScreen,
   1031         "vaults": VaultsScreen,
   1032         "add_vault": AddVaultScreen,
   1033         "service": ServiceScreen,
   1034     }
   1035 
   1036     def on_mount(self) -> None:
   1037         self.push_screen(MainMenuScreen())
   1038 
   1039 
   1040 def main() -> None:
   1041     app = ObsidianSyncSetup()
   1042     app.run()
   1043 
   1044 
   1045 if __name__ == "__main__":
   1046     main()