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