solstone-linux 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Standalone Linux desktop observer for solstone."""
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,384 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Activity detection using DBus APIs.
5
+
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.
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import os
15
+
16
+ from dbus_next import Variant
17
+ from dbus_next.aio import MessageBus
18
+ from dbus_next.errors import (
19
+ DBusError,
20
+ InvalidIntrospectionError,
21
+ InvalidMemberNameError,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _DBUS_PROBE_TIMEOUT_SEC = 2.0
27
+
28
+ _SERVICE_MISSING_ERRORS = (
29
+ "org.freedesktop.DBus.Error.ServiceUnknown",
30
+ "org.freedesktop.DBus.Error.NameHasNoOwner",
31
+ )
32
+
33
+ # GTK4/GDK4 — optional, only needed for monitor geometry detection.
34
+ # On systems without GTK4, get_monitor_geometries() will raise RuntimeError
35
+ # but screencast recording still works (monitors labeled as "monitor-N").
36
+ try:
37
+ import gi
38
+
39
+ gi.require_version("Gdk", "4.0")
40
+ gi.require_version("Gtk", "4.0")
41
+ from gi.repository import Gdk, Gtk
42
+
43
+ _HAS_GTK = True
44
+ except (ImportError, ValueError):
45
+ _HAS_GTK = False
46
+
47
+ # DBus service constants — screen lock
48
+ FDO_SCREENSAVER_BUS = "org.freedesktop.ScreenSaver"
49
+ FDO_SCREENSAVER_PATH = "/ScreenSaver"
50
+ FDO_SCREENSAVER_IFACE = "org.freedesktop.ScreenSaver"
51
+
52
+ GNOME_SCREENSAVER_BUS = "org.gnome.ScreenSaver"
53
+ GNOME_SCREENSAVER_PATH = "/org/gnome/ScreenSaver"
54
+ GNOME_SCREENSAVER_IFACE = "org.gnome.ScreenSaver"
55
+
56
+ # DBus service constants — power save
57
+ DISPLAY_CONFIG_BUS = "org.gnome.Mutter.DisplayConfig"
58
+ DISPLAY_CONFIG_PATH = "/org/gnome/Mutter/DisplayConfig"
59
+ DISPLAY_CONFIG_IFACE = "org.gnome.Mutter.DisplayConfig"
60
+
61
+ KDE_POWER_BUS = "org.kde.Solid.PowerManagement"
62
+ KDE_POWER_PATH = "/org/kde/Solid/PowerManagement"
63
+ KDE_POWER_IFACE = "org.kde.Solid.PowerManagement"
64
+
65
+ # DBus service constants — monitor geometry (KDE)
66
+ KSCREEN_BUS = "org.kde.KScreen"
67
+ KSCREEN_PATH = "/backend"
68
+ KSCREEN_IFACE = "org.kde.kscreen.Backend"
69
+
70
+
71
+ def _is_service_missing(exc: BaseException) -> bool:
72
+ """True if exc is a DBusError meaning the bus name is not currently owned."""
73
+ return (
74
+ isinstance(exc, DBusError)
75
+ and getattr(exc, "type", "") in _SERVICE_MISSING_ERRORS
76
+ )
77
+
78
+
79
+ def _is_gnome_desktop() -> bool:
80
+ """True if any XDG_CURRENT_DESKTOP token equals 'gnome' (case-insensitive)."""
81
+ return any(
82
+ token.strip().casefold() == "gnome"
83
+ for token in os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")
84
+ )
85
+
86
+
87
+ async def _name_has_owner(bus: MessageBus, bus_name: str) -> bool:
88
+ """Ask the bus daemon whether a well-known name is currently owned.
89
+
90
+ Returns False on any probe failure (daemon unreachable, timeout, parser
91
+ error) after logging a warning — the service is treated as absent.
92
+ """
93
+
94
+ async def _probe() -> bool:
95
+ intro = await bus.introspect("org.freedesktop.DBus", "/org/freedesktop/DBus")
96
+ obj = bus.get_proxy_object(
97
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", intro
98
+ )
99
+ iface = obj.get_interface("org.freedesktop.DBus")
100
+ return bool(await iface.call_name_has_owner(bus_name))
101
+
102
+ try:
103
+ return await asyncio.wait_for(_probe(), timeout=_DBUS_PROBE_TIMEOUT_SEC)
104
+ except (DBusError, InvalidMemberNameError, OSError, asyncio.TimeoutError) as exc:
105
+ logger.warning(
106
+ "NameHasOwner probe failed: service=%s path=%s: %s: %s",
107
+ bus_name,
108
+ "/org/freedesktop/DBus",
109
+ type(exc).__name__,
110
+ exc,
111
+ )
112
+ return False
113
+
114
+
115
+ async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
116
+ """Check which activity DBus services are reachable."""
117
+ services = {
118
+ "fdo_screensaver": FDO_SCREENSAVER_BUS,
119
+ "gnome_screensaver": GNOME_SCREENSAVER_BUS,
120
+ "gnome_display_config": DISPLAY_CONFIG_BUS,
121
+ "kde_power": KDE_POWER_BUS,
122
+ "kscreen": KSCREEN_BUS,
123
+ }
124
+ results = {}
125
+ for name, bus_name in services.items():
126
+ results[name] = await _name_has_owner(bus, bus_name)
127
+
128
+ # Log grouped by function
129
+ lock_backends = ["fdo_screensaver", "gnome_screensaver"]
130
+ power_backends = ["gnome_display_config", "kde_power"]
131
+ monitor_backends = ["kscreen"]
132
+ results["gtk4"] = _HAS_GTK
133
+
134
+ def _status(keys):
135
+ return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys)
136
+
137
+ logger.info("Screen lock backends: %s", _status(lock_backends))
138
+ logger.info("Power save backends: %s", _status(power_backends))
139
+ logger.info(
140
+ "Monitor backends: %s, gtk4 [%s]",
141
+ _status(monitor_backends),
142
+ "ok" if results["gtk4"] else "missing",
143
+ )
144
+
145
+ any_lock = any(results[k] for k in lock_backends)
146
+ any_power = any(results[k] for k in power_backends)
147
+ if not any_lock and not any_power:
148
+ logger.warning(
149
+ "No activity backends available — running in always-capture mode"
150
+ )
151
+
152
+ return results
153
+
154
+
155
+ async def is_screen_locked(bus: MessageBus) -> bool:
156
+ """Check if the screen is locked.
157
+
158
+ On GNOME, probes only org.gnome.ScreenSaver — the FDO ScreenSaver bus
159
+ on GNOME serves idle-inhibit endpoints only and does not implement
160
+ GetActive. On non-GNOME desktops, tries FDO ScreenSaver first (KDE
161
+ kwin and other compliant desktops), then falls back to GNOME
162
+ ScreenSaver. Returns True if locked, False if unlocked or all
163
+ backends unavailable.
164
+ """
165
+ if not _is_gnome_desktop():
166
+ # Try freedesktop.org ScreenSaver first (KDE kwin and other non-GNOME desktops)
167
+ try:
168
+ intro = await bus.introspect(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH)
169
+ obj = bus.get_proxy_object(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH, intro)
170
+ iface = obj.get_interface(FDO_SCREENSAVER_IFACE)
171
+ return bool(await iface.call_get_active())
172
+ except (
173
+ DBusError,
174
+ InvalidMemberNameError,
175
+ InvalidIntrospectionError,
176
+ OSError,
177
+ ) as exc:
178
+ if not _is_service_missing(exc):
179
+ logger.warning(
180
+ "is_screen_locked FDO backend failed: service=%s path=%s: %s: %s",
181
+ FDO_SCREENSAVER_BUS,
182
+ FDO_SCREENSAVER_PATH,
183
+ type(exc).__name__,
184
+ exc,
185
+ )
186
+
187
+ # Fall back to GNOME ScreenSaver
188
+ try:
189
+ intro = await bus.introspect(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH)
190
+ obj = bus.get_proxy_object(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH, intro)
191
+ iface = obj.get_interface(GNOME_SCREENSAVER_IFACE)
192
+ return bool(await iface.call_get_active())
193
+ except (
194
+ DBusError,
195
+ InvalidMemberNameError,
196
+ InvalidIntrospectionError,
197
+ OSError,
198
+ ) as exc:
199
+ if not _is_service_missing(exc):
200
+ logger.warning(
201
+ "is_screen_locked GNOME backend failed: service=%s path=%s: %s: %s",
202
+ GNOME_SCREENSAVER_BUS,
203
+ GNOME_SCREENSAVER_PATH,
204
+ type(exc).__name__,
205
+ exc,
206
+ )
207
+ return False
208
+
209
+
210
+ async def is_power_save_active(bus: MessageBus) -> bool:
211
+ """Check display power save via GNOME Mutter, then KDE Solid.
212
+
213
+ Returns True if power save is active, False otherwise.
214
+ """
215
+ # Try GNOME Mutter DisplayConfig first
216
+ try:
217
+ intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH)
218
+ obj = bus.get_proxy_object(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH, intro)
219
+ iface = obj.get_interface("org.freedesktop.DBus.Properties")
220
+ mode_variant = await iface.call_get(DISPLAY_CONFIG_IFACE, "PowerSaveMode")
221
+ mode = int(mode_variant.value)
222
+ return mode != 0
223
+ except (
224
+ DBusError,
225
+ InvalidMemberNameError,
226
+ InvalidIntrospectionError,
227
+ OSError,
228
+ ) as exc:
229
+ if not _is_service_missing(exc):
230
+ logger.warning(
231
+ "is_power_save_active Mutter backend failed: service=%s path=%s: %s: %s",
232
+ DISPLAY_CONFIG_BUS,
233
+ DISPLAY_CONFIG_PATH,
234
+ type(exc).__name__,
235
+ exc,
236
+ )
237
+
238
+ # Fall back to KDE Solid PowerManagement
239
+ try:
240
+ intro = await bus.introspect(KDE_POWER_BUS, KDE_POWER_PATH)
241
+ obj = bus.get_proxy_object(KDE_POWER_BUS, KDE_POWER_PATH, intro)
242
+ iface = obj.get_interface(KDE_POWER_IFACE)
243
+ return bool(await iface.call_is_lid_closed())
244
+ except (
245
+ DBusError,
246
+ InvalidMemberNameError,
247
+ InvalidIntrospectionError,
248
+ OSError,
249
+ ) as exc:
250
+ if not _is_service_missing(exc):
251
+ logger.warning(
252
+ "is_power_save_active KDE backend failed: service=%s path=%s: %s: %s",
253
+ KDE_POWER_BUS,
254
+ KDE_POWER_PATH,
255
+ type(exc).__name__,
256
+ exc,
257
+ )
258
+ return False
259
+
260
+
261
+ def get_monitor_geometries() -> list[dict]:
262
+ """
263
+ Get structured monitor information.
264
+
265
+ Returns:
266
+ List of dicts with format:
267
+ [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...]
268
+ where box contains [left, top, right, bottom] coordinates
269
+
270
+ Raises:
271
+ RuntimeError: If GTK4/GDK4 is not available.
272
+ """
273
+ if not _HAS_GTK:
274
+ raise RuntimeError("GTK4 not available for monitor geometry detection")
275
+
276
+ from .monitor_positions import assign_monitor_positions
277
+
278
+ # Initialize GTK before using GDK functions
279
+ Gtk.init()
280
+
281
+ # Get the default display. If it is None, try opening one from the environment.
282
+ display = Gdk.Display.get_default()
283
+ if display is None:
284
+ env_display = os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")
285
+ if env_display is not None:
286
+ display = Gdk.Display.open(env_display)
287
+ if display is None:
288
+ raise RuntimeError("No display available")
289
+ monitors = display.get_monitors()
290
+
291
+ # Collect monitor geometries
292
+ geometries = []
293
+ for monitor in monitors:
294
+ geom = monitor.get_geometry()
295
+ connector = monitor.get_connector() or f"monitor-{len(geometries)}"
296
+ geometries.append(
297
+ {
298
+ "id": connector,
299
+ "box": [geom.x, geom.y, geom.x + geom.width, geom.y + geom.height],
300
+ }
301
+ )
302
+
303
+ # Assign position labels using shared algorithm
304
+ return assign_monitor_positions(geometries)
305
+
306
+
307
+ def _unwrap_variants(obj):
308
+ """Recursively unwrap dbus-next Variants in nested DBus structures."""
309
+ if isinstance(obj, Variant):
310
+ return _unwrap_variants(obj.value)
311
+ if isinstance(obj, dict):
312
+ return {key: _unwrap_variants(value) for key, value in obj.items()}
313
+ if isinstance(obj, list):
314
+ return [_unwrap_variants(value) for value in obj]
315
+ if isinstance(obj, tuple):
316
+ return tuple(_unwrap_variants(value) for value in obj)
317
+ return obj
318
+
319
+
320
+ async def get_monitor_geometries_kscreen(bus: MessageBus) -> list[dict]:
321
+ """
322
+ Get monitor geometry information from KDE KScreen DBus.
323
+
324
+ Returns:
325
+ List of dicts with format:
326
+ [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...]
327
+ """
328
+ try:
329
+ from .monitor_positions import assign_monitor_positions
330
+
331
+ intro = await bus.introspect(KSCREEN_BUS, KSCREEN_PATH)
332
+ obj = bus.get_proxy_object(KSCREEN_BUS, KSCREEN_PATH, intro)
333
+ iface = obj.get_interface(KSCREEN_IFACE)
334
+ config = _unwrap_variants(await iface.call_get_config())
335
+ outputs = config.get("outputs", {})
336
+ output_values = outputs.values() if isinstance(outputs, dict) else outputs
337
+
338
+ geometries = []
339
+ for output in output_values:
340
+ if not isinstance(output, dict):
341
+ continue
342
+ if not output.get("enabled") or not output.get("connected"):
343
+ continue
344
+
345
+ name = output.get("name")
346
+ pos = output.get("pos", {})
347
+ size = output.get("size", {})
348
+ if not isinstance(name, str) or not isinstance(pos, dict):
349
+ continue
350
+ if not isinstance(size, dict):
351
+ continue
352
+
353
+ x = int(pos.get("x", 0))
354
+ y = int(pos.get("y", 0))
355
+ scale = float(output.get("scale", 1.0) or 1.0)
356
+ width = int(size.get("width", 0))
357
+ height = int(size.get("height", 0))
358
+ logical_width = round(width / scale)
359
+ logical_height = round(height / scale)
360
+ geometries.append(
361
+ {
362
+ "id": name,
363
+ "box": [x, y, x + logical_width, y + logical_height],
364
+ }
365
+ )
366
+
367
+ monitors = assign_monitor_positions(geometries)
368
+ logger.debug("KScreen monitor geometries found: %d", len(monitors))
369
+ return monitors
370
+ except (
371
+ DBusError,
372
+ InvalidMemberNameError,
373
+ InvalidIntrospectionError,
374
+ OSError,
375
+ ) as exc:
376
+ if not _is_service_missing(exc):
377
+ logger.warning(
378
+ "get_monitor_geometries_kscreen failed: service=%s path=%s: %s: %s",
379
+ KSCREEN_BUS,
380
+ KSCREEN_PATH,
381
+ type(exc).__name__,
382
+ exc,
383
+ )
384
+ return []
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Audio device detection via ultrasonic tone.
5
+
6
+ Direct copy from solstone's observe/detect.py — no solstone imports.
7
+ Plays an ultrasonic tone and records from all mics to identify
8
+ microphone vs loopback devices.
9
+ """
10
+
11
+ import logging
12
+ import threading
13
+
14
+ import numpy as np
15
+ import soundcard as sc
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def input_detect(duration=0.4, sample_rate=44100):
21
+ t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
22
+ tone = 0.5 * np.sin(2 * np.pi * 18000 * t) # ultrasonic
23
+
24
+ try:
25
+ devices = sc.all_microphones(include_loopback=True)
26
+ except Exception:
27
+ logger.warning("Failed to enumerate audio devices")
28
+ return None, None
29
+ if not devices:
30
+ logger.warning("No audio devices found")
31
+ return None, None
32
+
33
+ results = {}
34
+ barrier = threading.Barrier(len(devices) + 1)
35
+
36
+ def record_mic(mic, results):
37
+ barrier.wait()
38
+ try:
39
+ audio = mic.record(
40
+ samplerate=sample_rate, numframes=int(sample_rate * duration)
41
+ )
42
+ results[mic.name] = audio
43
+ except Exception:
44
+ results[mic.name] = None
45
+
46
+ def play_tone():
47
+ barrier.wait()
48
+ try:
49
+ sp = sc.default_speaker()
50
+ sp.play(tone, samplerate=sample_rate)
51
+ except Exception:
52
+ logger.warning("No default speaker available for tone detection")
53
+
54
+ threads = []
55
+ for mic in devices:
56
+ thread = threading.Thread(target=record_mic, args=(mic, results))
57
+ thread.start()
58
+ threads.append(thread)
59
+
60
+ play_thread = threading.Thread(target=play_tone)
61
+ play_thread.start()
62
+ threads.append(play_thread)
63
+
64
+ for thread in threads:
65
+ thread.join()
66
+
67
+ # Analyze the recordings with a simple amplitude threshold
68
+ threshold = 0.001
69
+ mic_detected = None
70
+ loopback_detected = None
71
+ for mic in devices:
72
+ audio = results.get(mic.name)
73
+ if audio is not None and np.max(np.abs(audio)) > threshold:
74
+ # First match for each category
75
+ if "microphone" in str(mic).lower() and mic_detected is None:
76
+ mic_detected = mic
77
+ if "loopback" in str(mic).lower() and loopback_detected is None:
78
+ loopback_detected = mic
79
+ return mic_detected, loopback_detected
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Linux audio mute detection using PulseAudio/PipeWire.
5
+
6
+ Direct copy from solstone's observe/linux/audio.py — no solstone imports.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ async def is_sink_muted() -> bool:
16
+ """
17
+ Check if the default audio sink is muted using PulseAudio.
18
+
19
+ Uses `pactl get-sink-mute @DEFAULT_SINK@` to query mute status.
20
+
21
+ Returns:
22
+ True if muted, False otherwise (including on error).
23
+ """
24
+ try:
25
+ proc = await asyncio.create_subprocess_exec(
26
+ "pactl",
27
+ "get-sink-mute",
28
+ "@DEFAULT_SINK@",
29
+ stdout=asyncio.subprocess.PIPE,
30
+ stderr=asyncio.subprocess.PIPE,
31
+ )
32
+ stdout, stderr = await proc.communicate()
33
+
34
+ if proc.returncode != 0:
35
+ stderr_text = stderr.decode().strip() if stderr else ""
36
+ logger.warning(f"pactl failed (rc={proc.returncode}): {stderr_text}")
37
+ return False
38
+
39
+ output = stdout.decode().strip()
40
+ return "Mute: yes" in output
41
+
42
+ except FileNotFoundError:
43
+ logger.warning("pactl not found, assuming unmuted")
44
+ return False
45
+ except Exception as e:
46
+ logger.warning(f"Error checking sink mute status: {e}")
47
+ return False