linux observer

Add cross-desktop activity detection backends

Drop idle time polling and remove the GNOME idle monitor from activity detection, keeping observer mode selection based on screen lock or power save state.\n\nAdd freedesktop.ScreenSaver with GNOME ScreenSaver fallback for cross-desktop lock detection, and add KDE Solid PowerManagement as a power save fallback.\n\nUpdate both systemd unit definitions to pass XDG_CURRENT_DESKTOP through the user service environment, and add async fallback-chain tests with pytest-asyncio coverage.

+289 -83
+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
+57 -73
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 power save state across GNOME and KDE desktops 7 + using freedesktop, GNOME, and KDE DBus interfaces with ordered fallback 8 + chains. Every function degrades gracefully — returning a safe default — 9 + so the observer keeps running regardless of desktop environment. 13 10 """ 14 11 15 12 import logging ··· 34 31 _HAS_GTK = False 35 32 36 33 # 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" 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 45 42 DISPLAY_CONFIG_BUS = "org.gnome.Mutter.DisplayConfig" 46 43 DISPLAY_CONFIG_PATH = "/org/gnome/Mutter/DisplayConfig" 47 44 DISPLAY_CONFIG_IFACE = "org.gnome.Mutter.DisplayConfig" 48 45 46 + KDE_POWER_BUS = "org.kde.Solid.PowerManagement" 47 + KDE_POWER_PATH = "/org/kde/Solid/PowerManagement" 48 + KDE_POWER_IFACE = "org.kde.Solid.PowerManagement" 49 49 50 - async def probe_activity_services(bus: MessageBus) -> dict[str, bool]: 51 - """Check which activity DBus services are reachable. 52 50 53 - Returns a dict of service name -> available. Used for startup logging 54 - only — the observer runs regardless of what's available. 55 - """ 51 + async def probe_activity_services(bus: MessageBus) -> dict[str, bool]: 52 + """Check which activity DBus services are reachable.""" 56 53 services = { 57 - "idle_monitor": IDLE_MONITOR_BUS, 58 - "screensaver": SCREENSAVER_BUS, 59 - "display_config": DISPLAY_CONFIG_BUS, 54 + "fdo_screensaver": FDO_SCREENSAVER_BUS, 55 + "gnome_screensaver": GNOME_SCREENSAVER_BUS, 56 + "gnome_display_config": DISPLAY_CONFIG_BUS, 57 + "kde_power": KDE_POWER_BUS, 60 58 } 61 59 results = {} 62 60 for name, bus_name in services.items(): ··· 66 64 except Exception: 67 65 results[name] = False 68 66 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 - 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" 82 - ) 67 + lock_backends = ["fdo_screensaver", "gnome_screensaver"] 68 + power_backends = ["gnome_display_config", "kde_power"] 69 + 70 + lock_available = [name for name in lock_backends if results.get(name)] 71 + power_available = [name for name in power_backends if results.get(name)] 72 + 73 + if lock_available: 74 + logger.info("Screen lock backends: %s", ", ".join(lock_available)) 75 + else: 76 + logger.warning("No screen lock backends available — will assume unlocked") 77 + 78 + if power_available: 79 + logger.info("Power save backends: %s", ", ".join(power_available)) 80 + else: 81 + logger.warning("No power save backends available — will assume active display") 83 82 84 83 results["gtk4"] = _HAS_GTK 85 84 if not _HAS_GTK: ··· 88 87 return results 89 88 90 89 91 - async def get_idle_time_ms(bus: MessageBus) -> int: 92 - """ 93 - Get the current idle time in milliseconds. 90 + async def is_screen_locked(bus: MessageBus) -> bool: 91 + """Check if the screen is currently locked. 94 92 95 - Args: 96 - bus: Connected DBus session bus 97 - 98 - Returns: 99 - Idle time in milliseconds, or 0 if the service is unavailable 100 - (0 = assume active, so the observer keeps capturing). 93 + Tries freedesktop.ScreenSaver first (works on KDE, GNOME, and others), 94 + then falls back to GNOME ScreenSaver. 101 95 """ 102 96 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 97 + intro = await bus.introspect(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH) 98 + obj = bus.get_proxy_object(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH, intro) 99 + iface = obj.get_interface(FDO_SCREENSAVER_IFACE) 100 + return bool(await iface.call_get_active()) 110 101 except Exception: 111 - return 0 112 - 102 + pass 113 103 114 - async def is_screen_locked(bus: MessageBus) -> bool: 115 - """ 116 - Check if the screen is currently locked using GNOME ScreenSaver. 117 - 118 - Args: 119 - bus: Connected DBus session bus 120 - 121 - Returns: 122 - True if screen is locked, False otherwise 123 - """ 124 104 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) 105 + intro = await bus.introspect(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH) 106 + obj = bus.get_proxy_object(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH, intro) 107 + iface = obj.get_interface(GNOME_SCREENSAVER_IFACE) 128 108 return bool(await iface.call_get_active()) 129 109 except Exception: 130 110 return False 131 111 132 112 133 113 async def is_power_save_active(bus: MessageBus) -> bool: 134 - """ 135 - Check if display power save mode is active (screen blanked). 136 - 137 - Args: 138 - bus: Connected DBus session bus 114 + """Check if display power save mode is active. 139 115 140 - Returns: 141 - True if power save is active, False otherwise 116 + Tries GNOME Mutter DisplayConfig first (DPMS state), then falls back 117 + to KDE Solid PowerManagement lid state. 142 118 """ 143 119 try: 144 120 intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH) ··· 147 123 mode_variant = await iface.call_get(DISPLAY_CONFIG_IFACE, "PowerSaveMode") 148 124 mode = int(mode_variant.value) 149 125 return mode != 0 126 + except Exception: 127 + pass 128 + 129 + try: 130 + intro = await bus.introspect(KDE_POWER_BUS, KDE_POWER_PATH) 131 + obj = bus.get_proxy_object(KDE_POWER_BUS, KDE_POWER_PATH, intro) 132 + iface = obj.get_interface(KDE_POWER_IFACE) 133 + return bool(await iface.call_is_lid_closed()) 150 134 except Exception: 151 135 return False 152 136
+1 -1
src/solstone_linux/cli.py
··· 159 159 [Service] 160 160 Type=simple 161 161 ExecStart={binary} run 162 - PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR 162 + PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP 163 163 Restart=on-failure 164 164 RestartSec=10 165 165 StartLimitIntervalSec=300
+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,
+227
tests/test_activity.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for desktop activity detection fallbacks.""" 5 + 6 + from types import SimpleNamespace 7 + from unittest.mock import AsyncMock, MagicMock 8 + 9 + import pytest 10 + 11 + from solstone_linux import activity 12 + 13 + 14 + def _make_proxy(interface_name: str, iface: object) -> MagicMock: 15 + proxy = MagicMock() 16 + proxy.get_interface.side_effect = lambda name: ( 17 + iface if name == interface_name else None 18 + ) 19 + return proxy 20 + 21 + 22 + @pytest.mark.asyncio 23 + async def test_is_screen_locked_prefers_fdo_true(): 24 + bus = MagicMock() 25 + fdo_iface = AsyncMock() 26 + fdo_iface.call_get_active.return_value = True 27 + bus.introspect = AsyncMock(return_value="fdo-intro") 28 + bus.get_proxy_object.return_value = _make_proxy( 29 + activity.FDO_SCREENSAVER_IFACE, fdo_iface 30 + ) 31 + 32 + result = await activity.is_screen_locked(bus) 33 + 34 + assert result is True 35 + bus.introspect.assert_awaited_once_with( 36 + activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH 37 + ) 38 + fdo_iface.call_get_active.assert_awaited_once_with() 39 + 40 + 41 + @pytest.mark.asyncio 42 + async def test_is_screen_locked_prefers_fdo_false(): 43 + bus = MagicMock() 44 + fdo_iface = AsyncMock() 45 + fdo_iface.call_get_active.return_value = False 46 + bus.introspect = AsyncMock(return_value="fdo-intro") 47 + bus.get_proxy_object.return_value = _make_proxy( 48 + activity.FDO_SCREENSAVER_IFACE, fdo_iface 49 + ) 50 + 51 + result = await activity.is_screen_locked(bus) 52 + 53 + assert result is False 54 + bus.introspect.assert_awaited_once_with( 55 + activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH 56 + ) 57 + fdo_iface.call_get_active.assert_awaited_once_with() 58 + 59 + 60 + @pytest.mark.asyncio 61 + async def test_is_screen_locked_falls_back_to_gnome(): 62 + bus = MagicMock() 63 + gnome_iface = AsyncMock() 64 + gnome_iface.call_get_active.return_value = True 65 + bus.introspect = AsyncMock( 66 + side_effect=[Exception("fdo unavailable"), "gnome-intro"] 67 + ) 68 + 69 + def get_proxy_object(bus_name: str, path: str, intro: str) -> MagicMock: 70 + assert (bus_name, path, intro) == ( 71 + activity.GNOME_SCREENSAVER_BUS, 72 + activity.GNOME_SCREENSAVER_PATH, 73 + "gnome-intro", 74 + ) 75 + return _make_proxy(activity.GNOME_SCREENSAVER_IFACE, gnome_iface) 76 + 77 + bus.get_proxy_object.side_effect = get_proxy_object 78 + 79 + result = await activity.is_screen_locked(bus) 80 + 81 + assert result is True 82 + assert bus.introspect.await_args_list == [ 83 + ((activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH),), 84 + ((activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH),), 85 + ] 86 + gnome_iface.call_get_active.assert_awaited_once_with() 87 + 88 + 89 + @pytest.mark.asyncio 90 + async def test_is_screen_locked_returns_false_when_all_backends_fail(): 91 + bus = MagicMock() 92 + bus.introspect = AsyncMock(side_effect=Exception("unavailable")) 93 + 94 + result = await activity.is_screen_locked(bus) 95 + 96 + assert result is False 97 + assert bus.introspect.await_count == 2 98 + 99 + 100 + @pytest.mark.asyncio 101 + async def test_is_power_save_active_uses_gnome_display_config_when_nonzero(): 102 + bus = MagicMock() 103 + props_iface = AsyncMock() 104 + props_iface.call_get.return_value = SimpleNamespace(value=2) 105 + bus.introspect = AsyncMock(return_value="display-intro") 106 + bus.get_proxy_object.return_value = _make_proxy( 107 + "org.freedesktop.DBus.Properties", props_iface 108 + ) 109 + 110 + result = await activity.is_power_save_active(bus) 111 + 112 + assert result is True 113 + bus.introspect.assert_awaited_once_with( 114 + activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH 115 + ) 116 + props_iface.call_get.assert_awaited_once_with( 117 + activity.DISPLAY_CONFIG_IFACE, "PowerSaveMode" 118 + ) 119 + 120 + 121 + @pytest.mark.asyncio 122 + async def test_is_power_save_active_uses_gnome_display_config_when_zero(): 123 + bus = MagicMock() 124 + props_iface = AsyncMock() 125 + props_iface.call_get.return_value = SimpleNamespace(value=0) 126 + bus.introspect = AsyncMock(return_value="display-intro") 127 + bus.get_proxy_object.return_value = _make_proxy( 128 + "org.freedesktop.DBus.Properties", props_iface 129 + ) 130 + 131 + result = await activity.is_power_save_active(bus) 132 + 133 + assert result is False 134 + bus.introspect.assert_awaited_once_with( 135 + activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH 136 + ) 137 + props_iface.call_get.assert_awaited_once_with( 138 + activity.DISPLAY_CONFIG_IFACE, "PowerSaveMode" 139 + ) 140 + 141 + 142 + @pytest.mark.asyncio 143 + async def test_is_power_save_active_falls_back_to_kde(): 144 + bus = MagicMock() 145 + kde_iface = AsyncMock() 146 + kde_iface.call_is_lid_closed.return_value = True 147 + bus.introspect = AsyncMock( 148 + side_effect=[Exception("gnome unavailable"), "kde-intro"] 149 + ) 150 + 151 + def get_proxy_object(bus_name: str, path: str, intro: str) -> MagicMock: 152 + assert (bus_name, path, intro) == ( 153 + activity.KDE_POWER_BUS, 154 + activity.KDE_POWER_PATH, 155 + "kde-intro", 156 + ) 157 + return _make_proxy(activity.KDE_POWER_IFACE, kde_iface) 158 + 159 + bus.get_proxy_object.side_effect = get_proxy_object 160 + 161 + result = await activity.is_power_save_active(bus) 162 + 163 + assert result is True 164 + assert bus.introspect.await_args_list == [ 165 + ((activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH),), 166 + ((activity.KDE_POWER_BUS, activity.KDE_POWER_PATH),), 167 + ] 168 + kde_iface.call_is_lid_closed.assert_awaited_once_with() 169 + 170 + 171 + @pytest.mark.asyncio 172 + async def test_is_power_save_active_returns_false_when_all_backends_fail(): 173 + bus = MagicMock() 174 + bus.introspect = AsyncMock(side_effect=Exception("unavailable")) 175 + 176 + result = await activity.is_power_save_active(bus) 177 + 178 + assert result is False 179 + assert bus.introspect.await_count == 2 180 + 181 + 182 + @pytest.mark.asyncio 183 + async def test_probe_activity_services_all_available(): 184 + bus = MagicMock() 185 + bus.introspect = AsyncMock(return_value="intro") 186 + 187 + results = await activity.probe_activity_services(bus) 188 + 189 + assert results["fdo_screensaver"] is True 190 + assert results["gnome_screensaver"] is True 191 + assert results["gnome_display_config"] is True 192 + assert results["kde_power"] is True 193 + assert results["gtk4"] is activity._HAS_GTK 194 + 195 + 196 + @pytest.mark.asyncio 197 + async def test_probe_activity_services_all_unavailable(): 198 + bus = MagicMock() 199 + bus.introspect = AsyncMock(side_effect=Exception("unavailable")) 200 + 201 + results = await activity.probe_activity_services(bus) 202 + 203 + assert results["fdo_screensaver"] is False 204 + assert results["gnome_screensaver"] is False 205 + assert results["gnome_display_config"] is False 206 + assert results["kde_power"] is False 207 + assert results["gtk4"] is activity._HAS_GTK 208 + 209 + 210 + @pytest.mark.asyncio 211 + async def test_probe_activity_services_mixed_availability(): 212 + bus = MagicMock() 213 + 214 + async def introspect(bus_name: str, path: str) -> str: 215 + if bus_name in {activity.FDO_SCREENSAVER_BUS, activity.KDE_POWER_BUS}: 216 + return "intro" 217 + raise Exception(f"{bus_name} unavailable") 218 + 219 + bus.introspect = AsyncMock(side_effect=introspect) 220 + 221 + results = await activity.probe_activity_services(bus) 222 + 223 + assert results["fdo_screensaver"] is True 224 + assert results["gnome_screensaver"] is False 225 + assert results["gnome_display_config"] is False 226 + assert results["kde_power"] is True 227 + assert results["gtk4"] is activity._HAS_GTK