solstone-linux 0.4.2__tar.gz → 0.4.4__tar.gz

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.
Files changed (118) hide show
  1. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/AGENTS.md +11 -1
  2. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/CHANGELOG.md +17 -0
  3. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/INSTALL.md +11 -2
  4. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/Makefile +1 -1
  5. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/PKG-INFO +3 -3
  6. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/README.md +1 -1
  7. solstone_linux-0.4.4/contrib/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  8. solstone_linux-0.4.4/contrib/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  9. solstone_linux-0.4.4/contrib/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  10. solstone_linux-0.4.4/contrib/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  11. solstone_linux-0.4.4/contrib/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  12. solstone_linux-0.4.4/contrib/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  13. solstone_linux-0.4.4/contrib/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  14. solstone_linux-0.4.4/contrib/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  15. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/apps/solstone-observer.svg +6 -6
  16. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-error.svg +1 -1
  17. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +1 -1
  18. {solstone_linux-0.4.2/src/solstone_linux → solstone_linux-0.4.4/contrib}/icons/hicolor/scalable/status/solstone-recording.svg +1 -1
  19. {solstone_linux-0.4.2/src/solstone_linux → solstone_linux-0.4.4/contrib}/icons/hicolor/scalable/status/solstone-syncing.svg +5 -5
  20. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/pyproject.toml +2 -2
  21. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/scripts/release.sh +10 -0
  22. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/__init__.py +1 -1
  23. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/activity.py +65 -36
  24. solstone_linux-0.4.4/src/solstone_linux/audio_detect.py +66 -0
  25. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/audio_recorder.py +59 -4
  26. solstone_linux-0.4.4/src/solstone_linux/capture_stats.py +88 -0
  27. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/chat_bridge.py +122 -100
  28. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/cli.py +19 -19
  29. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/config.py +41 -16
  30. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/dbus_service.py +5 -34
  31. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/dbusmenu.py +3 -3
  32. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/doctor.py +13 -3
  33. solstone_linux-0.4.4/src/solstone_linux/event_sender.py +123 -0
  34. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  35. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  36. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  37. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  38. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  39. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  40. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  41. solstone_linux-0.4.4/src/solstone_linux/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  42. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/apps/solstone-observer.svg +6 -6
  43. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +1 -1
  44. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +1 -1
  45. {solstone_linux-0.4.2/contrib → solstone_linux-0.4.4/src/solstone_linux}/icons/hicolor/scalable/status/solstone-recording.svg +1 -1
  46. {solstone_linux-0.4.2/contrib → solstone_linux-0.4.4/src/solstone_linux}/icons/hicolor/scalable/status/solstone-syncing.svg +5 -5
  47. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/observer.py +78 -70
  48. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/recovery.py +36 -5
  49. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/screencast.py +209 -54
  50. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/sni.py +7 -7
  51. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/solstone-linux.service.in +3 -2
  52. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/sync.py +191 -20
  53. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/sync_health.py +21 -21
  54. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/tray.py +24 -59
  55. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/upload.py +78 -16
  56. solstone_linux-0.4.4/tests/fixtures/introspection/dbusmenu.xml +58 -0
  57. solstone_linux-0.4.4/tests/fixtures/introspection/observer1.xml +35 -0
  58. solstone_linux-0.4.4/tests/fixtures/introspection/status_notifier_item.xml +51 -0
  59. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_activity.py +114 -96
  60. solstone_linux-0.4.4/tests/test_audio_detect.py +75 -0
  61. solstone_linux-0.4.4/tests/test_audio_recorder.py +366 -0
  62. solstone_linux-0.4.4/tests/test_capture_stats.py +40 -0
  63. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_chat_bridge.py +283 -9
  64. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_cli.py +55 -3
  65. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_config.py +62 -1
  66. solstone_linux-0.4.4/tests/test_dbus_introspection.py +98 -0
  67. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_dbus_service.py +34 -18
  68. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_dbusmenu.py +1 -1
  69. solstone_linux-0.4.4/tests/test_docs_mirror.py +57 -0
  70. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_doctor.py +39 -7
  71. solstone_linux-0.4.4/tests/test_event_sender.py +121 -0
  72. solstone_linux-0.4.4/tests/test_observer.py +575 -0
  73. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_observer_emits_stream_silent_event.py +5 -4
  74. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_observer_health_beacon.py +15 -6
  75. solstone_linux-0.4.4/tests/test_screencast.py +837 -0
  76. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_sync.py +602 -43
  77. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_sync_health_surfaces.py +7 -8
  78. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_tray.py +37 -34
  79. solstone_linux-0.4.4/tests/test_upload.py +366 -0
  80. solstone_linux-0.4.4/tests/test_version_match.py +27 -0
  81. solstone_linux-0.4.2/contrib/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  82. solstone_linux-0.4.2/contrib/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  83. solstone_linux-0.4.2/contrib/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  84. solstone_linux-0.4.2/contrib/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  85. solstone_linux-0.4.2/contrib/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  86. solstone_linux-0.4.2/contrib/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  87. solstone_linux-0.4.2/contrib/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  88. solstone_linux-0.4.2/contrib/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  89. solstone_linux-0.4.2/src/solstone_linux/audio_detect.py +0 -79
  90. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  91. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  92. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  93. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  94. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  95. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  96. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  97. solstone_linux-0.4.2/src/solstone_linux/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  98. solstone_linux-0.4.2/tests/test_observer.py +0 -149
  99. solstone_linux-0.4.2/tests/test_screencast.py +0 -411
  100. solstone_linux-0.4.2/tests/test_upload.py +0 -137
  101. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/.gitignore +0 -0
  102. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/CLAUDE.md +0 -0
  103. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/LICENSE +0 -0
  104. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/scripts/extract_changelog.sh +0 -0
  105. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/audio_mute.py +0 -0
  106. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/install_guard.py +0 -0
  107. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/monitor_positions.py +0 -0
  108. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/session_env.py +0 -0
  109. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/src/solstone_linux/streams.py +0 -0
  110. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/__init__.py +0 -0
  111. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/conftest.py +0 -0
  112. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_extract_changelog.py +0 -0
  113. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_install_guard.py +0 -0
  114. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_monitor_positions.py +0 -0
  115. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  116. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_session_env.py +0 -0
  117. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_streams.py +0 -0
  118. {solstone_linux-0.4.2 → solstone_linux-0.4.4}/tests/test_sync_health.py +0 -0
