solstone-linux 0.1.0__tar.gz → 0.2.0__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 (68) hide show
  1. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/AGENTS.md +0 -3
  2. solstone_linux-0.2.0/CHANGELOG.md +84 -0
  3. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/INSTALL.md +11 -5
  4. solstone_linux-0.2.0/PKG-INFO +90 -0
  5. solstone_linux-0.2.0/README.md +75 -0
  6. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/pyproject.toml +1 -1
  7. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/scripts/release.sh +7 -0
  8. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/__init__.py +1 -1
  9. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/activity.py +20 -10
  10. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/cli.py +45 -72
  11. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/observer.py +32 -6
  12. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/screencast.py +51 -3
  13. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/sync.py +1 -4
  14. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/tray.py +20 -12
  15. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/upload.py +44 -53
  16. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_activity.py +35 -0
  17. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_cli.py +177 -0
  18. solstone_linux-0.2.0/tests/test_observer_emits_stream_silent_event.py +60 -0
  19. solstone_linux-0.2.0/tests/test_screencast_stop_filters_silent_streams.py +123 -0
  20. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_tray.py +21 -0
  21. solstone_linux-0.2.0/tests/test_upload.py +119 -0
  22. solstone_linux-0.1.0/CHANGELOG.md +0 -27
  23. solstone_linux-0.1.0/PKG-INFO +0 -73
  24. solstone_linux-0.1.0/README.md +0 -58
  25. solstone_linux-0.1.0/contrib/icons/hicolor/index.theme +0 -12
  26. solstone_linux-0.1.0/src/solstone_linux/icons/hicolor/index.theme +0 -12
  27. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/.gitignore +0 -0
  28. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/CLAUDE.md +0 -0
  29. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/LICENSE +0 -0
  30. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/Makefile +0 -0
  31. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  32. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  33. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  34. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  35. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/scripts/extract_changelog.sh +0 -0
  36. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/audio_detect.py +0 -0
  37. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/audio_mute.py +0 -0
  38. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/audio_recorder.py +0 -0
  39. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/chat_bridge.py +0 -0
  40. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/config.py +0 -0
  41. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/dbus_service.py +0 -0
  42. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/dbusmenu.py +0 -0
  43. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/doctor.py +0 -0
  44. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  45. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  46. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  47. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  48. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/install_guard.py +0 -0
  49. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/monitor_positions.py +0 -0
  50. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/recovery.py +0 -0
  51. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/session_env.py +0 -0
  52. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/sni.py +0 -0
  53. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/solstone-linux.service.in +0 -0
  54. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/src/solstone_linux/streams.py +0 -0
  55. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/__init__.py +0 -0
  56. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_chat_bridge.py +0 -0
  57. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_config.py +0 -0
  58. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_dbus_service.py +0 -0
  59. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_dbusmenu.py +0 -0
  60. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_doctor.py +0 -0
  61. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_extract_changelog.py +0 -0
  62. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_install_guard.py +0 -0
  63. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_monitor_positions.py +0 -0
  64. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_observer.py +0 -0
  65. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_screencast.py +0 -0
  66. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_session_env.py +0 -0
  67. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_streams.py +0 -0
  68. {solstone_linux-0.1.0 → solstone_linux-0.2.0}/tests/test_sync.py +0 -0
@@ -146,13 +146,10 @@ Tests use pytest with standard mocking. No system dependencies required for test
146
146
  ## Brand canon
147
147
 
148
148
  - **solstone-linux is an observer.** Owner-facing canon describes solstone as observers + journal; sol is the keeper who lives in and tends your journal. In engineering architecture, `observers + sol agent + journal` is the running software this repo's code talks to. This repo implements one of those observers.
149
- - **The canon lives elsewhere.** Owner-facing terminology comes from sol pbc's internal brand canon (system anatomy + voice terminology guides). This repo's branded prose follows it; the canon itself is not vendored here.
150
149
  - **Use co-experience language in branded prose.** In README, INSTALL, onboarding text, settings copy, and error messages, describe solstone-linux as something that experiences screen and audio along with the owner. Never describe it as watching, recording, monitoring, or tracking the owner.
