linux observer
0
fork

Configure Feed

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

Drop idle time polling, add cross-desktop screen lock and power save detection

Remove get_idle_time_ms() and IDLE_THRESHOLD_MS entirely. Rewrite
is_screen_locked() with FDO ScreenSaver → GNOME ScreenSaver fallback
chain. Rewrite is_power_save_active() with GNOME Mutter → KDE Solid
fallback chain. Simplify observer mode logic to screen_locked or
power_save. Add XDG_CURRENT_DESKTOP to systemd PassEnvironment. Add
pytest-asyncio dev dependency and tests for all fallback paths.

+306 -78
+2 -2
AGENTS.md
··· 20 20 audio_recorder.py Stereo audio recording (mic + system via soundcard) 21 21 audio_detect.py Audio device detection via ultrasonic tone 22 22 audio_mute.py PulseAudio mute state detection 23 - activity.py GNOME-specific activity detection (idle, screen lock, power save) 23 + activity.py Cross-desktop activity detection (screen lock, power save) via DBus 24 24 monitor_positions.py Monitor position assignment from geometry 25 25 session_env.py Desktop session environment checks and recovery 26 26 streams.py Stream name derivation (hostname-based) ··· 101 101 102 102 ## Key Patterns 103 103 104 - - **Activity detection is GNOME-specific.** Uses Mutter IdleMonitor, GNOME ScreenSaver, and Mutter DisplayConfig DBus interfaces. Other desktops capture screen and audio but won't get activity-based segment boundaries. 104 + - **Activity detection is cross-desktop.** Uses ordered DBus fallback chains for screen lock (freedesktop.org ScreenSaver → GNOME ScreenSaver) and power save (Mutter DisplayConfig → KDE Solid PowerManagement). All backends degrade gracefully to safe defaults. 105 105 - **Audio is stereo-interleaved.** Left channel = microphone, right channel = system audio. When muted, channels are split into separate mono FLAC files. 106 106 - **Screencast uses xdg-desktop-portal.** Session persistence via restore tokens avoids re-prompting the user. GStreamer subprocess (`gst-launch-1.0`) handles the actual PipeWire recording. 107 107 - **Crash recovery runs on startup.** `recovery.py` scans for orphaned `.incomplete` directories older than 2 minutes and finalizes or marks them as failed.
+1 -1
contrib/solstone-linux.service
··· 6 6 [Service] 7 7 Type=simple 8 8 ExecStart=/usr/bin/solstone-linux run 9 - PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR 9 + PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP 10 10 Restart=on-failure 11 11 RestartSec=10 12 12 StartLimitIntervalSec=300
+1
pyproject.toml
··· 20 20 [dependency-groups] 21 21 dev = [ 22 22 "pytest", 23 + "pytest-asyncio", 23 24 "ruff", 24 25 ] 25 26
+59 -67
src/solstone_linux/activity.py
··· 3 3 4 4 """Activity detection using DBus APIs. 5 5 6 - Extracted from solstone's observe/gnome/activity.py. The DBus services 7 - probed here are GNOME-specific; on other desktops (KDE, etc.) they may 8 - not be available. Every function degrades gracefully — returning a safe 9 - default — so the observer keeps running regardless of desktop environment. 10 - 11 - Changes from monorepo version: 12 - - Replaces `from observe.utils import assign_monitor_positions` with local module 6 + Detects screen lock and display power-save state via DBus, with ordered 7 + fallback chains that cover GNOME and KDE desktops. Every function 8 + degrades gracefully — returning a safe default — so the observer keeps 9 + running regardless of desktop environment. 13 10 """ 14 11 15 12 import logging ··· 33 30 except (ImportError, ValueError): 34 31 _HAS_GTK = False 35 32 36 - # DBus service constants 37 - IDLE_MONITOR_BUS = "org.gnome.Mutter.IdleMonitor" 38 - IDLE_MONITOR_PATH = "/org/gnome/Mutter/IdleMonitor/Core" 39 - IDLE_MONITOR_IFACE = "org.gnome.Mutter.IdleMonitor" 33 + # DBus service constants — screen lock 34 + FDO_SCREENSAVER_BUS = "org.freedesktop.ScreenSaver" 35 + FDO_SCREENSAVER_PATH = "/ScreenSaver" 36 + FDO_SCREENSAVER_IFACE = "org.freedesktop.ScreenSaver" 40 37 41 - SCREENSAVER_BUS = "org.gnome.ScreenSaver" 42 - SCREENSAVER_PATH = "/org/gnome/ScreenSaver" 43 - SCREENSAVER_IFACE = "org.gnome.ScreenSaver" 38 + GNOME_SCREENSAVER_BUS = "org.gnome.ScreenSaver" 39 + GNOME_SCREENSAVER_PATH = "/org/gnome/ScreenSaver" 40 + GNOME_SCREENSAVER_IFACE = "org.gnome.ScreenSaver" 44 41 42 + # DBus service constants — power save 45 43 DISPLAY_CONFIG_BUS = "org.gnome.Mutter.DisplayConfig" 46 44 DISPLAY_CONFIG_PATH = "/org/gnome/Mutter/DisplayConfig" 47 45 DISPLAY_CONFIG_IFACE = "org.gnome.Mutter.DisplayConfig" 48 46 47 + KDE_POWER_BUS = "org.kde.Solid.PowerManagement" 48 + KDE_POWER_PATH = "/org/kde/Solid/PowerManagement" 49 + KDE_POWER_IFACE = "org.kde.Solid.PowerManagement" 50 + 49 51 50 52 async def probe_activity_services(bus: MessageBus) -> dict[str, bool]: 51 53 """Check which activity DBus services are reachable. ··· 54 56 only — the observer runs regardless of what's available. 55 57 """ 56 58 services = { 57 - "idle_monitor": IDLE_MONITOR_BUS, 58 - "screensaver": SCREENSAVER_BUS, 59 - "display_config": DISPLAY_CONFIG_BUS, 59 + "fdo_screensaver": FDO_SCREENSAVER_BUS, 60 + "gnome_screensaver": GNOME_SCREENSAVER_BUS, 61 + "gnome_display_config": DISPLAY_CONFIG_BUS, 62 + "kde_power": KDE_POWER_BUS, 60 63 } 61 64 results = {} 62 65 for name, bus_name in services.items(): ··· 66 69 except Exception: 67 70 results[name] = False 68 71 69 - available = [k for k, v in results.items() if v] 70 - missing = [k for k, v in results.items() if not v] 71 - if missing: 72 + # Log grouped by function 73 + lock_backends = ["fdo_screensaver", "gnome_screensaver"] 74 + power_backends = ["gnome_display_config", "kde_power"] 75 + 76 + def _status(keys): 77 + return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys) 78 + 79 + logger.info("Screen lock backends: %s", _status(lock_backends)) 80 + logger.info("Power save backends: %s", _status(power_backends)) 81 + 82 + any_lock = any(results[k] for k in lock_backends) 83 + any_power = any(results[k] for k in power_backends) 84 + if not any_lock and not any_power: 72 85 logger.warning( 73 - "Activity signals unavailable: %s — observer will assume active", 74 - ", ".join(missing), 75 - ) 76 - if available: 77 - logger.info("Activity signals available: %s", ", ".join(available)) 78 - if not available: 79 - logger.warning( 80 - "No activity signals available (non-GNOME desktop?) " 81 - "— running in always-capture mode" 86 + "No activity backends available — running in always-capture mode" 82 87 ) 83 88 84 89 results["gtk4"] = _HAS_GTK ··· 88 93 return results 89 94 90 95 91 - async def get_idle_time_ms(bus: MessageBus) -> int: 92 - """ 93 - Get the current idle time in milliseconds. 94 - 95 - Args: 96 - bus: Connected DBus session bus 96 + async def is_screen_locked(bus: MessageBus) -> bool: 97 + """Check if the screen is locked via FDO ScreenSaver, then GNOME ScreenSaver. 97 98 98 - Returns: 99 - Idle time in milliseconds, or 0 if the service is unavailable 100 - (0 = assume active, so the observer keeps capturing). 99 + Returns True if locked, False if unlocked or all backends unavailable. 101 100 """ 101 + # Try freedesktop.org ScreenSaver first (KDE kwin + GNOME) 102 102 try: 103 - introspection = await bus.introspect(IDLE_MONITOR_BUS, IDLE_MONITOR_PATH) 104 - proxy_obj = bus.get_proxy_object( 105 - IDLE_MONITOR_BUS, IDLE_MONITOR_PATH, introspection 106 - ) 107 - idle_monitor = proxy_obj.get_interface(IDLE_MONITOR_IFACE) 108 - idle_time = await idle_monitor.call_get_idletime() 109 - return idle_time 103 + intro = await bus.introspect(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH) 104 + obj = bus.get_proxy_object(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH, intro) 105 + iface = obj.get_interface(FDO_SCREENSAVER_IFACE) 106 + return bool(await iface.call_get_active()) 110 107 except Exception: 111 - return 0 112 - 113 - 114 - async def is_screen_locked(bus: MessageBus) -> bool: 115 - """ 116 - Check if the screen is currently locked using GNOME ScreenSaver. 108 + pass 117 109 118 - Args: 119 - bus: Connected DBus session bus 120 - 121 - Returns: 122 - True if screen is locked, False otherwise 123 - """ 110 + # Fall back to GNOME ScreenSaver 124 111 try: 125 - intro = await bus.introspect(SCREENSAVER_BUS, SCREENSAVER_PATH) 126 - obj = bus.get_proxy_object(SCREENSAVER_BUS, SCREENSAVER_PATH, intro) 127 - iface = obj.get_interface(SCREENSAVER_IFACE) 112 + intro = await bus.introspect(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH) 113 + obj = bus.get_proxy_object(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH, intro) 114 + iface = obj.get_interface(GNOME_SCREENSAVER_IFACE) 128 115 return bool(await iface.call_get_active()) 129 116 except Exception: 130 117 return False 131 118 132 119 133 120 async def is_power_save_active(bus: MessageBus) -> bool: 134 - """ 135 - Check if display power save mode is active (screen blanked). 121 + """Check display power save via GNOME Mutter, then KDE Solid. 136 122 137 - Args: 138 - bus: Connected DBus session bus 139 - 140 - Returns: 141 - True if power save is active, False otherwise 123 + Returns True if power save is active, False otherwise. 142 124 """ 125 + # Try GNOME Mutter DisplayConfig first 143 126 try: 144 127 intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH) 145 128 obj = bus.get_proxy_object(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH, intro) ··· 147 130 mode_variant = await iface.call_get(DISPLAY_CONFIG_IFACE, "PowerSaveMode") 148 131 mode = int(mode_variant.value) 149 132 return mode != 0 133 + except Exception: 134 + pass 135 + 136 + # Fall back to KDE Solid PowerManagement 137 + try: 138 + intro = await bus.introspect(KDE_POWER_BUS, KDE_POWER_PATH) 139 + obj = bus.get_proxy_object(KDE_POWER_BUS, KDE_POWER_PATH, intro) 140 + iface = obj.get_interface(KDE_POWER_IFACE) 141 + return bool(await iface.call_is_lid_closed()) 150 142 except Exception: 151 143 return False 152 144
+2 -8
src/solstone_linux/observer.py
··· 33 33 from dbus_next.constants import BusType 34 34 35 35 from .activity import ( 36 - get_idle_time_ms, 37 36 is_power_save_active, 38 37 is_screen_locked, 39 38 probe_activity_services, ··· 53 52 PLATFORM = platform.system().lower() 54 53 55 54 # Constants 56 - IDLE_THRESHOLD_MS = 5 * 60 * 1000 # 5 minutes 57 55 RMS_THRESHOLD = 0.01 58 56 MIN_HITS_FOR_SAVE = 3 59 57 CHUNK_DURATION = 5 # seconds ··· 107 105 108 106 # Activity status cache (updated each loop) 109 107 self.cached_is_active = False 110 - self.cached_idle_time_ms = 0 111 108 self.cached_screen_locked = False 112 109 self.cached_is_muted = False 113 110 self.cached_power_save = False ··· 138 135 self.audio_recorder.start_recording() 139 136 logger.info("Audio recording started") 140 137 141 - # Connect to DBus for idle/lock detection 138 + # Connect to DBus for activity detection 142 139 self.bus = await MessageBus(bus_type=BusType.SESSION).connect() 143 140 logger.info("DBus connection established") 144 141 ··· 162 159 163 160 async def check_activity_status(self) -> str: 164 161 """Check system activity status and determine capture mode.""" 165 - idle_time = await get_idle_time_ms(self.bus) 166 162 screen_locked = await is_screen_locked(self.bus) 167 163 power_save = await is_power_save_active(self.bus) 168 164 sink_muted = await is_sink_muted() 169 165 170 166 # Cache values for status events 171 - self.cached_idle_time_ms = idle_time 172 167 self.cached_screen_locked = screen_locked 173 168 self.cached_is_muted = sink_muted 174 169 self.cached_power_save = power_save 175 170 176 171 # Determine screen activity 177 - screen_idle = (idle_time > IDLE_THRESHOLD_MS) or screen_locked or power_save 172 + screen_idle = screen_locked or power_save 178 173 screen_active = not screen_idle 179 174 180 175 # Determine mode ··· 395 390 # Activity info 396 391 activity_info = { 397 392 "active": self.cached_is_active, 398 - "idle_time_ms": self.cached_idle_time_ms, 399 393 "screen_locked": self.cached_screen_locked, 400 394 "sink_muted": self.cached_is_muted, 401 395 "power_save": self.cached_power_save,
+241
tests/test_activity.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for cross-desktop activity detection backends.""" 5 + 6 + import logging 7 + from unittest.mock import AsyncMock, MagicMock, call 8 + 9 + import pytest 10 + 11 + from solstone_linux import activity 12 + 13 + 14 + def _make_proxy_with_interface(interface: MagicMock) -> MagicMock: 15 + proxy = MagicMock() 16 + proxy.get_interface.return_value = interface 17 + return proxy 18 + 19 + 20 + def _make_variant(value: int) -> MagicMock: 21 + variant = MagicMock() 22 + variant.value = value 23 + return variant 24 + 25 + 26 + class TestIsScreenLocked: 27 + """Test screen lock fallback order.""" 28 + 29 + @pytest.mark.asyncio 30 + async def test_fdo_backend_returns_true_without_gnome_fallback(self): 31 + bus = MagicMock() 32 + bus.introspect = AsyncMock(return_value=object()) 33 + iface = MagicMock() 34 + iface.call_get_active = AsyncMock(return_value=True) 35 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 36 + 37 + result = await activity.is_screen_locked(bus) 38 + 39 + assert result is True 40 + assert bus.introspect.await_count == 1 41 + bus.introspect.assert_awaited_once_with( 42 + activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH 43 + ) 44 + 45 + @pytest.mark.asyncio 46 + async def test_fdo_backend_returns_false_without_gnome_fallback(self): 47 + bus = MagicMock() 48 + bus.introspect = AsyncMock(return_value=object()) 49 + iface = MagicMock() 50 + iface.call_get_active = AsyncMock(return_value=False) 51 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 52 + 53 + result = await activity.is_screen_locked(bus) 54 + 55 + assert result is False 56 + assert bus.introspect.await_count == 1 57 + bus.introspect.assert_awaited_once_with( 58 + activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH 59 + ) 60 + 61 + @pytest.mark.asyncio 62 + async def test_fdo_failure_gnome_returns_true(self): 63 + bus = MagicMock() 64 + bus.introspect = AsyncMock(side_effect=[Exception("fdo unavailable"), object()]) 65 + gnome_iface = MagicMock() 66 + gnome_iface.call_get_active = AsyncMock(return_value=True) 67 + bus.get_proxy_object.return_value = _make_proxy_with_interface(gnome_iface) 68 + 69 + result = await activity.is_screen_locked(bus) 70 + 71 + assert result is True 72 + assert bus.introspect.await_args_list == [ 73 + call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), 74 + call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), 75 + ] 76 + 77 + @pytest.mark.asyncio 78 + async def test_fdo_failure_gnome_returns_false(self): 79 + bus = MagicMock() 80 + bus.introspect = AsyncMock(side_effect=[Exception("fdo unavailable"), object()]) 81 + gnome_iface = MagicMock() 82 + gnome_iface.call_get_active = AsyncMock(return_value=False) 83 + bus.get_proxy_object.return_value = _make_proxy_with_interface(gnome_iface) 84 + 85 + result = await activity.is_screen_locked(bus) 86 + 87 + assert result is False 88 + assert bus.introspect.await_args_list == [ 89 + call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), 90 + call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), 91 + ] 92 + 93 + @pytest.mark.asyncio 94 + async def test_both_backends_fail_returns_false(self): 95 + bus = MagicMock() 96 + bus.introspect = AsyncMock( 97 + side_effect=[Exception("fdo unavailable"), Exception("gnome unavailable")] 98 + ) 99 + 100 + result = await activity.is_screen_locked(bus) 101 + 102 + assert result is False 103 + assert bus.introspect.await_args_list == [ 104 + call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), 105 + call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), 106 + ] 107 + 108 + 109 + class TestIsPowerSaveActive: 110 + """Test power save fallback order.""" 111 + 112 + @pytest.mark.asyncio 113 + async def test_gnome_backend_nonzero_mode_returns_true(self): 114 + bus = MagicMock() 115 + bus.introspect = AsyncMock(return_value=object()) 116 + iface = MagicMock() 117 + iface.call_get = AsyncMock(return_value=_make_variant(2)) 118 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 119 + 120 + result = await activity.is_power_save_active(bus) 121 + 122 + assert result is True 123 + bus.introspect.assert_awaited_once_with( 124 + activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH 125 + ) 126 + 127 + @pytest.mark.asyncio 128 + async def test_gnome_backend_zero_mode_returns_false(self): 129 + bus = MagicMock() 130 + bus.introspect = AsyncMock(return_value=object()) 131 + iface = MagicMock() 132 + iface.call_get = AsyncMock(return_value=_make_variant(0)) 133 + bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) 134 + 135 + result = await activity.is_power_save_active(bus) 136 + 137 + assert result is False 138 + bus.introspect.assert_awaited_once_with( 139 + activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH 140 + ) 141 + 142 + @pytest.mark.asyncio 143 + async def test_gnome_failure_kde_lid_closed_returns_true(self): 144 + bus = MagicMock() 145 + bus.introspect = AsyncMock( 146 + side_effect=[Exception("gnome unavailable"), object()] 147 + ) 148 + kde_iface = MagicMock() 149 + kde_iface.call_is_lid_closed = AsyncMock(return_value=True) 150 + bus.get_proxy_object.return_value = _make_proxy_with_interface(kde_iface) 151 + 152 + result = await activity.is_power_save_active(bus) 153 + 154 + assert result is True 155 + assert bus.introspect.await_args_list == [ 156 + call(activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH), 157 + call(activity.KDE_POWER_BUS, activity.KDE_POWER_PATH), 158 + ] 159 + 160 + @pytest.mark.asyncio 161 + async def test_gnome_failure_kde_lid_open_returns_false(self): 162 + bus = MagicMock() 163 + bus.introspect = AsyncMock( 164 + side_effect=[Exception("gnome unavailable"), object()] 165 + ) 166 + kde_iface = MagicMock() 167 + kde_iface.call_is_lid_closed = AsyncMock(return_value=False) 168 + bus.get_proxy_object.return_value = _make_proxy_with_interface(kde_iface) 169 + 170 + result = await activity.is_power_save_active(bus) 171 + 172 + assert result is False 173 + assert bus.introspect.await_args_list == [ 174 + call(activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH), 175 + call(activity.KDE_POWER_BUS, activity.KDE_POWER_PATH), 176 + ] 177 + 178 + @pytest.mark.asyncio 179 + async def test_both_backends_fail_returns_false(self): 180 + bus = MagicMock() 181 + bus.introspect = AsyncMock( 182 + side_effect=[Exception("gnome unavailable"), Exception("kde unavailable")] 183 + ) 184 + 185 + result = await activity.is_power_save_active(bus) 186 + 187 + assert result is False 188 + assert bus.introspect.await_args_list == [ 189 + call(activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH), 190 + call(activity.KDE_POWER_BUS, activity.KDE_POWER_PATH), 191 + ] 192 + 193 + 194 + class TestProbeActivityServices: 195 + """Test activity backend probing and logging.""" 196 + 197 + @pytest.mark.asyncio 198 + async def test_all_services_available_returns_true_results(self): 199 + bus = MagicMock() 200 + bus.introspect = AsyncMock(return_value=object()) 201 + 202 + results = await activity.probe_activity_services(bus) 203 + 204 + assert results["fdo_screensaver"] is True 205 + assert results["gnome_screensaver"] is True 206 + assert results["gnome_display_config"] is True 207 + assert results["kde_power"] is True 208 + assert results["gtk4"] is activity._HAS_GTK 209 + 210 + @pytest.mark.asyncio 211 + async def test_no_services_available_logs_warning(self, caplog): 212 + bus = MagicMock() 213 + bus.introspect = AsyncMock(side_effect=Exception("missing")) 214 + 215 + with caplog.at_level(logging.WARNING): 216 + results = await activity.probe_activity_services(bus) 217 + 218 + assert results["fdo_screensaver"] is False 219 + assert results["gnome_screensaver"] is False 220 + assert results["gnome_display_config"] is False 221 + assert results["kde_power"] is False 222 + assert "No activity backends available" in caplog.text 223 + 224 + @pytest.mark.asyncio 225 + async def test_mixed_service_availability_returns_correct_results(self): 226 + bus = MagicMock() 227 + bus.introspect = AsyncMock( 228 + side_effect=[ 229 + object(), 230 + Exception("missing"), 231 + object(), 232 + Exception("missing"), 233 + ] 234 + ) 235 + 236 + results = await activity.probe_activity_services(bus) 237 + 238 + assert results["fdo_screensaver"] is True 239 + assert results["gnome_screensaver"] is False 240 + assert results["gnome_display_config"] is True 241 + assert results["kde_power"] is False