@@ -16,7 +16,10 @@ src/solstone_linux/
16
16
  cli.py CLI entry point (run, setup, settings, install-service, status)
17
17
  solstone-linux.service.in Systemd unit template (rendered by install-service)
18
18
  config.py Config loading/persistence (config under ~/.config/solstone-linux/)
19
+ doctor.py Install prerequisite checks for the doctor command
20
+ install_guard.py Install ownership guard for pipx-managed service installs
19
21
  observer.py Main capture loop — state machine (idle/screencast), audio + video
22
+ capture_stats.py Shared capture cache statistics
20
23
  screencast.py Portal-based multi-monitor recording (xdg-desktop-portal + GStreamer)
21
24
  audio_recorder.py Stereo audio recording (mic + system via soundcard)
22
25
  audio_detect.py Audio device detection via ultrasonic tone
@@ -25,9 +28,16 @@ src/solstone_linux/
25
28
  monitor_positions.py Monitor position assignment from geometry
26
29
  session_env.py Desktop session environment checks and recovery
27
30
  streams.py Stream name derivation (hostname-based)
31
+ event_sender.py Background sender for observer event relay
28
32
  sync.py Background sync service — uploads completed segments to server
33
+ sync_health.py Sync health facts, derivation, persistence, and surface copy
29
34
  upload.py HTTP upload client for solstone ingest server
30
35
  recovery.py Crash recovery for orphaned .incomplete segments
36
+ chat_bridge.py Server-initiated chat event bridge to local notifications
37
+ dbus_service.py Observer status/control D-Bus service interface
38
+ dbusmenu.py D-Bus menu protocol implementation for tray menus
39
+ sni.py StatusNotifierItem D-Bus interface for tray icons
40
+ tray.py In-process D-Bus SNI tray icon, menu, and tooltip
31
41
 
32
42
  tests/ pytest test suite
33
43
  contrib/ Reference icons for development fallback
@@ -123,7 +133,7 @@ Python packages (in pyproject.toml):
123
133
  - `numpy` — Audio buffer manipulation and RMS computation
124
134
  - `soundfile` — FLAC encoding
125
135
  - `soundcard` — Audio device enumeration and recording
126
- - `dbus-next` — Async DBus client for portal and activity detection
136
+ - `dbus-fast` — Async DBus client for portal and activity detection
127
137
  - `PyGObject` — GDK monitor geometry (installed from system)
128
138
 
129
139
  ## Data Paths
