linux observer
at main 315 lines 9.7 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""CLI entry point for solstone-linux. 5 6Subcommands: 7 run Start capture loop + sync service (default) 8 setup Interactive configuration 9 install-service Write systemd user unit, enable, start 10 status Show capture and sync state 11""" 12 13from __future__ import annotations 14 15import argparse 16import asyncio 17import json 18import logging 19import shutil 20import socket 21import subprocess 22import sys 23from pathlib import Path 24 25from .config import load_config, save_config 26from .streams import stream_name 27 28 29def _setup_logging(verbose: bool = False) -> None: 30 level = logging.DEBUG if verbose else logging.INFO 31 logging.basicConfig( 32 level=level, 33 format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", 34 datefmt="%H:%M:%S", 35 ) 36 37 38def cmd_run(args: argparse.Namespace) -> int: 39 """Start the capture loop + sync service.""" 40 from .observer import async_run 41 from .recovery import recover_incomplete_segments 42 43 config = load_config() 44 config.ensure_dirs() 45 46 if not config.stream: 47 try: 48 config.stream = stream_name(host=socket.gethostname()) 49 except ValueError as e: 50 print(f"Error: {e}", file=sys.stderr) 51 return 1 52 53 if args.interval: 54 config.segment_interval = args.interval 55 56 # Crash recovery before starting 57 recovered = recover_incomplete_segments(config.captures_dir) 58 if recovered: 59 print(f"Recovered {recovered} incomplete segment(s)") 60 61 try: 62 return asyncio.run(async_run(config)) 63 except KeyboardInterrupt: 64 return 0 65 66 67def cmd_setup(args: argparse.Namespace) -> int: 68 """Interactive setup — configure server URL and register.""" 69 from .upload import UploadClient 70 71 config = load_config() 72 73 # Prompt for server URL 74 default_url = config.server_url or "" 75 url = input(f"Solstone server URL [{default_url}]: ").strip() 76 if url: 77 config.server_url = url 78 elif not config.server_url: 79 print("Error: server URL is required", file=sys.stderr) 80 return 1 81 82 # Derive stream name 83 if not config.stream: 84 try: 85 config.stream = stream_name(host=socket.gethostname()) 86 except ValueError as e: 87 print(f"Error deriving stream name: {e}", file=sys.stderr) 88 return 1 89 print(f"Stream: {config.stream}") 90 91 # Save config before registration (so URL is persisted) 92 config.ensure_dirs() 93 save_config(config) 94 95 # Auto-register — try sol CLI first (no server needed), fall back to HTTP 96 if not config.key: 97 sol = shutil.which("sol") 98 if sol: 99 print("Registering via sol CLI...") 100 try: 101 result = subprocess.run( 102 [sol, "observer", "--json", "create", config.stream], 103 capture_output=True, 104 text=True, 105 timeout=10, 106 ) 107 if result.returncode == 0: 108 data = json.loads(result.stdout) 109 config.key = data["key"] 110 save_config(config) 111 print(f"Registered (key: {config.key[:8]}...)") 112 else: 113 print("CLI registration failed, trying HTTP...") 114 except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, OSError): 115 print("CLI registration failed, trying HTTP...") 116 117 if not config.key: 118 print("Registering with server...") 119 client = UploadClient(config) 120 if client.ensure_registered(config): 121 config = load_config() 122 print(f"Registered (key: {config.key[:8]}...)") 123 else: 124 print( 125 "Warning: registration failed. Run setup again when server is available." 126 ) 127 else: 128 print(f"Already registered (key: {config.key[:8]}...)") 129 130 print(f"\nConfig saved to {config.config_path}") 131 print(f"Captures will go to {config.captures_dir}") 132 print( 133 "\nRun 'solstone-linux run' to start, or 'solstone-linux install-service' for systemd." 134 ) 135 return 0 136 137 138def cmd_install_service(args: argparse.Namespace) -> int: 139 """Write systemd user unit file, enable, and start the service.""" 140 binary = shutil.which("solstone-linux") 141 if not binary: 142 print("Error: solstone-linux not found on PATH", file=sys.stderr) 143 print( 144 "Install with: pipx install --system-site-packages solstone-linux", 145 file=sys.stderr, 146 ) 147 return 1 148 149 unit_dir = Path.home() / ".config" / "systemd" / "user" 150 unit_dir.mkdir(parents=True, exist_ok=True) 151 unit_path = unit_dir / "solstone-linux.service" 152 153 unit_content = f"""\ 154[Unit] 155Description=Solstone Linux Desktop Observer 156After=graphical-session.target 157BindsTo=graphical-session.target 158 159[Service] 160Type=simple 161ExecStart={binary} run 162PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP 163Restart=on-failure 164RestartSec=10 165StartLimitIntervalSec=300 166StartLimitBurst=5 167 168[Install] 169WantedBy=graphical-session.target 170""" 171 172 unit_path.write_text(unit_content) 173 print(f"Wrote {unit_path}") 174 175 # Reload, enable, start 176 try: 177 subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 178 subprocess.run( 179 ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], 180 check=True, 181 ) 182 print("Service enabled and started.") 183 subprocess.run( 184 ["systemctl", "--user", "status", "solstone-linux.service"], 185 check=False, 186 ) 187 except FileNotFoundError: 188 print("Warning: systemctl not found. Enable the service manually.") 189 except subprocess.CalledProcessError as e: 190 print(f"Warning: systemctl command failed: {e}") 191 192 return 0 193 194 195def cmd_status(args: argparse.Namespace) -> int: 196 """Show capture and sync state.""" 197 config = load_config() 198 199 print(f"Config: {config.config_path}") 200 print(f"Server: {config.server_url or '(not configured)'}") 201 print(f"Key: {config.key[:8] + '...' if config.key else '(not registered)'}") 202 print(f"Stream: {config.stream or '(not set)'}") 203 print() 204 205 # Cache size 206 captures_dir = config.captures_dir 207 if captures_dir.exists(): 208 total_size = 0 209 segment_count = 0 210 day_count = 0 211 incomplete_count = 0 212 213 for day_dir in sorted(captures_dir.iterdir()): 214 if not day_dir.is_dir(): 215 continue 216 day_count += 1 217 for stream_dir in day_dir.iterdir(): 218 if not stream_dir.is_dir(): 219 continue 220 for seg_dir in stream_dir.iterdir(): 221 if not seg_dir.is_dir(): 222 continue 223 if seg_dir.name.endswith(".incomplete"): 224 incomplete_count += 1 225 continue 226 if seg_dir.name.endswith(".failed"): 227 continue 228 segment_count += 1 229 for f in seg_dir.iterdir(): 230 if f.is_file(): 231 total_size += f.stat().st_size 232 233 size_mb = total_size / (1024 * 1024) 234 print(f"Cache: {captures_dir}") 235 print( 236 f" {segment_count} segments across {day_count} day(s), {size_mb:.1f} MB" 237 ) 238 if incomplete_count: 239 print(f" {incomplete_count} incomplete segment(s)") 240 else: 241 print(f"Cache: {captures_dir} (not created yet)") 242 243 # Synced days 244 synced_path = config.state_dir / "synced_days.json" 245 if synced_path.exists(): 246 try: 247 with open(synced_path) as f: 248 synced = json.load(f) 249 print(f"Synced: {len(synced)} day(s) fully synced") 250 except (json.JSONDecodeError, OSError): 251 pass 252 253 # Systemd status 254 try: 255 result = subprocess.run( 256 ["systemctl", "--user", "is-active", "solstone-linux.service"], 257 capture_output=True, 258 text=True, 259 ) 260 state = result.stdout.strip() 261 print(f"\nService: {state}") 262 except FileNotFoundError: 263 pass 264 265 return 0 266 267 268def main() -> None: 269 """CLI entry point.""" 270 parser = argparse.ArgumentParser( 271 prog="solstone-linux", 272 description="Standalone Linux desktop observer for solstone", 273 ) 274 parser.add_argument( 275 "-v", "--verbose", action="store_true", help="Enable debug logging" 276 ) 277 subparsers = parser.add_subparsers(dest="command") 278 279 # run 280 run_parser = subparsers.add_parser("run", help="Start capture + sync") 281 run_parser.add_argument( 282 "--interval", 283 type=int, 284 default=None, 285 help="Segment duration in seconds (default: 300)", 286 ) 287 288 # setup 289 subparsers.add_parser("setup", help="Interactive configuration") 290 291 # install-service 292 subparsers.add_parser("install-service", help="Install systemd user service") 293 294 # status 295 subparsers.add_parser("status", help="Show capture and sync state") 296 297 args = parser.parse_args() 298 _setup_logging(args.verbose) 299 300 # Default to run if no subcommand 301 command = args.command or "run" 302 303 commands = { 304 "run": cmd_run, 305 "setup": cmd_setup, 306 "install-service": cmd_install_service, 307 "status": cmd_status, 308 } 309 310 handler = commands.get(command) 311 if handler: 312 sys.exit(handler(args)) 313 else: 314 parser.print_help() 315 sys.exit(1)