linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 577 lines 21 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3"""solstone tray app — in-process D-Bus SNI component. 4 5Exports the tray icon, menu, and tooltip on the observer's existing 6session bus connection. No separate tray process is required. 7""" 8 9import asyncio 10import logging 11import os 12import subprocess 13import time 14from pathlib import Path 15from datetime import datetime 16 17from dbus_next.aio import MessageBus 18 19from . import __version__ 20from .dbusmenu import DBusMenu, MenuItem, separator 21from .sni import StatusNotifierItem, register_with_watcher 22 23log = logging.getLogger(__name__) 24 25# Icon names — these reference SVGs in our icon theme 26ICONS = { 27 "recording": "solstone-recording", 28 "paused": "solstone-paused", 29 "idle": "solstone-paused", 30 "stopped": "solstone-error", 31 "syncing": "solstone-syncing", 32 "error": "solstone-error", 33} 34 35# Agent instructions template copied to clipboard 36AGENT_INSTRUCTIONS = """solstone observer (Linux) 37Source: {source_dir} 38Read INSTALL.md in the source directory for setup and architecture. 39Config: {config_path} 40Captures: {captures_dir} 41Logs: journalctl --user -u solstone-linux -f 42Service: systemctl --user status solstone-linux""" 43 44SOURCE_DIR = str(Path(__file__).resolve().parent) 45 46 47def _compute_header_label(status, sync_status, pause_remaining) -> str: 48 if status == "paused": 49 if pause_remaining and pause_remaining > 0: 50 mins = pause_remaining // 60 51 return f"paused ({mins}m remaining)" 52 return "paused" 53 if status == "stopped": 54 return "not running" 55 if status == "recording": 56 if sync_status == "offline": 57 return "observing — offline (recording locally)" 58 if sync_status in ("syncing", "uploading", "retrying"): 59 return "observing — syncing" 60 return "observing — connected" 61 if status == "idle": 62 if sync_status == "offline": 63 return "idle — offline" 64 if sync_status in ("syncing", "uploading", "retrying"): 65 return "idle — syncing" 66 return "idle — connected" 67 return str(status) 68 69 70class TrayApp: 71 """In-process tray component — exports SNI on the observer's bus.""" 72 73 def __init__(self, observer, bus): 74 self._observer = observer 75 self.config = observer.config 76 self.bus: MessageBus = bus 77 self.sni = StatusNotifierItem("solstone-observer") 78 self.menu = DBusMenu() 79 80 # State cache (for change detection) 81 self.status = "recording" 82 self.sync_status = "synced" 83 self.sync_progress = "" 84 self.error = "" 85 self.paused_remaining = 0 86 self.stats = {} 87 self._last_stats_time = 0.0 88 89 # Menu item references for dynamic updates 90 self._status_header: MenuItem = None 91 self._status_item: MenuItem = None 92 self._sync_item: MenuItem = None 93 self._segment_item: MenuItem = None 94 self._cache_item: MenuItem = None 95 self._captures_item: MenuItem = None 96 self._uptime_item: MenuItem = None 97 self._pause_submenu: MenuItem = None 98 self._resume_item: MenuItem = None 99 100 async def start(self): 101 pid = os.getpid() 102 bus_name = f"org.kde.StatusNotifierItem-{pid}-1" 103 await self.bus.request_name(bus_name) 104 105 # Export interfaces 106 self.bus.export("/StatusNotifierItem", self.sni) 107 self.bus.export("/MenuBar", self.menu) 108 109 # Resolve icon theme: installed location, then dev/contrib fallback 110 installed_icon = ( 111 Path.home() 112 / ".local/share/icons/hicolor/scalable/status/solstone-recording.svg" 113 ) 114 if installed_icon.exists(): 115 self.sni._icon_theme_path = str(Path.home() / ".local/share/icons") 116 else: 117 contrib = ( 118 Path(__file__).resolve().parent.parent.parent / "contrib" / "icons" 119 ) 120 if (contrib / "hicolor").is_dir(): 121 self.sni._icon_theme_path = str(contrib) 122 if self.sni._icon_theme_path: 123 log.info(f"Icon theme path: {self.sni._icon_theme_path}") 124 125 # Set initial icon 126 self.sni.set_icon(ICONS["recording"]) 127 self._update_accessible_descriptions() 128 self.sni.set_tooltip("solstone observer", "starting...") 129 130 # Build menu 131 self._build_menu() 132 133 # Register with watcher (3 attempts) 134 registered = False 135 for attempt in range(3): 136 registered = await register_with_watcher(self.bus, bus_name) 137 if registered: 138 break 139 if attempt < 2: 140 await asyncio.sleep(1) 141 log.info(f"SNI watcher retry {attempt + 1}/2...") 142 143 if not registered: 144 log.info("No StatusNotifierWatcher available") 145 return False 146 147 return True 148 149 def update(self): 150 """Read observer state and update tray display.""" 151 obs = self._observer 152 153 # Determine status 154 if obs._paused: 155 status = "paused" 156 elif obs.current_mode == "screencast": 157 status = "recording" 158 else: 159 status = "idle" 160 161 # Sync status 162 sync_status = "synced" 163 sync_progress = "" 164 if obs._sync: 165 sync_status = obs._sync.sync_status 166 sync_progress = obs._sync.sync_progress 167 168 # Segment timer 169 if obs._paused or obs.segment_dir is None: 170 segment_timer = 0 171 else: 172 remaining = obs.interval - (time.monotonic() - obs.start_at_mono) 173 segment_timer = max(0, int(remaining)) 174 175 # Pause remaining 176 if not obs._paused or obs._pause_until <= 0: 177 pause_remaining = 0 178 else: 179 pause_remaining = max(0, int(obs._pause_until - time.monotonic())) 180 181 # Compute stats (throttled — filesystem walk every 60s) 182 now = time.monotonic() 183 if now - self._last_stats_time >= 60: 184 self._last_stats_time = now 185 captures_today = 0 186 total_size = 0 187 today = datetime.now().strftime("%Y%m%d") 188 captures_dir = obs.config.captures_dir 189 190 try: 191 if captures_dir.exists(): 192 for day_dir in captures_dir.iterdir(): 193 if not day_dir.is_dir(): 194 continue 195 for stream_dir in day_dir.iterdir(): 196 if not stream_dir.is_dir(): 197 continue 198 for seg_dir in stream_dir.iterdir(): 199 if not seg_dir.is_dir(): 200 continue 201 if seg_dir.name.endswith(".incomplete"): 202 continue 203 if seg_dir.name.endswith(".failed"): 204 continue 205 if day_dir.name == today: 206 captures_today += 1 207 for file_path in seg_dir.iterdir(): 208 if file_path.is_file(): 209 total_size += file_path.stat().st_size 210 except OSError: 211 pass 212 213 synced_days = 0 214 if obs._sync: 215 synced_days = len(obs._sync._synced_days) 216 217 total_size_mb = int(total_size / (1024 * 1024)) 218 uptime_seconds = int(time.monotonic() - obs._start_mono) 219 220 self.stats = { 221 "captures_today": captures_today, 222 "total_size_mb": total_size_mb, 223 "synced_days": synced_days, 224 "uptime_seconds": uptime_seconds, 225 } 226 227 self._update_status(status) 228 self._update_sync(sync_status, sync_progress) 229 self._update_header(pause_remaining) 230 self._update_live_stats(segment_timer, pause_remaining) 231 self.paused_remaining = pause_remaining 232 233 def _build_menu(self): 234 """Build the full tray menu structure.""" 235 236 self._status_header = MenuItem(label="observing", enabled=False) 237 238 # ── Status submenu (live data) ── 239 self._status_item = MenuItem(label="observing", enabled=False) 240 self._sync_item = MenuItem(label="sync: up to date", enabled=False) 241 self._segment_item = MenuItem(label="segment: --:--", enabled=False) 242 self._cache_item = MenuItem(label="cache: --", enabled=False) 243 self._captures_item = MenuItem(label="captures today: --", enabled=False) 244 self._uptime_item = MenuItem(label="uptime: --", enabled=False) 245 246 status_submenu = MenuItem( 247 label="status", 248 children_display="submenu", 249 ) 250 status_submenu.children = [ 251 self._status_item, 252 self._sync_item, 253 separator(), 254 self._segment_item, 255 self._cache_item, 256 self._captures_item, 257 self._uptime_item, 258 ] 259 260 # ── Pause / Resume ── 261 pause_15m = MenuItem(label="15 minutes", callback=lambda: self._pause(900)) 262 pause_30m = MenuItem(label="30 minutes", callback=lambda: self._pause(1800)) 263 pause_1h = MenuItem(label="1 hour", callback=lambda: self._pause(3600)) 264 pause_indef = MenuItem(label="until I resume", callback=lambda: self._pause(0)) 265 266 self._pause_submenu = MenuItem( 267 label="pause", 268 children_display="submenu", 269 ) 270 self._pause_submenu.children = [pause_15m, pause_30m, pause_1h, pause_indef] 271 272 self._resume_item = MenuItem( 273 label="resume", 274 visible=False, 275 callback=self._resume, 276 ) 277 278 # ── Open journal / Show captures ── 279 open_journal = MenuItem( 280 label="open journal", 281 callback=self._open_journal, 282 ) 283 284 # ── Settings submenu ── 285 settings_open_config = MenuItem( 286 label="open config.json", 287 callback=self._open_config, 288 ) 289 settings_submenu = MenuItem( 290 label="settings", 291 children_display="submenu", 292 ) 293 settings_submenu.children = [ 294 settings_open_config, 295 ] 296 297 # ── About submenu ── 298 about_version = MenuItem( 299 label=f"solstone observer v{__version__}", 300 enabled=False, 301 ) 302 about_website = MenuItem( 303 label="solstone.app", 304 callback=lambda: self._open_url("https://solstone.app/observers"), 305 ) 306 about_source = MenuItem( 307 label="source code", 308 callback=lambda: self._open_url("https://github.com/solpbc/solstone-linux"), 309 ) 310 about_privacy = MenuItem( 311 label="privacy policy", 312 callback=lambda: self._open_url("https://solpbc.org/privacy"), 313 ) 314 about_copyright = MenuItem( 315 label="\u00a9 2026 sol pbc \u2014 a public benefit corporation", 316 enabled=False, 317 ) 318 319 about_submenu = MenuItem( 320 label="about", 321 children_display="submenu", 322 ) 323 about_copy_agent = MenuItem( 324 label="copy help agent instructions", 325 callback=self._copy_agent_instructions, 326 ) 327 328 about_submenu.children = [ 329 about_version, 330 about_website, 331 about_source, 332 about_privacy, 333 about_copy_agent, 334 separator(), 335 about_copyright, 336 ] 337 338 # ── Service hint ── 339 service_hint = MenuItem( 340 label="managed via systemctl", 341 enabled=False, 342 ) 343 344 # ── Assemble full menu ── 345 self.menu.set_menu( 346 [ 347 self._status_header, 348 separator(), 349 self._pause_submenu, 350 self._resume_item, 351 separator(), 352 status_submenu, 353 open_journal, 354 settings_submenu, 355 about_submenu, 356 separator(), 357 service_hint, 358 ] 359 ) 360 361 def _update_status(self, status: str): 362 """Update tray icon and menu for observer status.""" 363 if status == self.status: 364 return 365 self.status = status 366 367 # Pick icon 368 if self.error: 369 icon = ICONS["error"] 370 elif self.sync_status in ("syncing", "uploading", "retrying"): 371 icon = ICONS["syncing"] 372 else: 373 icon = ICONS.get(status, ICONS["recording"]) 374 self.sni.set_icon(icon) 375 376 # Update tooltip 377 self.sni.set_tooltip("solstone observer", self._build_tooltip()) 378 379 # Toggle pause/resume 380 is_paused = status == "paused" 381 self._pause_submenu.visible = not is_paused 382 self._resume_item.visible = is_paused 383 if is_paused and self.paused_remaining > 0: 384 mins = self.paused_remaining // 60 385 self._resume_item.label = f"resume ({mins}m remaining)" 386 else: 387 self._resume_item.label = "resume" 388 self.menu.update_properties(self._pause_submenu, "visible") 389 self.menu.update_properties(self._resume_item, "visible", "label") 390 391 # SNI status 392 if status == "stopped" or self.error: 393 self.sni.set_status("NeedsAttention") 394 else: 395 self.sni.set_status("Active") 396 self._update_accessible_descriptions() 397 398 log.info(f"Status -> {status} (icon: {icon})") 399 400 def _update_header(self, pause_remaining: int): 401 label = _compute_header_label(self.status, self.sync_status, pause_remaining) 402 if label == self._status_header.label: 403 return 404 self._status_header.label = label 405 self._status_item.label = label 406 self.menu.update_properties(self._status_header, "label") 407 408 def _update_sync(self, sync_status: str, progress: str): 409 """Update sync status display.""" 410 if sync_status == self.sync_status and progress == self.sync_progress: 411 return 412 self.sync_status = sync_status 413 self.sync_progress = progress 414 415 labels = { 416 "synced": "sync: up to date", 417 "syncing": f"sync: {progress}" if progress else "sync: checking...", 418 "uploading": f"sync: {progress}" if progress else "sync: uploading...", 419 "retrying": f"sync: {progress}" if progress else "sync: retrying...", 420 "offline": "sync: offline", 421 } 422 self._sync_item.label = labels.get(sync_status, f"sync: {sync_status}") 423 424 # Update icon — syncing state gets the half icon 425 if not self.error: 426 if sync_status in ("syncing", "uploading", "retrying"): 427 self.sni.set_icon(ICONS["syncing"]) 428 else: 429 self.sni.set_icon(ICONS.get(self.status, ICONS["recording"])) 430 431 self.sni.set_tooltip("solstone observer", self._build_tooltip()) 432 self._update_accessible_descriptions() 433 434 def _update_live_stats(self, segment_timer: int, pause_remaining: int): 435 """Update the live stats in the status submenu.""" 436 # Segment timer 437 mins = segment_timer // 60 438 secs = segment_timer % 60 439 new_label = f"segment: {mins}:{secs:02d} remaining" 440 if self._segment_item.label != new_label: 441 self._segment_item.label = new_label 442 443 # Stats (computed in update()) 444 if self.stats: 445 captures = self.stats.get("captures_today", 0) 446 size_mb = self.stats.get("total_size_mb", 0) 447 synced_days = self.stats.get("synced_days", 0) 448 uptime = self.stats.get("uptime_seconds", 0) 449 450 new_cache = f"cache: {size_mb} MB ({synced_days} days synced)" 451 new_captures = f"captures today: {captures} segments" 452 453 hours = uptime // 3600 454 mins_up = (uptime % 3600) // 60 455 new_uptime = f"uptime: {hours}h {mins_up}m" 456 457 if self._cache_item.label != new_cache: 458 self._cache_item.label = new_cache 459 if self._captures_item.label != new_captures: 460 self._captures_item.label = new_captures 461 if self._uptime_item.label != new_uptime: 462 self._uptime_item.label = new_uptime 463 464 # Update pause remaining in resume button 465 if self.status == "paused" and pause_remaining > 0: 466 pr_mins = pause_remaining // 60 467 new_resume = f"resume ({pr_mins}m remaining)" 468 if self._resume_item.label != new_resume: 469 self._resume_item.label = new_resume 470 471 def _build_tooltip(self) -> str: 472 """Build plain-text tooltip body (cross-DE compatible).""" 473 parts = [] 474 475 status_labels = { 476 "recording": "observing", 477 "paused": "paused", 478 "idle": "idle (screen inactive)", 479 "stopped": "not running", 480 } 481 parts.append(status_labels.get(self.status, self.status)) 482 483 if self.sync_status == "synced": 484 parts.append("all segments synced") 485 elif self.sync_progress: 486 parts.append(f"sync: {self.sync_progress}") 487 else: 488 parts.append(f"sync: {self.sync_status}") 489 490 if self.error: 491 parts.append(self.error) 492 493 return "\n".join(parts) 494 495 def _update_accessible_descriptions(self): 496 if self.error: 497 desc = "Solstone observer — error" 498 elif self.sync_status in ("syncing", "uploading", "retrying"): 499 desc = "Solstone observer — syncing" 500 elif self.status == "paused": 501 desc = "Solstone observer — paused" 502 elif self.status == "idle": 503 desc = "Solstone observer — idle" 504 elif self.status == "stopped": 505 desc = "Solstone observer — stopped" 506 else: 507 desc = "Solstone observer — recording" 508 if self.config.stream: 509 desc = f"{desc} ({self.config.stream})" 510 511 self.sni.set_icon_accessible_desc(desc) 512 self.sni.set_attention_accessible_desc(desc) 513 514 # ── Menu callbacks ── 515 516 def _pause(self, seconds: int): 517 log.info(f"Pause: {seconds}s") 518 self._observer.pause(seconds) 519 520 def _resume(self): 521 log.info("Resume") 522 self._observer.resume() 523 524 def _open_journal(self): 525 log.info("Opening journal") 526 self._open_url(self.config.server_url or "https://journal.solstone.app") 527 528 def _open_config(self): 529 config_path = str(self.config.config_path) 530 log.info(f"Opening config: {config_path}") 531 try: 532 subprocess.Popen( 533 ["xdg-open", config_path], 534 stdout=subprocess.DEVNULL, 535 stderr=subprocess.DEVNULL, 536 ) 537 except Exception as e: 538 log.error(f"Failed to open config: {e}") 539 540 def _copy_agent_instructions(self): 541 """Copy coding agent instructions to clipboard.""" 542 text = AGENT_INSTRUCTIONS.format( 543 source_dir=SOURCE_DIR, 544 config_path=str(self.config.config_path), 545 captures_dir=str(self.config.captures_dir), 546 ) 547 log.info("Copying agent instructions to clipboard") 548 try: 549 # wl-copy for Wayland, xclip for X11 550 session_type = os.environ.get("XDG_SESSION_TYPE", "") 551 if session_type == "wayland" or os.environ.get("WAYLAND_DISPLAY"): 552 proc = subprocess.Popen(["wl-copy"], stdin=subprocess.PIPE) 553 else: 554 proc = subprocess.Popen( 555 ["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE 556 ) 557 proc.communicate(text.encode()) 558 log.info("Copied to clipboard") 559 except FileNotFoundError: 560 # Fallback: try xsel 561 try: 562 proc = subprocess.Popen( 563 ["xsel", "--clipboard", "--input"], stdin=subprocess.PIPE 564 ) 565 proc.communicate(text.encode()) 566 log.info("Copied to clipboard (xsel)") 567 except FileNotFoundError: 568 log.error("No clipboard tool found (wl-copy, xclip, or xsel)") 569 570 def _open_url(self, url: str): 571 log.info(f"Opening: {url}") 572 try: 573 subprocess.Popen( 574 ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL 575 ) 576 except Exception as e: 577 log.error(f"Failed to open URL: {e}")