linux observer
0
fork

Configure Feed

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

Fold system tray into observer process

The tray was a separate process with its own bus, polling loop, and
reconnection logic. Now it's an in-process component that shares the
observer's MessageBus and reads state directly. Pause/resume go through
Observer.pause()/resume() instead of D-Bus RPC. On headless systems the
tray silently skips. Removes solstone-tray entry point, install-tray CLI
command, and solstone-tray.desktop autostart file. Merges icon install
into install-service.

+186 -294
+2 -6
INSTALL.md
··· 71 71 solstone-linux install-service 72 72 ``` 73 73 74 - 6. install the system tray indicator (optional): 75 - ``` 76 - solstone-linux install-tray 77 - ``` 78 - this installs status icons and an XDG autostart entry so the tray app launches on login. to start it immediately: `solstone-tray &` 74 + the system tray indicator appears automatically when the observer starts on a graphical session with a StatusNotifierWatcher (KDE Plasma, GNOME with AppIndicator extension). no separate install step needed. 79 75 80 - 7. verify it's running and connected: 76 + 6. verify it's running and connected: 81 77 ``` 82 78 systemctl --user status solstone-linux 83 79 sol remote list
-7
contrib/solstone-tray.desktop
··· 1 - [Desktop Entry] 2 - Type=Application 3 - Name=Solstone Tray 4 - Comment=System tray status for solstone observer 5 - Exec=solstone-tray 6 - Icon=solstone-recording 7 - NoDisplay=true
-1
pyproject.toml
··· 16 16 17 17 [project.scripts] 18 18 solstone-linux = "solstone_linux.cli:main" 19 - solstone-tray = "solstone_linux.tray:main" 20 19 21 20 [dependency-groups] 22 21 dev = [
+18 -61
src/solstone_linux/cli.py
··· 7 7 run Start capture loop + sync service (default) 8 8 setup Interactive configuration 9 9 install-service Write systemd user unit, enable, start 10 - install-tray Install tray icons and XDG autostart entry 11 10 status Show capture and sync state 12 11 """ 13 12 ··· 190 189 except subprocess.CalledProcessError as e: 191 190 print(f"Warning: systemctl command failed: {e}") 192 191 193 - return 0 194 - 195 - 196 - def cmd_install_tray(args: argparse.Namespace) -> int: 197 - """Install tray icons and XDG autostart desktop entry.""" 198 - binary = shutil.which("solstone-tray") 199 - if not binary: 200 - print("Error: solstone-tray not found on PATH", file=sys.stderr) 201 - print( 202 - "Install with: pipx install --system-site-packages solstone-linux", 203 - file=sys.stderr, 204 - ) 205 - return 1 206 - 207 - # Source icons from the installed package 208 192 icon_source = Path(__file__).resolve().parent / "icons" / "hicolor" 209 - if not icon_source.is_dir(): 210 - print(f"Error: bundled icons not found at {icon_source}", file=sys.stderr) 211 - return 1 212 - 213 - # Install icons to ~/.local/share/icons/hicolor/ 214 - icon_dest = Path.home() / ".local" / "share" / "icons" / "hicolor" 215 - status_dir = icon_dest / "scalable" / "status" 216 - status_dir.mkdir(parents=True, exist_ok=True) 217 - 218 - for svg in sorted((icon_source / "scalable" / "status").iterdir()): 219 - if svg.suffix == ".svg": 220 - shutil.copy2(svg, status_dir / svg.name) 221 - print(f"Installed {status_dir / svg.name}") 222 - 223 - # Copy index.theme only if one doesn't already exist 224 - index_dest = icon_dest / "index.theme" 225 - if not index_dest.exists(): 226 - shutil.copy2(icon_source / "index.theme", index_dest) 227 - print(f"Wrote {index_dest}") 228 - 229 - # Update icon cache (non-fatal if gtk-update-icon-cache is missing) 230 - try: 231 - subprocess.run(["gtk-update-icon-cache", str(icon_dest)], check=False) 232 - except FileNotFoundError: 233 - print("Warning: gtk-update-icon-cache not found. Icon cache not updated.") 193 + if icon_source.is_dir(): 194 + icon_dest = Path.home() / ".local" / "share" / "icons" / "hicolor" 195 + status_dir = icon_dest / "scalable" / "status" 196 + status_dir.mkdir(parents=True, exist_ok=True) 234 197 235 - # Write autostart desktop entry 236 - autostart_dir = Path.home() / ".config" / "autostart" 237 - autostart_dir.mkdir(parents=True, exist_ok=True) 238 - desktop_path = autostart_dir / "solstone-tray.desktop" 198 + for svg in sorted((icon_source / "scalable" / "status").iterdir()): 199 + if svg.suffix == ".svg": 200 + shutil.copy2(svg, status_dir / svg.name) 201 + print(f"Installed {status_dir / svg.name}") 239 202 240 - desktop_content = f"""\ 241 - [Desktop Entry] 242 - Type=Application 243 - Name=Solstone Tray 244 - Comment=System tray status for solstone observer 245 - Exec={binary} 246 - Icon=solstone-recording 247 - NoDisplay=true 248 - """ 203 + # Copy index.theme only if one doesn't already exist 204 + index_dest = icon_dest / "index.theme" 205 + if not index_dest.exists(): 206 + shutil.copy2(icon_source / "index.theme", index_dest) 207 + print(f"Wrote {index_dest}") 249 208 250 - desktop_path.write_text(desktop_content) 251 - print(f"Wrote {desktop_path}") 252 - print("Tray will auto-start on next login.") 209 + # Update icon cache (non-fatal) 210 + try: 211 + subprocess.run(["gtk-update-icon-cache", str(icon_dest)], check=False) 212 + except FileNotFoundError: 213 + pass 253 214 254 215 return 0 255 216 ··· 353 314 # install-service 354 315 subparsers.add_parser("install-service", help="Install systemd user service") 355 316 356 - # install-tray 357 - subparsers.add_parser("install-tray", help="Install tray icons and autostart entry") 358 - 359 317 # status 360 318 subparsers.add_parser("status", help="Show capture and sync state") 361 319 ··· 369 327 "run": cmd_run, 370 328 "setup": cmd_setup, 371 329 "install-service": cmd_install_service, 372 - "install-tray": cmd_install_tray, 373 330 "status": cmd_status, 374 331 } 375 332
+2 -12
src/solstone_linux/dbus_service.py
··· 84 84 85 85 @method() 86 86 def Pause(self, duration_seconds: "i") -> "s": 87 - self._observer._paused = True 88 - if duration_seconds > 0: 89 - self._observer._pause_until = time.monotonic() + duration_seconds 90 - else: 91 - self._observer._pause_until = 0.0 92 - self.StatusChanged("paused") 93 - logger.info("Pause requested: %ss", duration_seconds) 87 + self._observer.pause(duration_seconds) 94 88 return "ok" 95 89 96 90 @method() 97 91 def Resume(self) -> "s": 98 - self._observer._paused = False 99 - self._observer._pause_until = 0.0 100 - self.StatusChanged( 101 - "recording" if self._observer.current_mode == "screencast" else "idle" 102 - ) 92 + self._observer.resume() 103 93 return "ok" 104 94 105 95 @method()
+61
src/solstone_linux/observer.py
··· 118 118 119 119 # D-Bus service interface 120 120 self._dbus_service = None 121 + self._tray = None 121 122 122 123 async def setup(self) -> bool: 123 124 """Initialize audio devices, DBus connection, and sync service.""" ··· 168 169 await self.bus.request_name(BUS_NAME) 169 170 self._sync._dbus_service = self._dbus_service 170 171 logger.info("D-Bus service exported as %s", BUS_NAME) 172 + 173 + # Initialize system tray (graceful: skip if no StatusNotifierWatcher) 174 + try: 175 + from .tray import TrayApp 176 + 177 + tray = TrayApp(self, self.bus) 178 + started = await tray.start() 179 + if started: 180 + self._tray = tray 181 + logger.info("System tray active") 182 + else: 183 + logger.info("System tray unavailable (no StatusNotifierWatcher)") 184 + except Exception as e: 185 + logger.info("System tray disabled: %s", e) 186 + 171 187 logger.info("Sync service initialized") 172 188 173 189 return True ··· 422 438 stream=self.stream, 423 439 ) 424 440 441 + def pause(self, duration_seconds: int): 442 + """Pause capture. duration_seconds=0 means indefinite.""" 443 + self._paused = True 444 + if duration_seconds > 0: 445 + self._pause_until = time.monotonic() + duration_seconds 446 + else: 447 + self._pause_until = 0.0 448 + if self._dbus_service: 449 + self._dbus_service.StatusChanged("paused") 450 + logger.info("Paused for %ss", duration_seconds) 451 + 452 + def resume(self): 453 + """Resume capture from pause.""" 454 + self._paused = False 455 + self._pause_until = 0.0 456 + if self._dbus_service: 457 + self._dbus_service.StatusChanged( 458 + "recording" if self.current_mode == MODE_SCREENCAST else "idle" 459 + ) 460 + logger.info("Resumed") 461 + 425 462 async def main_loop(self): 426 463 """Run the main observer loop with background sync.""" 427 464 logger.info(f"Starting observer loop (interval={self.interval}s)") ··· 481 518 else "idle" 482 519 ) 483 520 logger.info("Auto-resumed from timed pause") 521 + if self._tray: 522 + try: 523 + self._tray.update() 524 + except Exception: 525 + logger.warning( 526 + "Tray update failed, disabling tray", exc_info=True 527 + ) 528 + self._tray = None 484 529 485 530 # Handle paused state 486 531 if self._paused: ··· 596 641 if mode_changed and self._dbus_service: 597 642 status = "recording" if new_mode == MODE_SCREENCAST else "idle" 598 643 self._dbus_service.StatusChanged(status) 644 + if self._tray: 645 + try: 646 + self._tray.update() 647 + except Exception: 648 + logger.warning( 649 + "Tray update failed, disabling tray", exc_info=True 650 + ) 651 + self._tray = None 599 652 600 653 # Emit status event 601 654 self.emit_status() 655 + if self._tray: 656 + try: 657 + self._tray.update() 658 + except Exception: 659 + logger.warning( 660 + "Tray update failed, disabling tray", exc_info=True 661 + ) 662 + self._tray = None 602 663 finally: 603 664 # Cleanup on exit 604 665 logger.info("Observer loop stopped, cleaning up...")
+66 -163
src/solstone_linux/tray.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 - """solstone tray app — pure D-Bus SNI implementation. 3 + """solstone tray app — in-process D-Bus SNI component. 4 4 5 - Connects to the observer backend over D-Bus and displays a system 6 - tray icon with status, menus, and tooltip. No GUI toolkit dependency. 5 + Exports the tray icon, menu, and tooltip on the observer's existing 6 + session bus connection. No separate tray process is required. 7 7 """ 8 8 9 9 import asyncio 10 10 import logging 11 11 import os 12 - import signal 13 12 import subprocess 14 - import sys 13 + import time 15 14 from pathlib import Path 16 15 17 16 from dbus_next.aio import MessageBus 18 17 19 - from .config import load_config 20 18 from .dbusmenu import DBusMenu, MenuItem, separator 21 19 from .sni import StatusNotifierItem, register_with_watcher 22 20 23 21 log = logging.getLogger(__name__) 24 - 25 - BACKEND_BUS = "org.solpbc.solstone.Observer1" 26 - BACKEND_PATH = "/org/solpbc/solstone/Observer1" 27 22 28 23 # Icon names — these reference SVGs in our icon theme 29 24 ICONS = { ··· 48 43 49 44 50 45 class TrayApp: 51 - """Main tray application coordinating SNI, menu, and backend.""" 46 + """In-process tray component — exports SNI on the observer's bus.""" 52 47 53 - def __init__(self): 54 - self.config = load_config() 55 - self.bus: MessageBus = None 48 + def __init__(self, observer, bus): 49 + self._observer = observer 50 + self.config = observer.config 51 + self.bus: MessageBus = bus 56 52 self.sni = StatusNotifierItem("solstone-observer") 57 53 self.menu = DBusMenu() 58 - self.backend = None 59 - self.backend_props = None 60 54 61 - # State cache 55 + # State cache (for change detection) 62 56 self.status = "recording" 63 57 self.sync_status = "synced" 64 58 self.sync_progress = "" ··· 77 71 self._resume_item: MenuItem = None 78 72 79 73 async def start(self): 80 - self.bus = await MessageBus().connect() 81 - 82 74 pid = os.getpid() 83 75 bus_name = f"org.kde.StatusNotifierItem-{pid}-1" 84 76 await self.bus.request_name(bus_name) ··· 110 102 # Build menu 111 103 self._build_menu() 112 104 113 - # Register with watcher (with retries) 105 + # Register with watcher (3 attempts) 114 106 registered = False 115 - for attempt in range(6): 107 + for attempt in range(3): 116 108 registered = await register_with_watcher(self.bus, bus_name) 117 109 if registered: 118 110 break 119 - if attempt < 5: 120 - await asyncio.sleep(2) 121 - log.info(f"Retry {attempt + 1}/5...") 111 + if attempt < 2: 112 + await asyncio.sleep(1) 113 + log.info(f"SNI watcher retry {attempt + 1}/2...") 122 114 123 115 if not registered: 124 - log.error("Could not register with StatusNotifierWatcher.") 116 + log.info("No StatusNotifierWatcher available") 125 117 return False 126 118 127 - # Connect to backend 128 - await self._connect_backend() 119 + return True 120 + 121 + def update(self): 122 + """Read observer state and update tray display.""" 123 + obs = self._observer 124 + 125 + # Determine status 126 + if obs._paused: 127 + status = "paused" 128 + elif obs.current_mode == "screencast": 129 + status = "recording" 130 + else: 131 + status = "idle" 132 + 133 + # Sync status 134 + sync_status = "synced" 135 + sync_progress = "" 136 + if obs._sync: 137 + sync_status = obs._sync.sync_status 138 + sync_progress = obs._sync.sync_progress 139 + 140 + # Segment timer 141 + if obs._paused or obs.segment_dir is None: 142 + segment_timer = 0 143 + else: 144 + remaining = obs.interval - (time.monotonic() - obs.start_at_mono) 145 + segment_timer = max(0, int(remaining)) 146 + 147 + # Pause remaining 148 + if not obs._paused or obs._pause_until <= 0: 149 + pause_remaining = 0 150 + else: 151 + pause_remaining = max(0, int(obs._pause_until - time.monotonic())) 129 152 130 - # Start background tasks 131 - asyncio.create_task(self._poll_backend()) 153 + # Get stats 154 + if obs._dbus_service: 155 + try: 156 + raw_stats = obs._dbus_service.GetStats() 157 + self.stats = {k: v.value for k, v in raw_stats.items()} 158 + except Exception: 159 + pass 132 160 133 - return True 161 + self._update_status(status) 162 + self._update_sync(sync_status, sync_progress) 163 + self._update_live_stats(segment_timer, pause_remaining) 164 + self.paused_remaining = pause_remaining 134 165 135 166 def _build_menu(self): 136 167 """Build the full tray menu structure.""" ··· 254 285 ] 255 286 ) 256 287 257 - async def _connect_backend(self): 258 - """Connect to the observer's D-Bus interface.""" 259 - try: 260 - introspection = await self.bus.introspect(BACKEND_BUS, BACKEND_PATH) 261 - proxy = self.bus.get_proxy_object(BACKEND_BUS, BACKEND_PATH, introspection) 262 - self.backend = proxy.get_interface("org.solpbc.solstone.Observer1") 263 - self.backend_props = proxy.get_interface("org.freedesktop.DBus.Properties") 264 - 265 - # Subscribe to signals 266 - self.backend.on_status_changed(self._on_status_changed) 267 - self.backend.on_sync_progress_changed(self._on_sync_progress_changed) 268 - self.backend.on_error_occurred(self._on_error_occurred) 269 - 270 - log.info("Connected to observer backend") 271 - except Exception as e: 272 - log.warning(f"Backend not available: {e}") 273 - self._update_status("stopped") 274 - 275 - async def _poll_backend(self): 276 - """Poll backend for state updates every 5 seconds.""" 277 - while True: 278 - await asyncio.sleep(5) 279 - try: 280 - if self.backend is None: 281 - await self._connect_backend() 282 - continue 283 - 284 - status = await self.backend.get_status() 285 - sync_status = await self.backend.get_sync_status() 286 - sync_progress = await self.backend.get_sync_progress() 287 - error = await self.backend.get_error() 288 - pause_remaining = await self.backend.get_pause_remaining() 289 - segment_timer = await self.backend.get_segment_timer() 290 - 291 - # Get stats 292 - try: 293 - stats = await self.backend.call_get_stats() 294 - self.stats = {k: v.value for k, v in stats.items()} 295 - except Exception: 296 - pass 297 - 298 - self._update_status(status) 299 - self._update_sync(sync_status, sync_progress) 300 - self._update_live_stats(segment_timer, pause_remaining) 301 - self.paused_remaining = pause_remaining 302 - 303 - if error and error != self.error: 304 - self.error = error 305 - log.info(f"Error: {error}") 306 - elif not error and self.error: 307 - self.error = "" 308 - log.info("Error cleared") 309 - 310 - except Exception as e: 311 - log.warning(f"Poll failed: {e}") 312 - self.backend = None 313 - self.backend_props = None 314 - self._update_status("stopped") 315 - 316 288 def _update_status(self, status: str): 317 289 """Update tray icon and menu for observer status.""" 318 290 if status == self.status: ··· 359 331 else: 360 332 self.sni.set_status("Active") 361 333 362 - log.info(f"Status \u2192 {status} (icon: {icon})") 334 + log.info(f"Status -> {status} (icon: {icon})") 363 335 364 336 def _update_sync(self, sync_status: str, progress: str): 365 337 """Update sync status display.""" ··· 443 415 444 416 return "<br>".join(parts) 445 417 446 - # ── Signal handlers ── 447 - 448 - def _on_status_changed(self, status: str): 449 - self._update_status(status) 450 - 451 - def _on_sync_progress_changed(self, progress: str): 452 - if ":" in progress: 453 - sync_status, sync_progress = progress.split(":", 1) 454 - self._update_sync(sync_status, sync_progress) 455 - 456 - def _on_error_occurred(self, message: str): 457 - self.error = message 458 - if message: 459 - self.sni.set_status("NeedsAttention") 460 - self.sni.set_icon(ICONS["error"]) 461 - else: 462 - self.sni.set_status("Active") 463 - self._update_status(self.status) 464 - 465 418 # ── Menu callbacks ── 466 419 467 420 def _pause(self, seconds: int): 468 421 log.info(f"Pause: {seconds}s") 469 - if self.backend: 470 - asyncio.create_task(self._do_pause(seconds)) 471 - 472 - async def _do_pause(self, seconds: int): 473 - try: 474 - await self.backend.call_pause(seconds) 475 - except Exception as e: 476 - log.error(f"Pause failed: {e}") 422 + self._observer.pause(seconds) 477 423 478 424 def _resume(self): 479 425 log.info("Resume") 480 - if self.backend: 481 - asyncio.create_task(self._do_resume()) 482 - 483 - async def _do_resume(self): 484 - try: 485 - await self.backend.call_resume() 486 - except Exception as e: 487 - log.error(f"Resume failed: {e}") 426 + self._observer.resume() 488 427 489 428 def _open_journal(self): 490 429 log.info("Opening journal") ··· 534 473 proc.communicate(text.encode()) 535 474 log.info("Copied to clipboard") 536 475 except FileNotFoundError: 537 - # Fallback: try xdg-open or xsel 476 + # Fallback: try xsel 538 477 try: 539 478 proc = subprocess.Popen( 540 479 ["xsel", "--clipboard", "--input"], stdin=subprocess.PIPE ··· 554 493 log.error(f"Failed to open URL: {e}") 555 494 556 495 def _quit(self): 557 - log.info("Quit requested") 558 - asyncio.get_event_loop().stop() 559 - 560 - async def stop(self): 561 - if self.bus: 562 - self.bus.disconnect() 563 - 564 - 565 - async def _async_main(): 566 - logging.basicConfig( 567 - level=logging.INFO, 568 - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", 569 - datefmt="%H:%M:%S", 570 - ) 571 - 572 - app = TrayApp() 573 - 574 - loop = asyncio.get_event_loop() 575 - stop = loop.create_future() 576 - for sig in (signal.SIGINT, signal.SIGTERM): 577 - loop.add_signal_handler(sig, stop.set_result, None) 578 - 579 - started = await app.start() 580 - if not started: 581 - sys.exit(1) 582 - 583 - log.info("Tray app running. Ctrl+C to stop.") 584 - await stop 585 - await app.stop() 586 - log.info("Stopped.") 587 - 588 - 589 - def main(): 590 - asyncio.run(_async_main()) 591 - 592 - 593 - if __name__ == "__main__": 594 - main() 496 + log.info("Quit requested via tray") 497 + self._observer.running = False
+6 -23
tests/test_dbus_service.py
··· 72 72 73 73 74 74 class TestPauseResume: 75 - def test_pause_sets_state(self): 75 + def test_pause_calls_observer(self): 76 76 observer = _make_observer() 77 77 service = ObserverService(observer) 78 - before = time.monotonic() 79 78 80 79 result = _call_method(service, "Pause", 30) 81 80 82 81 assert result == "ok" 83 - assert observer._paused is True 84 - assert before + 29 <= observer._pause_until <= before + 31 82 + observer.pause.assert_called_once_with(30) 85 83 86 - def test_pause_indefinite(self): 84 + def test_pause_indefinite_calls_observer(self): 87 85 observer = _make_observer() 88 86 service = ObserverService(observer) 89 87 90 88 _call_method(service, "Pause", 0) 91 89 92 - assert observer._paused is True 93 - assert observer._pause_until == 0.0 90 + observer.pause.assert_called_once_with(0) 94 91 95 - def test_resume_clears_state(self): 92 + def test_resume_calls_observer(self): 96 93 observer = _make_observer() 97 94 service = ObserverService(observer) 98 - _call_method(service, "Pause", 30) 99 95 100 96 result = _call_method(service, "Resume") 101 97 102 98 assert result == "ok" 103 - assert observer._paused is False 104 - assert observer._pause_until == 0.0 105 - 106 - def test_resume_returns_mode(self): 107 - observer = _make_observer() 108 - observer.current_mode = "idle" 109 - observer._paused = True 110 - observer._pause_until = time.monotonic() + 30 111 - service = ObserverService(observer) 112 - 113 - result = _call_method(service, "Resume") 114 - 115 - assert result == "ok" 116 - assert _get_prop(service, "Status") == "idle" 99 + observer.resume.assert_called_once() 117 100 118 101 119 102 class TestAutoResume:
+31 -21
tests/test_tray.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - from unittest.mock import MagicMock, patch 4 + import time 5 + from pathlib import Path 6 + from unittest.mock import MagicMock 5 7 6 8 from solstone_linux.config import Config 7 9 from solstone_linux.dbusmenu import MenuItem, separator ··· 13 15 if tmp_path: 14 16 config.base_dir = tmp_path 15 17 config.server_url = "https://test.example.com" 16 - with patch("solstone_linux.tray.load_config", return_value=config): 17 - app = TrayApp() 18 + observer = MagicMock() 19 + observer.config = config 20 + observer._paused = False 21 + observer._pause_until = 0.0 22 + observer.current_mode = "screencast" 23 + observer.segment_dir = None 24 + observer.interval = 300 25 + observer.start_at_mono = time.monotonic() 26 + observer._sync = None 27 + observer._dbus_service = None 28 + bus = MagicMock() 29 + app = TrayApp(observer, bus) 18 30 return app 19 31 20 32 21 33 class TestTrayInit: 22 - def test_make_app_loads_config(self): 34 + def test_make_app_uses_observer_config(self): 23 35 app = _make_app() 24 36 25 37 assert isinstance(app, TrayApp) ··· 167 179 assert "Sync: 2/5" in tooltip 168 180 169 181 170 - class TestSignalHandlers: 171 - def test_on_sync_progress_changed_parses_status_and_progress(self): 172 - app = _make_app() 173 - app._build_menu() 174 - 175 - app._on_sync_progress_changed("uploading:3/10 segments") 176 - 177 - assert app.sync_status == "uploading" 178 - assert app.sync_progress == "3/10 segments" 179 - 180 - def test_on_sync_progress_changed_without_colon_keeps_state(self): 182 + class TestUpdate: 183 + def test_update_reads_observer_state(self): 181 184 app = _make_app() 182 185 app._build_menu() 183 - app._on_sync_progress_changed("uploading:3/10 segments") 186 + app._observer.current_mode = "screencast" 187 + app._observer._paused = False 188 + app._observer.segment_dir = Path("/tmp/test.incomplete") 189 + app._observer.start_at_mono = time.monotonic() - 60 190 + app._observer.interval = 300 184 191 185 - app._on_sync_progress_changed("no-colon") 192 + app.update() 186 193 187 - assert app.sync_status == "uploading" 188 - assert app.sync_progress == "3/10 segments" 194 + assert app.status == "recording" 195 + assert app._segment_item.label.startswith(("segment: 4:", "segment: 3:")) 189 196 190 - def test_on_status_changed_updates_status(self): 197 + def test_update_shows_paused(self): 191 198 app = _make_app() 192 199 app._build_menu() 200 + app._observer._paused = True 201 + app._observer._pause_until = time.monotonic() + 600 193 202 194 - app._on_status_changed("paused") 203 + app.update() 195 204 196 205 assert app.status == "paused" 206 + assert app._resume_item.visible is True 197 207 198 208 199 209 class TestConfigIntegration: