diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c2c2d..cbe044a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to solstone-linux are documented here. The format is based on Keep a Changelog (https://keepachangelog.com/), and this project adheres to Semantic Versioning. +## [0.3.3] - 2026-06-16 + +### Fixed +- the tray status submenu now refreshes its values every time you open it. the segment countdown, cache size, captures today, uptime, and sync line had been showing stale values on reopen on some desktops; they now reflect the current state each time you open the menu. + ## [0.3.2] - 2026-06-16 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 614c816..73b2d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solstone-linux" -version = "0.3.2" +version = "0.3.3" description = "Standalone Linux desktop observer for solstone" readme = "README.md" license = "AGPL-3.0-only" diff --git a/src/solstone_linux/__init__.py b/src/solstone_linux/__init__.py index 5b7f6ef..1b00afb 100644 --- a/src/solstone_linux/__init__.py +++ b/src/solstone_linux/__init__.py @@ -3,4 +3,4 @@ """Standalone Linux desktop observer for solstone.""" -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/src/solstone_linux/dbusmenu.py b/src/solstone_linux/dbusmenu.py index efa2880..7d3941e 100644 --- a/src/solstone_linux/dbusmenu.py +++ b/src/solstone_linux/dbusmenu.py @@ -86,6 +86,8 @@ class DBusMenu(ServiceInterface): def __init__(self): super().__init__("com.canonical.dbusmenu") + self.on_about_to_show = None + self._props_emitted = 0 self._revision = 1 self._root = MenuItem() # id 0 is root self._root.id = 0 @@ -106,6 +108,7 @@ class DBusMenu(ServiceInterface): return updated = {name: self._property_variant(item, name) for name in names} + self._props_emitted += 1 self.ItemsPropertiesUpdated([[item.id, updated]], []) def _register_items(self, items: list[MenuItem]): @@ -198,11 +201,16 @@ class DBusMenu(ServiceInterface): @method() def AboutToShow(self, item_id: "i") -> "b": - return False # GetLayout always returns fresh state; no pending unsignaled changes. + if self.on_about_to_show is None: + return False + return bool(self.on_about_to_show()) @method() def AboutToShowGroup(self, ids: "ai") -> "aiai": - return [[], []] # no updates, no errors + if self.on_about_to_show is None: + return [[], []] + changed = bool(self.on_about_to_show()) + return [list(ids), []] if changed else [[], []] # ── D-Bus Properties ── diff --git a/src/solstone_linux/tray.py b/src/solstone_linux/tray.py index 3e00524..e78d8ef 100644 --- a/src/solstone_linux/tray.py +++ b/src/solstone_linux/tray.py @@ -160,9 +160,10 @@ class TrayApp: return True - def update(self): + def update(self, force_stats=False): """Read observer state and update tray display.""" obs = self._observer + now = time.monotonic() # Determine status if obs._paused: @@ -178,18 +179,17 @@ class TrayApp: if obs._paused or obs.segment_dir is None: segment_timer = 0 else: - remaining = obs.interval - (time.monotonic() - obs.start_at_mono) + remaining = obs.interval - (now - obs.start_at_mono) segment_timer = max(0, int(remaining)) # Pause remaining if not obs._paused or obs._pause_until <= 0: pause_remaining = 0 else: - pause_remaining = max(0, int(obs._pause_until - time.monotonic())) + pause_remaining = max(0, int(obs._pause_until - now)) # Compute stats (throttled — filesystem walk every 60s) - now = time.monotonic() - if now - self._last_stats_time >= 60: + if force_stats or now - self._last_stats_time >= 60: self._last_stats_time = now captures_today = 0 total_size = 0 @@ -220,7 +220,7 @@ class TrayApp: pass total_size_mb = int(total_size / (1024 * 1024)) - uptime_seconds = int(time.monotonic() - obs._start_mono) + uptime_seconds = int(now - obs._start_mono) self.stats = { "captures_today": captures_today, @@ -234,6 +234,19 @@ class TrayApp: self._update_live_stats(segment_timer, pause_remaining) self.paused_remaining = pause_remaining + def _on_about_to_show(self) -> bool: + """Full recompute on menu open; returns True if any item changed. + + Runs outside _refresh_tray so a failure here never tears down the tray. + """ + before = self.menu._props_emitted + try: + self.update(force_stats=True) + except Exception: + log.warning("Tray on-open recompute failed", exc_info=True) + return False + return self.menu._props_emitted > before + def _build_menu(self): """Build the full tray menu structure.""" @@ -361,6 +374,7 @@ class TrayApp: service_hint, ] ) + self.menu.on_about_to_show = self._on_about_to_show def _icon_for_health(self, status: str, health: SyncHealth) -> str: if self.error: @@ -420,6 +434,7 @@ class TrayApp: self._status_header.label = label self._status_item.label = label self.menu.update_properties(self._status_header, "label") + self.menu.update_properties(self._status_item, "label") def _update_sync(self, health: SyncHealth): """Update sync status display.""" @@ -427,6 +442,7 @@ class TrayApp: return self.health = health self._sync_item.label = health.sync_line + self.menu.update_properties(self._sync_item, "label") if not self.error: self.sni.set_icon(self._icon_for_health(self.status, health)) @@ -446,6 +462,7 @@ class TrayApp: new_label = f"segment: {mins}:{secs:02d} remaining" if self._segment_item.label != new_label: self._segment_item.label = new_label + self.menu.update_properties(self._segment_item, "label") # Stats (computed in update()) if self.stats: @@ -462,10 +479,13 @@ class TrayApp: if self._cache_item.label != new_cache: self._cache_item.label = new_cache + self.menu.update_properties(self._cache_item, "label") if self._captures_item.label != new_captures: self._captures_item.label = new_captures + self.menu.update_properties(self._captures_item, "label") if self._uptime_item.label != new_uptime: self._uptime_item.label = new_uptime + self.menu.update_properties(self._uptime_item, "label") # Update pause remaining in resume button if self.status == "paused" and pause_remaining > 0: @@ -473,6 +493,7 @@ class TrayApp: new_resume = f"resume ({pr_mins}m remaining)" if self._resume_item.label != new_resume: self._resume_item.label = new_resume + self.menu.update_properties(self._resume_item, "label") def _build_tooltip(self, health: SyncHealth | None = None) -> str: """Build plain-text tooltip body (cross-DE compatible).""" diff --git a/tests/test_dbusmenu.py b/tests/test_dbusmenu.py index a43bf1a..98caa21 100644 --- a/tests/test_dbusmenu.py +++ b/tests/test_dbusmenu.py @@ -55,6 +55,7 @@ def test_update_properties_emits_items_properties_updated(): menu.ItemsPropertiesUpdated.assert_called_once() menu.LayoutUpdated.assert_not_called() assert menu._revision == revision + assert menu._props_emitted == 1 updated_props, removed_props = menu.ItemsPropertiesUpdated.call_args.args assert removed_props == [] @@ -79,9 +80,29 @@ def test_update_properties_noop_when_no_names(): menu.ItemsPropertiesUpdated.assert_not_called() menu.LayoutUpdated.assert_not_called() assert menu._revision == revision + assert menu._props_emitted == 0 -def test_about_to_show_returns_false(): +def test_about_to_show_uses_optional_hook(): menu = DBusMenu() assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False + + menu.on_about_to_show = lambda: True + assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is True + + menu.on_about_to_show = lambda: False + assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False + + +def test_about_to_show_group_uses_optional_hook(): + menu = DBusMenu() + ids = [1, 2, 3] + + assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []] + + menu.on_about_to_show = lambda: True + assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [ids, []] + + menu.on_about_to_show = lambda: False + assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []] diff --git a/tests/test_tray.py b/tests/test_tray.py index cd0f78b..af23a11 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -2,6 +2,7 @@ # Copyright (c) 2026 sol pbc import time +from datetime import datetime from pathlib import Path from unittest.mock import call from unittest.mock import MagicMock @@ -10,7 +11,7 @@ from unittest.mock import patch import pytest from solstone_linux.config import Config -from solstone_linux.dbusmenu import MenuItem, separator +from solstone_linux.dbusmenu import DBusMenu, MenuItem, separator from solstone_linux.sni import StatusNotifierItem from solstone_linux.sync_health import ErrorType, HealthState, SyncFacts, derive_health from solstone_linux.tray import ( @@ -60,6 +61,28 @@ def _offline_health(): return _health(SyncFacts(last_error_class=ErrorType.TRANSIENT)) +def _create_capture_segment(app, size=1024 * 1024): + today = datetime.now().strftime("%Y%m%d") + segment_dir = app.config.captures_dir / today / "test-stream" / "120000_300" + segment_dir.mkdir(parents=True) + (segment_dir / "screen.mp4").write_bytes(b"x" * size) + return segment_dir + + +def _prepare_open_refresh_state(app, now): + segment_dir = _create_capture_segment(app) + app._observer.current_mode = "screencast" + app._observer._paused = False + app._observer.segment_dir = segment_dir + app._observer.start_at_mono = now - 75 + app._observer._start_mono = now - 3661 + app._observer.interval = 300 + app._observer._sync = MagicMock() + app._observer._sync.health = _connected_health() + app._last_stats_time = now - 10 + return segment_dir + + class TestResolveIconThemePath: def test_resolve_icon_theme_path_prefers_installed(self, tmp_path): installed_icon = ( @@ -158,6 +181,22 @@ class TestUpdateStatus: class TestUpdateSync: + def test_update_sync_signals_label_change_only_once(self): + app = _make_app() + app._build_menu() + app.menu.update_properties = MagicMock() + health = _connected_health() + + app._update_sync(health) + + app.menu.update_properties.assert_called_once_with(app._sync_item, "label") + + app.menu.update_properties.reset_mock() + + app._update_sync(health) + + app.menu.update_properties.assert_not_called() + def test_update_sync_synced(self): app = _make_app() app._build_menu() @@ -223,12 +262,40 @@ class TestUpdateLiveStats: } app._update_live_stats(245, 0) + + assert app.menu.update_properties.call_args_list == [ + call(app._segment_item, "label"), + call(app._cache_item, "label"), + call(app._captures_item, "label"), + call(app._uptime_item, "label"), + ] + app.menu.update_properties.reset_mock() app._update_live_stats(245, 0) app.menu.update_properties.assert_not_called() + def test_update_live_stats_signals_resume_countdown_change_only_once(self): + app = _make_app() + app._build_menu() + app.status = "paused" + app._segment_item.label = "segment: 0:00 remaining" + app.menu.update_properties = MagicMock() + + app._update_live_stats(0, 600) + + app.menu.update_properties.assert_called_once_with( + app._resume_item, + "label", + ) + + app.menu.update_properties.reset_mock() + + app._update_live_stats(0, 600) + + app.menu.update_properties.assert_not_called() + class TestHeaderLabel: def test_update_header_emits_label_property_update(self): @@ -243,8 +310,21 @@ class TestHeaderLabel: app._update_header(0, _connected_health()) - app.menu.update_properties.assert_called_with(app._status_header, "label") + assert ( + call(app._status_header, "label") + in app.menu.update_properties.call_args_list + ) + assert ( + call(app._status_item, "label") in app.menu.update_properties.call_args_list + ) assert app._status_header.label == "observing — connected" + assert app._status_item.label == "observing — connected" + + app.menu.update_properties.reset_mock() + + app._update_header(0, _connected_health()) + + app.menu.update_properties.assert_not_called() def test_header_recording_connected(self): app = _make_app() @@ -354,6 +434,73 @@ class TestStatusNotifierItem: class TestUpdate: + def test_on_about_to_show_forces_recompute(self, tmp_path): + app = _make_app(tmp_path) + app._build_menu() + now = 10_000.0 + _prepare_open_refresh_state(app, now) + + with patch("solstone_linux.tray.time.monotonic", return_value=now): + changed = app._on_about_to_show() + + assert changed is True + assert app.stats == { + "captures_today": 1, + "total_size_mb": 1, + "uptime_seconds": 3661, + } + assert app._segment_item.label == "segment: 3:45 remaining" + assert app._cache_item.label == "cache: 1 MB" + assert app._captures_item.label == "captures today: 1 segments" + assert app._uptime_item.label == "uptime: 1h 1m" + assert app._sync_item.label == "sync: up to date" + assert app._status_item.label == "observing — connected" + + def test_about_to_show_returns_true_and_layout_has_refreshed_labels(self, tmp_path): + app = _make_app(tmp_path) + app._build_menu() + now = 10_000.0 + _prepare_open_refresh_state(app, now) + + with patch("solstone_linux.tray.time.monotonic", return_value=now): + assert DBusMenu.AboutToShow.__wrapped__(app.menu, 0) is True + + row_items = [ + app._status_item, + app._sync_item, + app._segment_item, + app._cache_item, + app._captures_item, + app._uptime_item, + ] + props_by_id = { + item_id: props + for item_id, props in DBusMenu.GetGroupProperties.__wrapped__( + app.menu, + [item.id for item in row_items], + [], + ) + } + + for item in row_items: + assert props_by_id[item.id]["label"].value == item.label + + def test_on_about_to_show_failure_keeps_tray_and_last_known_layout(self): + app = _make_app() + app._build_menu() + app._observer._tray = app + app.update = MagicMock(side_effect=RuntimeError("boom")) + + assert app._on_about_to_show() is False + assert app._observer._tray is app + + props = DBusMenu.GetGroupProperties.__wrapped__( + app.menu, + [app._status_item.id], + [], + ) + assert props[0][1]["label"].value == "observing" + def test_first_update_clears_starting_tooltip(self): """Tray tooltip must not stay on 'starting...' after first update.""" app = _make_app()