@@ -4,6 +4,23 @@ All notable changes to solstone-linux are documented here.
4
4
  The format is based on Keep a Changelog (https://keepachangelog.com/),
5
5
  and this project adheres to Semantic Versioning.
6
6
 
7
+ ## [0.4.4] - 2026-07-04
8
+
9
+ ### Fixed
10
+ - stopping or restarting sol now shuts down promptly, even with an upload mid-retry. that upload used to wait out its full retry delay first; now it ends the moment you quit sol or the machine powers off. a bad retry setting in your config no longer leaves the uploader stuck, either.
11
+
12
+ ## [0.4.3] - 2026-07-03
13
+
14
+ ### Changed
15
+ - the app now calls itself sol everywhere you see it — the launcher, tray, menus, status, and notifications. your journal is the memory it keeps, and solstone is the platform underneath. the command you run stays `solstone-linux`; nothing about what it does changed, only what it's called.
16
+ - segments your journal rejected or sol couldn't recover are now held for 30 days before they're removed, instead of being dropped silently. `status` and `doctor` show the count, so you can see when any are waiting.
17
+
18
+ ### Fixed
19
+ - local cleanup now deletes a synced segment only after your journal confirms, file by file, that it holds everything in it. previously a segment could be cleaned up while your journal was missing part of it. if your journal can't yet confirm file by file, cleanup holds off and keeps the local copy.
20
+ - sol now recovers on its own from situations that used to leave it quietly stalled or stopped: the first screen-share dialog being dismissed, a journal that's slow to respond or offline, and speakers muted at startup (it goes on without audio and picks it back up when a device is available). an accidental second copy now declines to run rather than disturb the one already going. a round of smaller stability improvements rides along.
21
+ - closing the lid on a docked KDE laptop no longer makes sol go idle.
22
+ - chat notifications now come back on their own after a network drop, and dismissing a notification no longer counts as opening it.
23
+
7
24
  ## [0.4.2] - 2026-06-29
8
25
 
9
26
  ### Added
@@ -52,7 +52,7 @@ this is the developer/from-source path; most installs should use the `pipx insta
52
52
 
53
53
  **arch:**
54
54
  ```
55
- sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx
55
+ sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire gst-plugins-good libpulse alsa-lib xdg-desktop-portal python-pipx uv python-cairo
56
56
  ```
57
57
 
58
58
  **opensuse:**
@@ -88,6 +88,15 @@ this is the developer/from-source path; most installs should use the `pipx insta
88
88
  systemctl --user status solstone-linux
89
89
  ```
90
90
 
91
+ ## updating from PyPI
92
+
93
+ ```
94
+ pipx upgrade solstone-linux
95
+ systemctl --user restart solstone-linux
96
+ ```
97
+
98
+ the systemd unit template and icons only refresh when you re-run `solstone-linux install-service`, so after upgrading across a release that changed the unit, re-run `solstone-linux install-service`.
99
+
91
100
  ## updating after a code change
92
101
 
93
102
  ```
@@ -96,7 +105,7 @@ git pull && make install-service
96
105
 
97
106
  ## notes
98
107
 
99
- - activity detection (idle timeout, screen lock, power save) works on both GNOME and KDE. on other desktops the observer still experiences your screen and audio fine, but activity-based segment boundaries won't trigger.
108
+ - Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger.
100
109
  - the tray icon uses the StatusNotifierItem (SNI) D-Bus protocol. it works on KDE natively and GNOME with the AppIndicator extension. if no SNI host is available, the observer runs normally without a tray icon.
101
110
 
102
111
  ## appendix: GNOME tray support
@@ -156,7 +156,7 @@ versions: .installed
156
156
  $(PYTHON) --version
157
157
  @echo ""
158
158
  @echo "=== Installed packages ==="
159
- @$(UV) pip list | grep -E "^(pytest|ruff|requests|numpy|soundfile|soundcard|dbus-next|PyGObject)" || true
159
+ @$(UV) pip list | grep -E "^(pytest|ruff|requests|numpy|soundfile|soundcard|dbus-fast|PyGObject)" || true
160
160
 
161
161
  release: ## Publish solstone-linux to PyPI (production)
162
162
  @bash scripts/release.sh
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solstone-linux
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Standalone Linux desktop observer for solstone
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.10
8
- Requires-Dist: dbus-next
8
+ Requires-Dist: dbus-fast>=5.0
9
9
  Requires-Dist: numpy
10
10
  Requires-Dist: pygobject
11
11
  Requires-Dist: requests
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  Standalone Linux desktop observer for [solstone](https://solpbc.org). Experiences your screen and audio along with you on a GNOME Wayland session, stores segments locally, and syncs to your solstone journal.
19
19
 
20
- **Note:** Activity detection (idle timeout, screen lock, power save) currently requires a GNOME desktop. On other desktops (KDE, Sway, Hyprland, XFCE), the observer still experiences your screen and audio, but activity-based segment boundaries won't trigger.
20
+ **Note:** Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger.
21
21
 
22
22
  ## System Dependencies
23
23
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Standalone Linux desktop observer for [solstone](https://solpbc.org). Experiences your screen and audio along with you on a GNOME Wayland session, stores segments locally, and syncs to your solstone journal.
4
4
 
5
- **Note:** Activity detection (idle timeout, screen lock, power save) currently requires a GNOME desktop. On other desktops (KDE, Sway, Hyprland, XFCE), the observer still experiences your screen and audio, but activity-based segment boundaries won't trigger.
5
+ **Note:** Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger.
6
6
 
7
7
  ## System Dependencies
8
8
 
@@ -3,16 +3,16 @@
3
3
  <!-- canonical sol app icon (transparent) — UNIFIED WORDMARK direction (locked 2026-06-25, founder-approved).
4
4
  mark: the sol wordmark (sun ring + "sol" Comfortaa Bold) from sol-wordmark.svg, at full extents.
5
5
  ground: NONE (transparent), open center — the background shows through the ring.
6
- color: #B06A1A accessible orange (ring + "sol" glyph) bumped from native #E8923A so it holds on
7
- light surfaces too; clears 3:1 on light, and on dark the #F5C740 gold rays carry brightness
8
- while #B06A1A still reads (~4:1). #F5C740 gold rays.
6
+ color: #E8923A sol orange (ring + "sol" glyph) + #FFCF33 sol gold rays one recipe on every ground.
7
+ the mark is a logotype (WCAG-exempt), so the brand orange holds on light and dark alike
8
+ (palette simplification 2026-06-29, records/decisions/260629-cmo-sol-mark-palette-simplification.md).
9
9
  geometry: identical mark + scale (1.185x, 35.9529) to sol-app-icon-cream.svg; only the ground differs.
10
10
  use: Windows + Linux (and any controlled/dark surface — docs, dark UI, marketing on dark).
11
11
  decision: records/decisions/260625-cmo-sol-app-icon-unified-wordmark.md
12
12
  RENDERING RULE: render from this SVG at every target size; never downsample. -->
13
13
  <g transform="translate(512,512) scale(35.9529) translate(-16,-16)">
14
- <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
15
- <circle cx="16" cy="16" r="8.0" fill="none" stroke="#B06A1A" stroke-width="1.2"/>
16
- <path fill="#B06A1A" fill-rule="evenodd" d="M12.079 18.795C13.489 18.795 14.229 18.065 14.229 17.155C14.229 16.365 13.729 15.835 12.229 15.535C11.149 15.315 10.939 15.095 10.939 14.725C10.939 14.345 11.399 14.135 11.989 14.135C12.499 14.135 12.859 14.235 13.199 14.555C13.399 14.745 13.729 14.815 13.949 14.665C14.159 14.505 14.169 14.255 13.989 14.035C13.589 13.545 12.889 13.245 12.009 13.245C10.989 13.245 9.959 13.735 9.959 14.755C9.959 15.525 10.529 16.075 11.879 16.335C12.919 16.525 13.249 16.815 13.239 17.215C13.229 17.615 12.809 17.895 12.039 17.895C11.429 17.895 10.889 17.625 10.659 17.375C10.469 17.175 10.189 17.125 9.929 17.335C9.699 17.515 9.659 17.825 9.859 18.035C10.299 18.475 11.149 18.795 12.079 18.795Z M16.999 18.795C18.609 18.795 19.749 17.645 19.749 16.025C19.739 14.395 18.599 13.245 16.999 13.245C15.379 13.245 14.239 14.395 14.239 16.025C14.239 17.645 15.379 18.795 16.999 18.795ZM16.999 17.895C15.959 17.895 15.219 17.125 15.219 16.025C15.219 14.925 15.959 14.145 16.999 14.145C18.039 14.145 18.769 14.925 18.769 16.025C18.769 17.125 18.039 17.895 16.999 17.895Z M21.569 18.755H21.589C21.989 18.755 22.269 18.545 22.269 18.255C22.269 17.965 22.079 17.755 21.819 17.755H21.569C21.279 17.755 21.069 17.405 21.069 16.905V11.445C21.069 11.155 20.859 10.945 20.569 10.945C20.279 10.945 20.069 11.155 20.069 11.445V16.905C20.069 17.985 20.689 18.755 21.569 18.755Z"/>
14
+ <path fill="#FFCF33" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
15
+ <circle cx="16" cy="16" r="8.0" fill="none" stroke="#E8923A" stroke-width="1.2"/>
16
+ <path fill="#E8923A" fill-rule="evenodd" d="M12.079 18.795C13.489 18.795 14.229 18.065 14.229 17.155C14.229 16.365 13.729 15.835 12.229 15.535C11.149 15.315 10.939 15.095 10.939 14.725C10.939 14.345 11.399 14.135 11.989 14.135C12.499 14.135 12.859 14.235 13.199 14.555C13.399 14.745 13.729 14.815 13.949 14.665C14.159 14.505 14.169 14.255 13.989 14.035C13.589 13.545 12.889 13.245 12.009 13.245C10.989 13.245 9.959 13.735 9.959 14.755C9.959 15.525 10.529 16.075 11.879 16.335C12.919 16.525 13.249 16.815 13.239 17.215C13.229 17.615 12.809 17.895 12.039 17.895C11.429 17.895 10.889 17.625 10.659 17.375C10.469 17.175 10.189 17.125 9.929 17.335C9.699 17.515 9.659 17.825 9.859 18.035C10.299 18.475 11.149 18.795 12.079 18.795Z M16.999 18.795C18.609 18.795 19.749 17.645 19.749 16.025C19.739 14.395 18.599 13.245 16.999 13.245C15.379 13.245 14.239 14.395 14.239 16.025C14.239 17.645 15.379 18.795 16.999 18.795ZM16.999 17.895C15.959 17.895 15.219 17.125 15.219 16.025C15.219 14.925 15.959 14.145 16.999 14.145C18.039 14.145 18.769 14.925 18.769 16.025C18.769 17.125 18.039 17.895 16.999 17.895Z M21.569 18.755H21.589C21.989 18.755 22.269 18.545 22.269 18.255C22.269 17.965 22.079 17.755 21.819 17.755H21.569C21.279 17.755 21.069 17.405 21.069 16.905V11.445C21.069 11.155 20.859 10.945 20.569 10.945C20.279 10.945 20.069 11.155 20.069 11.445V16.905C20.069 17.985 20.689 18.755 21.569 18.755Z"/>
17
17
  </g>
18
18
  </svg>
@@ -9,7 +9,7 @@
9
9
  </mask>
10
10
  </defs>
11
11
  <g mask="url(#ixko)">
12
- <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
12
+ <path fill="#FFCF33" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
13
13
  <circle cx="16.0" cy="16.0" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/>
14
14
  </g>
15
15
  <path d="M7 7 L25 25 M25 7 L7 25" fill="none" stroke="#E8923A"
@@ -9,7 +9,7 @@
9
9
  </mask>
10
10
  </defs>
11
11
  <g mask="url(#icko)">
12
- <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
12
+ <path fill="#FFCF33" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
13
13
  <circle cx="16.0" cy="16.0" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/>
14
14
  </g>
15
15
  <path d="M 8.50 21.50 A 4.5 4.5 0 0 1 12.02 18.00 A 5.2 5.2 0 0 1 21.35 13.65 A 4.0 4.0 0 0 1 26.91 18.89 C 28 22, 27.5 25.5, 23 25.5 C 20 28.8, 16 28.8, 13 25.5 C 10 26.5, 8 23.5, 8.50 21.50 Z" fill="none" stroke="#999"
@@ -1,7 +1,7 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" role="img" aria-label="solstone icon observing state">
2
2
  <title>solstone — observing</title>
3
3
  <!-- Sun rays: 10 floating wedges with curved inner arc matching the ring -->
4
- <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
4
+ <path fill="#FFCF33" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
5
5
  <!-- Sun ring: open annulus -->
6
6
  <circle cx="16" cy="16" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/>
7
7
  </svg>
@@ -2,11 +2,11 @@
2
2
  role="img" aria-label="solstone icon syncing state">
3
3
  <title>solstone — syncing</title>
4
4
  <!-- Top 5 rays only (no clip path needed) -->
5
- <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z"/>
6
- <path fill="#F5C740" d="M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z"/>
7
- <path fill="#F5C740" d="M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z"/>
8
- <path fill="#F5C740" d="M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z"/>
9
- <path fill="#F5C740" d="M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
5
+ <path fill="#FFCF33" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z"/>
6
+ <path fill="#FFCF33" d="M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z"/>
7
+ <path fill="#FFCF33" d="M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z"/>
8
+ <path fill="#FFCF33" d="M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z"/>
9
+ <path fill="#FFCF33" d="M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/>
10
10
  <!-- Top arc of the ring -->
11
11
  <path fill="none" stroke="#E8923A" stroke-width="1.7"
12
12
  d="M 9.5 16.0 A 6.5 6.5 0 0 1 22.5 16.0"/>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = "Standalone Linux desktop observer for solstone"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "numpy",
11
11
  "soundfile",
12
12
  "soundcard",
13
- "dbus-next",
13
+ "dbus-fast>=5.0",
14
14
  "PyGObject",
15
15
  ]
16
16
 
@@ -75,6 +75,16 @@ SDIST_NAME=$(basename "${SDISTS[0]}")
75
75
  VERSION="${SDIST_NAME#solstone_linux-}"
76
76
  VERSION="${VERSION%.tar.gz}"
77
77
 
78
+ INIT_VERSION=$(grep -E '^__version__ = "[^"]+"' src/solstone_linux/__init__.py | sed 's/^__version__ = "\([^"]*\)".*/\1/' || true)
79
+ if [[ -z "$INIT_VERSION" ]]; then
80
+ echo "error: could not read __version__ from src/solstone_linux/__init__.py" >&2
81
+ exit 1
82
+ fi
83
+ if [[ "$INIT_VERSION" != "$VERSION" ]]; then
84
+ echo "error: version mismatch: sdist version ${VERSION}, src/solstone_linux/__init__.py __version__ ${INIT_VERSION}" >&2
85
+ exit 1
86
+ fi
87
+
78
88
  uvx twine check dist/*
79
89
 
80
90
  # Pre-flight: verify the CHANGELOG block exists before publishing.
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Standalone Linux desktop observer for solstone."""
5
5
 
6
- __version__ = "0.4.2"
6
+ __version__ = "0.4.4"
@@ -16,9 +16,9 @@ import re
16
16
  import shutil
17
17
  import subprocess
18
18
 
19
- from dbus_next import Variant
20
- from dbus_next.aio import MessageBus
21
- from dbus_next.errors import (
19
+ from dbus_fast import Variant
20
+ from dbus_fast.aio import MessageBus
21
+ from dbus_fast.errors import (
22
22
  DBusError,
23
23
  InvalidIntrospectionError,
24
24
  InvalidMemberNameError,
@@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
28
28
 
29
29
  _DBUS_PROBE_TIMEOUT_SEC = 2.0
30
30
  _POWER_SAVE_WARNED_BACKENDS: set[str] = set()
31
+ # One session bus lives for the whole process, so id(bus) is stable and
32
+ # never recycled — a plain dict needs no eviction. Keyed by bus identity
33
+ # because dbus-fast's cython MessageBus cannot be weak-referenced.
34
+ _PROXY_CACHE: dict = {}
31
35
 
32
36
  _SERVICE_MISSING_ERRORS = (
33
37
  "org.freedesktop.DBus.Error.ServiceUnknown",
@@ -62,10 +66,6 @@ DISPLAY_CONFIG_BUS = "org.gnome.Mutter.DisplayConfig"
62
66
  DISPLAY_CONFIG_PATH = "/org/gnome/Mutter/DisplayConfig"
63
67
  DISPLAY_CONFIG_IFACE = "org.gnome.Mutter.DisplayConfig"
64
68
 
65
- KDE_POWER_BUS = "org.kde.Solid.PowerManagement"
66
- KDE_POWER_PATH = "/org/kde/Solid/PowerManagement"
67
- KDE_POWER_IFACE = "org.kde.Solid.PowerManagement"
68
-
69
69
  # DBus service constants — monitor geometry (KDE)
70
70
  KSCREEN_BUS = "org.kde.KScreen"
71
71
  KSCREEN_PATH = "/backend"
@@ -88,6 +88,23 @@ def _is_gnome_desktop() -> bool:
88
88
  )
89
89
 
90
90
 
91
+ async def _cached_interface(bus: MessageBus, service: str, path: str, iface_name: str):
92
+ key = (id(bus), service, path, iface_name)
93
+ if key in _PROXY_CACHE:
94
+ return _PROXY_CACHE[key]
95
+ intro = await bus.introspect(service, path)
96
+ obj = bus.get_proxy_object(service, path, intro)
97
+ iface = obj.get_interface(iface_name)
98
+ _PROXY_CACHE[key] = iface
99
+ return iface
100
+
101
+
102
+ def _invalidate_interface(
103
+ bus: MessageBus, service: str, path: str, iface_name: str
104
+ ) -> None:
105
+ _PROXY_CACHE.pop((id(bus), service, path, iface_name), None)
106
+
107
+
91
108
  async def _name_has_owner(bus: MessageBus, bus_name: str) -> bool:
92
109
  """Ask the bus daemon whether a well-known name is currently owned.
93
110
 
@@ -199,7 +216,6 @@ async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
199
216
  "fdo_screensaver": FDO_SCREENSAVER_BUS,
200
217
  "gnome_screensaver": GNOME_SCREENSAVER_BUS,
201
218
  "gnome_display_config": DISPLAY_CONFIG_BUS,
202
- "kde_power": KDE_POWER_BUS,
203
219
  "kscreen": KSCREEN_BUS,
204
220
  }
205
221
  results = {}
@@ -212,7 +228,7 @@ async def probe_activity_services(bus: MessageBus) -> dict[str, bool]:
212
228
 
213
229
  # Log grouped by function
214
230
  lock_backends = ["fdo_screensaver", "gnome_screensaver"]
215
- power_backends = ["gnome_display_config", "kde_power"]
231
+ power_backends = ["gnome_display_config"]
216
232
  monitor_backends = ["kscreen"]
217
233
 
218
234
  def _status(keys):
@@ -253,9 +269,12 @@ async def is_screen_locked(bus: MessageBus) -> bool:
253
269
  if not _is_gnome_desktop():
254
270
  # Try freedesktop.org ScreenSaver first (KDE kwin and other non-GNOME desktops)
255
271
  try:
256
- intro = await bus.introspect(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH)
257
- obj = bus.get_proxy_object(FDO_SCREENSAVER_BUS, FDO_SCREENSAVER_PATH, intro)
258
- iface = obj.get_interface(FDO_SCREENSAVER_IFACE)
272
+ iface = await _cached_interface(
273
+ bus,
274
+ FDO_SCREENSAVER_BUS,
275
+ FDO_SCREENSAVER_PATH,
276
+ FDO_SCREENSAVER_IFACE,
277
+ )
259
278
  return bool(await iface.call_get_active())
260
279
  except (
261
280
  DBusError,
@@ -263,6 +282,12 @@ async def is_screen_locked(bus: MessageBus) -> bool:
263
282
  InvalidIntrospectionError,
264
283
  OSError,
265
284
  ) as exc:
285
+ _invalidate_interface(
286
+ bus,
287
+ FDO_SCREENSAVER_BUS,
288
+ FDO_SCREENSAVER_PATH,
289
+ FDO_SCREENSAVER_IFACE,
290
+ )
266
291
  if not _is_service_missing(exc):
267
292
  logger.warning(
268
293
  "is_screen_locked FDO backend failed: service=%s path=%s: %s: %s",
@@ -274,9 +299,12 @@ async def is_screen_locked(bus: MessageBus) -> bool:
274
299
 
275
300
  # Fall back to GNOME ScreenSaver
276
301
  try:
277
- intro = await bus.introspect(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH)
278
- obj = bus.get_proxy_object(GNOME_SCREENSAVER_BUS, GNOME_SCREENSAVER_PATH, intro)
279
- iface = obj.get_interface(GNOME_SCREENSAVER_IFACE)
302
+ iface = await _cached_interface(
303
+ bus,
304
+ GNOME_SCREENSAVER_BUS,
305
+ GNOME_SCREENSAVER_PATH,
306
+ GNOME_SCREENSAVER_IFACE,
307
+ )
280
308
  return bool(await iface.call_get_active())
281
309
  except (
282
310
  DBusError,
@@ -284,6 +312,12 @@ async def is_screen_locked(bus: MessageBus) -> bool:
284
312
  InvalidIntrospectionError,
285
313
  OSError,
286
314
  ) as exc:
315
+ _invalidate_interface(
316
+ bus,
317
+ GNOME_SCREENSAVER_BUS,
318
+ GNOME_SCREENSAVER_PATH,
319
+ GNOME_SCREENSAVER_IFACE,
320
+ )
287
321
  if not _is_service_missing(exc):
288
322
  logger.warning(
289
323
  "is_screen_locked GNOME backend failed: service=%s path=%s: %s: %s",
@@ -296,9 +330,10 @@ async def is_screen_locked(bus: MessageBus) -> bool:
296
330
 
297
331
 
298
332
  async def is_power_save_active(bus: MessageBus) -> bool:
299
- """Check display power save via GNOME Mutter, then KDE Solid.
333
+ """Return True when the session reports a power-saving/display-off state.
300
334
 
301
- Returns True if power save is active, False otherwise.
335
+ Checks GNOME Mutter PowerSaveMode, then falls back to X11 DPMS when
336
+ XDG_SESSION_TYPE is x11; degrades to False when no backend is available.
302
337
  """
303
338
 
304
339
  def log_backend_failure_once(backend: str, bus_name: str, path: str, exc) -> None:
@@ -318,9 +353,12 @@ async def is_power_save_active(bus: MessageBus) -> bool:
318
353
 
319
354
  # Try GNOME Mutter DisplayConfig first
320
355
  try:
321
- intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH)
322
- obj = bus.get_proxy_object(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH, intro)
323
- iface = obj.get_interface("org.freedesktop.DBus.Properties")
356
+ iface = await _cached_interface(
357
+ bus,
358
+ DISPLAY_CONFIG_BUS,
359
+ DISPLAY_CONFIG_PATH,
360
+ "org.freedesktop.DBus.Properties",
361
+ )
324
362
  mode_variant = await iface.call_get(DISPLAY_CONFIG_IFACE, "PowerSaveMode")
325
363
  mode = int(mode_variant.value)
326
364
  return mode != 0
@@ -330,6 +368,12 @@ async def is_power_save_active(bus: MessageBus) -> bool:
330
368
  InvalidIntrospectionError,
331
369
  OSError,
332
370
  ) as exc:
371
+ _invalidate_interface(
372
+ bus,
373
+ DISPLAY_CONFIG_BUS,
374
+ DISPLAY_CONFIG_PATH,
375
+ "org.freedesktop.DBus.Properties",
376
+ )
333
377
  if not _is_service_missing(exc):
334
378
  log_backend_failure_once(
335
379
  "Mutter",
@@ -338,21 +382,6 @@ async def is_power_save_active(bus: MessageBus) -> bool:
338
382
  exc,
339
383
  )
340
384
 
341
- # Fall back to KDE Solid PowerManagement
342
- try:
343
- intro = await bus.introspect(KDE_POWER_BUS, KDE_POWER_PATH)
344
- obj = bus.get_proxy_object(KDE_POWER_BUS, KDE_POWER_PATH, intro)
345
- iface = obj.get_interface(KDE_POWER_IFACE)
346
- return bool(await iface.call_is_lid_closed())
347
- except (
348
- DBusError,
349
- InvalidMemberNameError,
350
- InvalidIntrospectionError,
351
- OSError,
352
- ) as exc:
353
- if not _is_service_missing(exc):
354
- log_backend_failure_once("KDE", KDE_POWER_BUS, KDE_POWER_PATH, exc)
355
-
356
385
  # X11-only fallback: DPMS via xset
357
386
  if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
358
387
  return await is_dpms_active()
@@ -407,7 +436,7 @@ def get_monitor_geometries() -> list[dict]:
407
436
 
408
437
 
409
438
  def _unwrap_variants(obj):
410
- """Recursively unwrap dbus-next Variants in nested DBus structures."""
439
+ """Recursively unwrap dbus-fast Variants in nested DBus structures."""
411
440
  if isinstance(obj, Variant):
412
441
  return _unwrap_variants(obj.value)
413
442
  if isinstance(obj, dict):
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Audio device detection.
5
+
6
+ Changes from monorepo version:
7
+ - Uses structural soundcard isloopback metadata instead of amplitude-thresholding
8
+ on a played tone, so muted sinks and silent rooms no longer fail detection.
9
+ """
10
+
11
+ import logging
12
+ import threading
13
+ import time
14
+
15
+ import soundcard as sc
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def input_detect(timeout=3.0):
21
+ try:
22
+ # Fully wedged PulseAudio enumeration is a pre-existing out-of-scope hang.
23
+ devices = sc.all_microphones(include_loopback=True)
24
+ except Exception:
25
+ logger.warning("Failed to enumerate audio devices")
26
+ return None, None
27
+ if not devices:
28
+ logger.warning("No audio devices found")
29
+ return None, None
30
+
31
+ results = {}
32
+ lock = threading.Lock()
33
+
34
+ def classify(index, mic):
35
+ try:
36
+ is_loopback = bool(mic.isloopback)
37
+ except Exception:
38
+ is_loopback = None
39
+ with lock:
40
+ results[index] = is_loopback
41
+
42
+ threads = []
43
+ deadline = time.monotonic() + timeout
44
+ for index, mic in enumerate(devices):
45
+ thread = threading.Thread(target=classify, args=(index, mic), daemon=True)
46
+ thread.start()
47
+ threads.append(thread)
48
+
49
+ for thread in threads:
50
+ remaining = max(0.0, deadline - time.monotonic())
51
+ thread.join(timeout=remaining)
52
+
53
+ with lock:
54
+ final_results = dict(results)
55
+
56
+ mic_detected = None
57
+ loopback_detected = None
58
+ for index, mic in enumerate(devices):
59
+ is_loopback = final_results.get(index)
60
+ if is_loopback is None:
61
+ continue
62
+ if is_loopback and loopback_detected is None:
63
+ loopback_detected = mic
64
+ elif not is_loopback and mic_detected is None:
65
+ mic_detected = mic
66
+ return mic_detected, loopback_detected