linux observer
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}")