151
150
  - **Keep code language in code-only contexts.** Internal architecture terms such as `Capture loop`, the capture pipeline, module names, and data-path names are canon-permitted here and must not be renamed just to match branded prose.
152
151
  - **Edit with the surface in mind.** If the owner sees the string, follow the canon. If the text is naming code, pipelines, modules, or storage artifacts for engineers, the existing internal vocabulary stays.
153
152
 
154
- Canon source of truth: sol pbc's internal brand canon (system-anatomy guide).
155
-
156
153
  ## License
157
154
 
158
155
  AGPL-3.0-only -- Copyright (c) 2026 sol pbc
@@ -0,0 +1,84 @@
1
+ # Changelog
2
+
3
+ All notable changes to solstone-linux are documented here.
4
+ The format is based on Keep a Changelog (https://keepachangelog.com/),
5
+ and this project adheres to Semantic Versioning.
6
+
7
+ ## [0.2.0] - 2026-06-13
8
+
9
+ setup is now hands-off: the first time the observer runs, it connects itself
10
+ to your journal automatically, with no separate key step.
11
+
12
+ ### Changed
13
+
14
+ - **first run sets itself up.** earlier versions asked you to create and paste
15
+ a key to connect the observer to your journal. now the observer introduces
16
+ itself to your journal on first run and remembers the connection on its own.
17
+ you go straight from install to observing, with no manual key step.
18
+
19
+ ## [0.1.1] - 2026-06-02
20
+
21
+ A focused maintenance release: two reliability fixes and a round of
22
+ install-instruction corrections from fresh-machine testing on Fedora,
23
+ Debian, and openSUSE.
24
+
25
+ ### Fixed
26
+
27
+ - **Idle monitors no longer silently drop observations.** When a monitor
28
+ produced no frames during a segment (a static screen with nothing
29
+ changing on it), GStreamer still wrote a header-only WebM file. Those
30
+ empty files were finalized, uploaded, and then failed to process in your
31
+ journal — so that monitor's segment was lost without any signal. The
32
+ observer now drops these empty recordings at the source and emits an
33
+ `observe.stream_silent` event (logged at WARNING) so the gap is visible
34
+ instead of silent.
35
+ - **Install no longer clobbers your system icon theme.** On GNOME,
36
+ `install-service` was writing a stray `index.theme` into the shared
37
+ hicolor icon directory, which shadowed the system index and caused
38
+ unrelated app icons to render as the solstone diamond. The installer now
39
+ drops only the solstone status icons (the system index already declares
40
+ their directory) and self-heals any previously broken install on the next
41
+ `install-service` run. A foreign or unreadable `index.theme` is left
42
+ untouched.
43
+
44
+ ### Documentation
45
+
46
+ - Corrected the Fedora and Debian system-dependency lines after fresh-box
47
+ install testing: dropped packages that do not exist in their repos
48
+ (`gstreamer1-plugin-pipewire` on Fedora, `gir1.2-gdk-4.0` on Debian) and
49
+ hoisted the cairo / pycairo build toolchain onto the main install line so
50
+ a fresh install succeeds in one shot. Added `gstreamer1.0-tools` to the
51
+ Debian line — `gst-launch-1.0` is required for screen recording and is
52
+ not pulled in transitively.
53
+ - Added a verified openSUSE dependency block and mirrored the corrected
54
+ dependency lists between `README.md` and `INSTALL.md`.
55
+ - Updated the install path to lead with `pipx install solstone-linux`, then
56
+ `solstone-linux install-service`, then `solstone-linux setup`.
57
+
58
+ ### Internal
59
+
60
+ - The release script now tags the commit and cuts a GitHub release only on
61
+ a production PyPI run; a TestPyPI run no longer leaves a tag or public
62
+ release behind.
63
+
64
+ ## [0.1.0] - 2026-05-19
65
+
66
+ First public release of solstone-linux — the Linux desktop observer
67
+ for your solstone journal.
68
+
69
+ solstone-linux runs as a systemd user service in your GNOME Wayland
70
+ session. It experiences screen and audio along with you, holds short
71
+ segments locally, and uploads them to your journal in the background.
72
+
73
+ ### Install paths
74
+
75
+ - From PyPI: `pipx install --system-site-packages solstone-linux`,
76
+ then `solstone-linux install-service` to register the systemd unit.
77
+ - From a clone: `git clone` this repo and run `make install-service`
78
+ for development or unreleased changes.
79
+
80
+ Both paths rely on host packages for PyGObject, GStreamer with the
81
+ PipeWire plugin, PipeWire itself, `pactl`, and `xdg-desktop-portal`
82
+ with ScreenCast support. PyGObject and the GStreamer bindings ride
83
+ along from system site-packages — that is why `--system-site-packages`
84
+ matters.
@@ -4,7 +4,7 @@ these instructions are for a coding agent and human working together. solstone-l
4
4
 
5
5
  solstone must already be installed and running. if it isn't, start there: https://solstone.app/install
6
6
 
7
- > **most users should run `sol observer install` from the solstone host instead of following this file by hand.** that one command handles the clone, the system-package preflight, the build, the registration, and the systemd unit including a `--dry-run` mode to preview every step. the instructions below are for developers building from source or troubleshooting the install.
7
+ > **most users install solstone-linux from PyPI in three commands** on the machine that will host the observer: `pipx install solstone-linux`, `solstone-linux install-service`, then `solstone-linux setup` (which prompts for your journal URL and auto-registers). if the observer machine can't reach your solstone host, mint a key from there first with `sol observer create <name>` and paste it during setup. the instructions below are for developers building from source or troubleshooting the install.
8
8
 
9
9
  ## before you begin
10
10
 
@@ -34,16 +34,18 @@ The observer connects to your solstone journal over HTTPS — colocation is opti
34
34
 
35
35
  ## install sequence
36
36
 
37
+ this is the developer/from-source path; most installs should use the `pipx install solstone-linux` + `solstone-linux install-service` + `solstone-linux setup` flow described in the callout above.
38
+
37
39
  1. install system dependencies for your distro, including `pipx`. if you need sudo, walk your human through it.
38
40
 
39
41
  **fedora:**
40
42
  ```
41
- sudo dnf install python3-gobject gtk4 gstreamer1-plugins-base gstreamer1-plugin-pipewire pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx
43
+ sudo dnf install python3-gobject python3-cairo gtk4 gstreamer1-plugins-base pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx gcc python3-devel pkgconf-pkg-config cairo-devel cairo-gobject-devel
42
44
  ```
43
45
 
44
46
  **debian / ubuntu:**
45
47
  ```
46
- sudo apt install python3-gi gir1.2-gdk-4.0 gir1.2-gtk-4.0 gstreamer1.0-pipewire libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx
48
+ sudo apt install python3-gi python3-cairo gir1.2-gtk-4.0 gstreamer1.0-pipewire gstreamer1.0-tools libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx gcc python3-dev pkg-config libcairo2-dev
47
49
  ```
48
50
 
49
51
  **arch:**
@@ -60,7 +62,11 @@ The observer connects to your solstone journal over HTTPS — colocation is opti
60
62
  ```
61
63
  note: package names diverge from Fedora — `typelib-1_0-Gtk-4_0` (not `gtk4`), `gstreamer-plugin-pipewire` (singular), and `alsa-devel` (not `alsa-lib-devel`).
62
64
 
63
- 2. If you have local sol, cloning into `$(sol root)/observers` keeps observers colocated with your solstone journal. For remote-sol setups, clone anywhere the observer runs independently of your journal at runtime:
65
+ the `cairo` headers + `gcc` + Python dev headers in the Fedora/Debian lines above are there because `pycairo` builds from source during install; without them the install fails with a compile error before you'd reach the Troubleshooting section. they're the same fixes listed under Troubleshooting, hoisted up so a fresh install works in one shot.
66
+
67
+ `uv` / `pipx`: Fedora packages both (`sudo dnf install uv pipx`); Debian/Ubuntu package `pipx` but not `uv`. the PyPI install flow only needs `pipx` — `uv` is optional and used by the from-source dev workflow in the Makefile.
68
+
69
+ 2. cloning into `$(sol root)/observers` is only a developer convenience for keeping observer checkouts colocated with a local solstone clone. for remote-sol setups, clone anywhere — the observer runs independently of your journal at runtime:
64
70
  ```
65
71
  cd "$(sol root)/observers"
66
72
  git clone https://github.com/solpbc/solstone-linux.git
@@ -73,7 +79,7 @@ The observer connects to your solstone journal over HTTPS — colocation is opti
73
79
  ```
74
80
  solstone-linux setup
75
81
  ```
76
- this prompts for the journal URL and auto-registers via `sol` when available.
82
+ this prompts for the journal URL and registers the observer with your journal.
77
83
 
78
84
  4. verify the service is running:
79
85
  ```
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: solstone-linux
3
+ Version: 0.2.0
4
+ Summary: Standalone Linux desktop observer for solstone
5
+ License-Expression: AGPL-3.0-only
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: dbus-next
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pygobject
11
+ Requires-Dist: requests
12
+ Requires-Dist: soundcard
13
+ Requires-Dist: soundfile
14
+ Description-Content-Type: text/markdown
15
+
16
+ # solstone-linux
17
+
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
+
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.
21
+
22
+ ## System Dependencies
23
+
24
+ **Fedora:**
25
+ ```
26
+ sudo dnf install python3-gobject python3-cairo gtk4 gstreamer1-plugins-base pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx gcc python3-devel pkgconf-pkg-config cairo-devel cairo-gobject-devel
27
+ ```
28
+
29
+ **Debian / Ubuntu:**
30
+ ```
31
+ sudo apt install python3-gi python3-cairo gir1.2-gtk-4.0 gstreamer1.0-pipewire gstreamer1.0-tools libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx gcc python3-dev pkg-config libcairo2-dev
32
+ ```
33
+
34
+ **Arch:**
35
+ ```
36
+ sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx
37
+ ```
38
+
39
+ **openSUSE:**
40
+ ```
41
+ sudo zypper install python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-4_0 gtk4-tools gstreamer-plugins-base gstreamer-plugin-pipewire pipewire-pulseaudio pulseaudio-utils alsa-devel xdg-desktop-portal python3-pipx
42
+ ```
43
+
44
+ ## Install
45
+
46
+ solstone (the journal) must already be installed and running on the host this observer reports to. If it isn't, start with the [journal install](https://solstone.app/install).
47
+
48
+ On the machine that will host the observer:
49
+
50
+ ```bash
51
+ pipx install solstone-linux
52
+ solstone-linux install-service
53
+ solstone-linux setup
54
+ ```
55
+
56
+ `setup` prompts for your journal URL and registers the observer for you. If this machine can't reach your solstone host directly, mint a key from there with `sol observer create <name>` and paste it during setup.
57
+
58
+ ### Developers building from source
59
+
60
+ ```bash
61
+ git clone https://github.com/solpbc/solstone-linux.git
62
+ cd solstone-linux
63
+ make install-service
64
+ solstone-linux setup
65
+ ```
66
+
67
+ See `INSTALL.md` for distro packages, tray notes, and troubleshooting details.
68
+
69
+ ## Setup
70
+
71
+ ```bash
72
+ solstone-linux setup
73
+ ```
74
+
75
+ ## Run
76
+
77
+ ```bash
78
+ # Foreground
79
+ solstone-linux run
80
+ ```
81
+
82
+ ## Status
83
+
84
+ ```bash
85
+ solstone-linux status
86
+ ```
87
+
88
+ ## License
89
+
90
+ AGPL-3.0-only — Copyright (c) 2026 sol pbc
@@ -0,0 +1,75 @@
1
+ # solstone-linux
2
+
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
+
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.
6
+
7
+ ## System Dependencies
8
+
9
+ **Fedora:**
10
+ ```
11
+ sudo dnf install python3-gobject python3-cairo gtk4 gstreamer1-plugins-base pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx gcc python3-devel pkgconf-pkg-config cairo-devel cairo-gobject-devel
12
+ ```
13
+
14
+ **Debian / Ubuntu:**
15
+ ```
16
+ sudo apt install python3-gi python3-cairo gir1.2-gtk-4.0 gstreamer1.0-pipewire gstreamer1.0-tools libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx gcc python3-dev pkg-config libcairo2-dev
17
+ ```
18
+
19
+ **Arch:**
20
+ ```
21
+ sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx
22
+ ```
23
+
24
+ **openSUSE:**
25
+ ```
26
+ sudo zypper install python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-4_0 gtk4-tools gstreamer-plugins-base gstreamer-plugin-pipewire pipewire-pulseaudio pulseaudio-utils alsa-devel xdg-desktop-portal python3-pipx
27
+ ```
28
+
29
+ ## Install
30
+
31
+ solstone (the journal) must already be installed and running on the host this observer reports to. If it isn't, start with the [journal install](https://solstone.app/install).
32
+
33
+ On the machine that will host the observer:
34
+
35
+ ```bash
36
+ pipx install solstone-linux
37
+ solstone-linux install-service
38
+ solstone-linux setup
39
+ ```
40
+
41
+ `setup` prompts for your journal URL and registers the observer for you. If this machine can't reach your solstone host directly, mint a key from there with `sol observer create <name>` and paste it during setup.
42
+
43
+ ### Developers building from source
44
+
45
+ ```bash
46
+ git clone https://github.com/solpbc/solstone-linux.git
47
+ cd solstone-linux
48
+ make install-service
49
+ solstone-linux setup
50
+ ```
51
+
52
+ See `INSTALL.md` for distro packages, tray notes, and troubleshooting details.
53
+
54
+ ## Setup
55
+
56
+ ```bash
57
+ solstone-linux setup
58
+ ```
59
+
60
+ ## Run
61
+
62
+ ```bash
63
+ # Foreground
64
+ solstone-linux run
65
+ ```
66
+
67
+ ## Status
68
+
69
+ ```bash
70
+ solstone-linux status
71
+ ```
72
+
73
+ ## License
74
+
75
+ AGPL-3.0-only — Copyright (c) 2026 sol pbc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Standalone Linux desktop observer for solstone"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -89,6 +89,13 @@ fi
89
89
  TWINE_USERNAME=__token__ TWINE_PASSWORD="$TOKEN" \
90
90
  "${RUN[@]}" uvx twine upload "${REPOSITORY_ARGS[@]}" dist/*
91
91
 
92
+ # Tag + GitHub release only for production. A TestPyPI dry-run should not leave
93
+ # a git tag or a public release behind.
94
+ if [[ "$TARGET" != "PyPI" ]]; then
95
+ echo "skipping git tag + GitHub release (TestPyPI run)"
96
+ exit 0
97
+ fi
98
+
92
99
  TAG="v${VERSION}"
93
100
  "${RUN[@]}" git tag -a "$TAG" -m "solstone-linux ${VERSION}"
94
101
  if ! "${RUN[@]}" git push origin "$TAG"; then
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Standalone Linux desktop observer for solstone."""
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.0"
@@ -24,6 +24,7 @@ from dbus_next.errors import (
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
26
  _DBUS_PROBE_TIMEOUT_SEC = 2.0
27
+ _POWER_SAVE_WARNED_BACKENDS: set[str] = set()
27
28
 
28
29
  _SERVICE_MISSING_ERRORS = (
29
30
  "org.freedesktop.DBus.Error.ServiceUnknown",
@@ -212,6 +213,22 @@ async def is_power_save_active(bus: MessageBus) -> bool:
212
213
 
213
214
  Returns True if power save is active, False otherwise.
214
215
  """
216
+
217
+ def log_backend_failure_once(backend: str, bus_name: str, path: str, exc) -> None:
218
+ level = logger.warning
219
+ if backend in _POWER_SAVE_WARNED_BACKENDS:
220
+ level = logger.debug
221
+ else:
222
+ _POWER_SAVE_WARNED_BACKENDS.add(backend)
223
+ level(
224
+ "is_power_save_active %s backend failed: service=%s path=%s: %s: %s",
225
+ backend,
226
+ bus_name,
227
+ path,
228
+ type(exc).__name__,
229
+ exc,
230
+ )
231
+
215
232
  # Try GNOME Mutter DisplayConfig first
216
233
  try:
217
234
  intro = await bus.introspect(DISPLAY_CONFIG_BUS, DISPLAY_CONFIG_PATH)
@@ -227,11 +244,10 @@ async def is_power_save_active(bus: MessageBus) -> bool:
227
244
  OSError,
228
245
  ) as exc:
229
246
  if not _is_service_missing(exc):
230
- logger.warning(
231
- "is_power_save_active Mutter backend failed: service=%s path=%s: %s: %s",
247
+ log_backend_failure_once(
248
+ "Mutter",
232
249
  DISPLAY_CONFIG_BUS,
233
250
  DISPLAY_CONFIG_PATH,
234
- type(exc).__name__,
235
251
  exc,
236
252
  )
237
253
 
@@ -248,13 +264,7 @@ async def is_power_save_active(bus: MessageBus) -> bool:
248
264
  OSError,
249
265
  ) as exc:
250
266
  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
- )
267
+ log_backend_failure_once("KDE", KDE_POWER_BUS, KDE_POWER_PATH, exc)
258
268
  return False
259
269
 
260
270
 
@@ -134,44 +134,23 @@ def cmd_setup(args: argparse.Namespace) -> int:
134
134
  )
135
135
  return 0
136
136
 
137
- print(f"Stream: {config.stream}")
138
137
  save_config(config)
139
138
 
140
139
  if not config.key:
141
- sol = shutil.which("sol")
142
- if sol:
143
- print("Registering via sol CLI...")
144
- try:
145
- result = subprocess.run(
146
- [sol, "observer", "--json", "create", config.stream],
147
- capture_output=True,
148
- text=True,
149
- timeout=10,
150
- )
151
- if result.returncode == 0:
152
- data = json.loads(result.stdout)
153
- config.key = data["key"]
154
- save_config(config)
155
- print(f"Registered (key: {config.key[:8]}...)")
156
- else:
157
- print("CLI registration failed, trying HTTP...")
158
- except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, OSError):
159
- print("CLI registration failed, trying HTTP...")
160
-
161
- if not config.key:
162
- print("Registering with your journal...")
163
- client = UploadClient(config)
164
- if client.ensure_registered(config):
165
- config = load_config()
166
- print(f"Registered (key: {config.key[:8]}...)")
167
- else:
168
- print(
169
- "Warning: registration failed. Run setup again when your journal is available."
170
- )
171
- if non_interactive:
172
- return 1
140
+ print("Registering with your journal...")
141
+ client = UploadClient(config)
142
+ if client.ensure_registered(config):
143
+ print(f"Registered (key: {config.key[:8]}...)")
144
+ print(f"Stream: {config.stream}")
145
+ else:
146
+ print(
147
+ "Warning: registration failed. Run setup again when your journal is available."
148
+ )
149
+ if non_interactive:
150
+ return 1
173
151
  else:
174
152
  print(f"Already registered (key: {config.key[:8]}...)")
153
+ print(f"Stream: {config.stream}")
175
154
 
176
155
  print(f"\nConfig saved to {config.config_path}")
177
156
  print(f"Captures will go to {config.captures_dir}")
@@ -203,46 +182,24 @@ def _cmd_setup_interactive() -> int:
203
182
  except ValueError as e:
204
183
  print(f"Error deriving stream name: {e}", file=sys.stderr)
205
184
  return 1
206
- print(f"Stream: {config.stream}")
207
185
 
208
186
  # Save config before registration (so URL is persisted)
209
187
  config.ensure_dirs()
210
188
  save_config(config)
211
189
 
212
- # Auto-register — try sol CLI first (no server needed), fall back to HTTP
213
190
  if not config.key:
214
- sol = shutil.which("sol")
215
- if sol:
216
- print("Registering via sol CLI...")
217
- try:
218
- result = subprocess.run(
219
- [sol, "observer", "--json", "create", config.stream],
220
- capture_output=True,
221
- text=True,
222
- timeout=10,
223
- )
224
- if result.returncode == 0:
225
- data = json.loads(result.stdout)
226
- config.key = data["key"]
227
- save_config(config)
228
- print(f"Registered (key: {config.key[:8]}...)")
229
- else:
230
- print("CLI registration failed, trying HTTP...")
231
- except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, OSError):
232
- print("CLI registration failed, trying HTTP...")
233
-
234
- if not config.key:
235
- print("Registering with your journal...")
236
- client = UploadClient(config)
237
- if client.ensure_registered(config):
238
- config = load_config()
239
- print(f"Registered (key: {config.key[:8]}...)")
240
- else:
241
- print(
242
- "Warning: registration failed. Run setup again when your journal is available."
243
- )
191
+ print("Registering with your journal...")
192
+ client = UploadClient(config)
193
+ if client.ensure_registered(config):
194
+ print(f"Registered (key: {config.key[:8]}...)")
195
+ print(f"Stream: {config.stream}")
196
+ else:
197
+ print(
198
+ "Warning: registration failed. Run setup again when your journal is available."
199
+ )
244
200
  else:
245
201
  print(f"Already registered (key: {config.key[:8]}...)")
202
+ print(f"Stream: {config.stream}")
246
203
 
247
204
  print(f"\nConfig saved to {config.config_path}")
248
205
  print(f"Captures will go to {config.captures_dir}")
@@ -321,15 +278,31 @@ def cmd_install_service(args: argparse.Namespace) -> int:
321
278
  shutil.copy2(svg, status_dir / svg.name)
322
279
  print(f"Installed {status_dir / svg.name}")
323
280
 
324
- # Copy index.theme only if one doesn't already exist
325
- index_dest = icon_dest / "index.theme"
326
- if not index_dest.exists():
327
- shutil.copy2(icon_source / "index.theme", index_dest)
328
- print(f"Wrote {index_dest}")
281
+ # Self-heal: earlier installs copied a solstone index.theme into this
282
+ # shared hicolor dir. Because the user icon dir out-ranks
283
+ # /usr/share/icons, that file shadowed the system hicolor index (which
284
+ # declares ~649 dirs) with one that declared only scalable/status, so
285
+ # every unrelated app-icon lookup fell back to hicolor, missed, and
286
+ # rendered as our diamond. Remove only our own file — matched on the
287
+ # exact "Name=solstone" line — and never touch a foreign index.theme.
288
+ legacy_index = icon_dest / "index.theme"
289
+ if legacy_index.exists():
290
+ try:
291
+ content = legacy_index.read_text()
292
+ except (OSError, UnicodeDecodeError):
293
+ print(f"Left existing icon theme index in place: {legacy_index}")
294
+ else:
295
+ if "Name=solstone" in content.splitlines():
296
+ legacy_index.unlink()
297
+ print(f"Removed stale solstone icon theme index: {legacy_index}")
329
298
 
330
- # Update icon cache (non-fatal)
299
+ # Refresh the icon cache (non-fatal). --ignore-theme-index keeps it
300
+ # quiet now that this dir ships no index.theme of its own.
331
301
  try:
332
- subprocess.run(["gtk-update-icon-cache", str(icon_dest)], check=False)
302
+ subprocess.run(
303
+ ["gtk-update-icon-cache", "--ignore-theme-index", str(icon_dest)],
304
+ check=False,
305
+ )
333
306
  except FileNotFoundError:
334
307
  pass
335
308
 
@@ -42,7 +42,7 @@ from .audio_recorder import AudioRecorder
42
42
  from .chat_bridge import run_chat_bridge
43
43
  from .config import Config
44
44
  from .recovery import write_segment_metadata
45
- from .screencast import Screencaster, StreamInfo
45
+ from .screencast import Screencaster, SilentStream, StreamInfo
46
46
  from .sync import SyncService
47
47
  from .upload import UploadClient
48
48
 
@@ -162,6 +162,7 @@ class Observer:
162
162
  self._client = UploadClient(self.config)
163
163
  if self.config.server_url:
164
164
  self._client.ensure_registered(self.config)
165
+ self.stream = self.config.stream
165
166
  self._sync = SyncService(self.config, self._client)
166
167
 
167
168
  from .dbus_service import BUS_NAME, OBJECT_PATH, ObserverService
@@ -317,7 +318,9 @@ class Observer:
317
318
  # Stop screencast first (closes file handles)
318
319
  if self.current_mode == MODE_SCREENCAST:
319
320
  logger.info("Stopping previous screencast")
320
- await self.screencaster.stop()
321
+ healthy, silent = await self.screencaster.stop()
322
+ for s in silent:
323
+ self._emit_stream_silent(s)
321
324
  self.current_streams = []
322
325
 
323
326
  # Save audio if we have enough threshold hits
@@ -389,6 +392,24 @@ class Observer:
389
392
 
390
393
  return True
391
394
 
395
+ def _emit_stream_silent(self, silent: SilentStream) -> None:
396
+ if self._client is None:
397
+ return
398
+ segment_dir_basename = self.segment_dir.name if self.segment_dir else ""
399
+ duration_seconds = int(time.time() - self.start_at) if self.start_at else 0
400
+ self._client.relay_event(
401
+ "observe",
402
+ "stream_silent",
403
+ connector=silent.connector,
404
+ position=silent.position,
405
+ node_id=silent.node_id,
406
+ file_bytes=silent.file_bytes,
407
+ segment_dir=segment_dir_basename,
408
+ duration_seconds=duration_seconds,
409
+ host=HOST,
410
+ platform=PLATFORM,
411
+ )
412
+
392
413
  def emit_status(self):
393
414
  """Emit observe.status event with current state (fire-and-forget)."""
394
415
  if not self._client:
@@ -437,7 +458,6 @@ class Observer:
437
458
  activity=activity_info,
438
459
  host=HOST,
439
460
  platform=PLATFORM,
440
- stream=self.stream,
441
461
  )
442
462
 
443
463
  def _refresh_tray(self):
@@ -551,7 +571,9 @@ class Observer:
551
571
  if self._paused:
552
572
  if self.segment_dir:
553
573
  if self.current_mode == MODE_SCREENCAST:
554
- await self.screencaster.stop()
574
+ healthy, silent = await self.screencaster.stop()
575
+ for s in silent:
576
+ self._emit_stream_silent(s)
555
577
  self.current_streams = []
556
578
  if self.threshold_hits >= MIN_HITS_FOR_SAVE:
557
579
  self._save_audio_segment(
@@ -603,7 +625,9 @@ class Observer:
603
625
  and not self.screencaster.is_healthy()
604
626
  ):
605
627
  logger.warning("Screencast recording failed, stopping gracefully")
606
- await self.screencaster.stop()
628
+ healthy, silent = await self.screencaster.stop()
629
+ for s in silent:
630
+ self._emit_stream_silent(s)
607
631
  self.current_streams = []
608
632
  self.current_mode = MODE_IDLE
609
633
 
@@ -692,7 +716,9 @@ class Observer:
692
716
  # Stop screencast first (closes file handles)
693
717
  if self.current_mode == MODE_SCREENCAST:
694
718
  logger.info("Stopping screencast for shutdown")
695
- await self.screencaster.stop()
719
+ healthy, silent = await self.screencaster.stop()
720
+ for s in silent:
721
+ self._emit_stream_silent(s)
696
722
  await asyncio.sleep(0.5)
697
723
 
698
724
  # Save final audio if threshold met