linux observer
0
fork

Configure Feed

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

Add deploy/upgrade targets and template systemd unit

Add a packaged unit template and render it from install-service with
force/no-op behavior plus refreshed CLI coverage.
Add deploy/upgrade Make targets and collapse the install docs onto make deploy.

+181 -110
+8 -1
AGENTS.md
··· 14 14 src/solstone_linux/ 15 15 __init__.py Package version 16 16 cli.py CLI entry point (run, setup, install-service, status) 17 + solstone-linux.service.in Systemd unit template (rendered by install-service) 17 18 config.py Config loading/persistence (~/.local/share/solstone-linux/) 18 19 observer.py Main capture loop — state machine (idle/screencast), audio + video 19 20 screencast.py Portal-based multi-monitor recording (xdg-desktop-portal + GStreamer) ··· 29 30 recovery.py Crash recovery for orphaned .incomplete segments 30 31 31 32 tests/ pytest test suite 32 - contrib/ Reference systemd service unit 33 + contrib/ Reference icons for development fallback 33 34 ``` 34 35 35 36 ## Architecture ··· 52 53 make test-only TEST=tests/test_config.py # Run specific test 53 54 make format # Auto-format with ruff 54 55 make ci # Lint + format check + tests 56 + make deploy # pipx install + install-service (first-time deploy on this machine) 57 + make upgrade # Run CI, then pipx reinstall + restart service 58 + make service-restart # systemctl restart wrapper 59 + make service-status # systemctl status wrapper 60 + make service-logs # systemctl log tail wrapper 61 + make uninstall-service # Disable + remove unit + pipx uninstall 55 62 make clean # Remove build artifacts and caches 56 63 make versions # Show installed package versions 57 64 ```
+33 -58
INSTALL.md
··· 24 24 25 25 ## install sequence 26 26 27 - 1. install system dependencies for your distro. if you need sudo, walk your human through it. 27 + 1. install system dependencies for your distro, including `pipx`. if you need sudo, walk your human through it. 28 28 29 29 **fedora:** 30 30 ``` 31 - sudo dnf install python3-gobject gtk4 gstreamer1-plugins-base gstreamer1-plugin-pipewire pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio 31 + 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 32 32 ``` 33 33 34 34 **debian / ubuntu:** 35 35 ``` 36 - sudo apt install python3-gi gir1.2-gdk-4.0 gir1.2-gtk-4.0 gstreamer1.0-pipewire libasound2-dev pulseaudio-utils pipewire-pulse 36 + 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 37 37 ``` 38 38 39 39 **arch:** 40 40 ``` 41 - sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib 41 + sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx 42 42 ``` 43 43 44 - 2. if not already cloned, clone into solstone's observers directory and install with pipx: 44 + 2. if not already cloned, clone into solstone's observers directory and deploy: 45 45 ``` 46 46 cd "$(sol root)/observers" 47 47 git clone https://github.com/solpbc/solstone-linux.git 48 48 cd solstone-linux 49 + make deploy 49 50 ``` 50 - ``` 51 - pipx install --system-site-packages . 52 - ``` 53 - `--system-site-packages` is required — the observer imports PyGObject and GStreamer bindings that only exist in system site-packages. 51 + `make deploy` installs with pipx using `--system-site-packages`, then installs and starts the user service. 54 52 55 - 3. register the observer with solstone and save the API key: 53 + 3. run the interactive setup: 56 54 ``` 57 - sol remote create solstone-linux 55 + solstone-linux setup 58 56 ``` 57 + this prompts for the server URL and auto-registers via `sol` when available. 59 58 60 - 4. write the config to `~/.local/share/solstone-linux/config/config.json`: 61 - ```json 62 - { 63 - "server_url": "http://localhost:5015", 64 - "key": "THE_API_KEY_FROM_STEP_3", 65 - "stream": "HOSTNAME" 66 - } 59 + 4. verify the service is running: 67 60 ``` 68 - 69 - **optional: cache retention.** by default, synced segments are deleted after 7 days. to change this, add `cache_retention_days` to config.json: 70 - - positive number: keep synced segments for that many days (default: `7`) 71 - - `0`: delete immediately after confirmed sync 72 - - `-1`: keep forever (never auto-delete) 73 - 74 - ```json 75 - { 76 - "server_url": "http://localhost:5015", 77 - "key": "THE_API_KEY_FROM_STEP_3", 78 - "stream": "HOSTNAME", 79 - "cache_retention_days": 7 80 - } 61 + systemctl --user status solstone-linux 81 62 ``` 82 63 83 - 5. install and start the systemd user service: 84 - ``` 85 - solstone-linux install-service 86 - ``` 64 + ## updating after a code change 87 65 88 - the system tray icon appears automatically when the observer starts in a graphical session. on KDE Plasma this works out of the box. on GNOME, the AppIndicator extension is required — see step 6. 66 + ``` 67 + git pull && make upgrade 68 + ``` 89 69 90 - 6. **GNOME only:** install the AppIndicator extension for tray icon support. KDE users can skip this. 70 + ## notes 91 71 92 - GNOME removed native system tray support. the AppIndicator extension restores it via the same StatusNotifierItem protocol KDE uses. without it, the observer runs fine but has no tray icon. 72 + - activity detection (idle timeout, screen lock, power save) works on both GNOME and KDE. other desktops capture screen and audio fine but may not get activity-based segment boundaries. 73 + - 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. 93 74 94 - **ubuntu:** already installed and enabled by default — skip this step. 75 + ## appendix: GNOME tray support 95 76 96 - **fedora:** 97 - ``` 98 - sudo dnf install gnome-shell-extension-appindicator 99 - ``` 100 - then log out and back in, or restart GNOME Shell (Alt+F2, type `r`, enter). enable the extension in GNOME Extensions app if not auto-enabled. 77 + the system tray icon appears automatically when the observer starts in a graphical session. on KDE Plasma this works out of the box. on GNOME, the AppIndicator extension is required. 101 78 102 - **arch:** 103 - ``` 104 - sudo pacman -S gnome-shell-extension-appindicator 105 - ``` 79 + GNOME removed native system tray support. the AppIndicator extension restores it via the same StatusNotifierItem protocol KDE uses. without it, the observer runs fine but has no tray icon. 106 80 107 - to check if it's working: `gnome-extensions list | grep appindicator` should show it. if the tray icon still doesn't appear, verify it's enabled: `gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com` 81 + **ubuntu:** already installed and enabled by default — skip this step. 108 82 109 - 7. verify it's running and connected: 110 - ``` 111 - systemctl --user status solstone-linux 112 - sol remote list 113 - ``` 83 + **fedora:** 84 + ``` 85 + sudo dnf install gnome-shell-extension-appindicator 86 + ``` 87 + then log out and back in, or restart GNOME Shell (Alt+F2, type `r`, enter). enable the extension in GNOME Extensions app if not auto-enabled. 114 88 115 - ## notes 89 + **arch:** 90 + ``` 91 + sudo pacman -S gnome-shell-extension-appindicator 92 + ``` 116 93 117 - - activity detection (idle timeout, screen lock, power save) works on both GNOME and KDE. other desktops capture screen and audio fine but may not get activity-based segment boundaries. 118 - - if pipx is not installed: `pip install --user pipx` or install via your package manager. 119 - - 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. 94 + to check if it's working: `gnome-extensions list | grep appindicator` should show it. if the tray icon still doesn't appear, verify it's enabled: `gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com`
+33 -1
Makefile
··· 1 1 # solstone-linux Makefile 2 2 # Standalone Linux desktop observer for solstone 3 3 4 - .PHONY: install test test-only format ci clean clean-install versions all 4 + .PHONY: install test test-only format ci clean clean-install versions all deploy upgrade service-restart service-status service-logs uninstall-service 5 5 6 6 # Default target 7 7 all: install ··· 17 17 $(error uv is not installed. Install it: curl -LsSf https://astral.sh/uv/install.sh | sh) 18 18 endif 19 19 20 + APP := solstone-linux 21 + UNIT := solstone-linux.service 22 + PIPX_FLAGS := --system-site-packages 23 + 20 24 # Marker file to track installation 21 25 .installed: pyproject.toml 22 26 @echo "Installing package with uv (including dev tools)..." ··· 25 29 26 30 # Install package in editable mode with isolated venv 27 31 install: .installed 32 + 33 + deploy: 34 + @command -v pipx >/dev/null || { echo "pipx not found — install with: sudo dnf install pipx (or apt/brew equivalent)"; exit 1; } 35 + # Editable installs (pipx install -e .) are deliberately avoided: pipx treats editable installs differently and system-site-packages behavior is unreliable with them. 36 + pipx install --force $(PIPX_FLAGS) . 37 + $(APP) install-service 38 + systemctl --user --no-pager status $(UNIT) | head 39 + 40 + upgrade: ci 41 + pipx install --force $(PIPX_FLAGS) . 42 + systemctl --user daemon-reload 43 + systemctl --user restart $(UNIT) 44 + systemctl --user --no-pager status $(UNIT) | head 45 + 46 + service-restart: 47 + systemctl --user restart $(UNIT) 48 + 49 + service-status: 50 + systemctl --user --no-pager status $(UNIT) 51 + 52 + service-logs: 53 + journalctl --user -u $(UNIT) -n 100 --no-pager -f 54 + 55 + uninstall-service: 56 + -systemctl --user disable --now $(UNIT) 57 + -rm -f $$HOME/.config/systemd/user/$(UNIT) 58 + -systemctl --user daemon-reload 59 + -pipx uninstall $(APP) 28 60 29 61 # Venv tool shortcuts 30 62 PYTEST := $(VENV_BIN)/pytest
+4 -6
README.md
··· 23 23 24 24 ## Install 25 25 26 - Packages are not yet on PyPI. Install from source: 26 + For a first-time install on this machine: 27 27 28 28 ```bash 29 29 git clone https://github.com/solpbc/solstone-linux.git 30 30 cd solstone-linux 31 - pipx install --system-site-packages . 31 + make deploy 32 + solstone-linux setup 32 33 ``` 33 34 34 - `--system-site-packages` is required for PyGObject/GStreamer access. 35 + See `INSTALL.md` for distro packages, tray notes, and troubleshooting details. 35 36 36 37 ## Setup 37 38 ··· 44 45 ```bash 45 46 # Foreground 46 47 solstone-linux run 47 - 48 - # As a systemd user service 49 - solstone-linux install-service 50 48 ``` 51 49 52 50 ## Status
+2 -1
contrib/solstone-linux.service src/solstone_linux/solstone-linux.service.in
··· 5 5 6 6 [Service] 7 7 Type=simple 8 - ExecStart=/usr/bin/solstone-linux run 8 + ExecStart={BINARY} run 9 9 PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP 10 + Environment=PATH={PATH} 10 11 Restart=on-failure 11 12 RestartSec=10 12 13 StartLimitIntervalSec=300
+47 -40
src/solstone_linux/cli.py
··· 14 14 15 15 import argparse 16 16 import asyncio 17 + import importlib.resources 17 18 import json 18 19 import logging 19 20 import os ··· 153 154 service_path = ":".join(dict.fromkeys(path_entries)) 154 155 155 156 unit_dir = Path.home() / ".config" / "systemd" / "user" 156 - unit_dir.mkdir(parents=True, exist_ok=True) 157 157 unit_path = unit_dir / "solstone-linux.service" 158 + template = ( 159 + importlib.resources.files("solstone_linux") 160 + .joinpath("solstone-linux.service.in") 161 + .read_text() 162 + ) 163 + unit = template.replace("{BINARY}", binary).replace("{PATH}", service_path) 164 + existing = unit_path.read_text() if unit_path.exists() else None 158 165 159 - unit_content = f"""\ 160 - [Unit] 161 - Description=Solstone Linux Desktop Observer 162 - After=graphical-session.target 163 - BindsTo=graphical-session.target 166 + if existing == unit and not args.force: 167 + print("Unit unchanged; nothing to do") 168 + else: 169 + unit_dir.mkdir(parents=True, exist_ok=True) 170 + unit_path.write_text(unit) 171 + print(f"Wrote {unit_path}") 164 172 165 - [Service] 166 - Type=simple 167 - ExecStart={binary} run 168 - PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP 169 - Environment=PATH={service_path} 170 - Restart=on-failure 171 - RestartSec=10 172 - StartLimitIntervalSec=300 173 - StartLimitBurst=5 174 - 175 - [Install] 176 - WantedBy=graphical-session.target 177 - """ 178 - 179 - unit_path.write_text(unit_content) 180 - print(f"Wrote {unit_path}") 181 - 182 - # Reload, enable, start 183 - try: 184 - subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 185 - subprocess.run( 186 - ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], 187 - check=True, 188 - ) 189 - print("Service enabled and started.") 190 - subprocess.run( 191 - ["systemctl", "--user", "status", "solstone-linux.service"], 192 - check=False, 193 - ) 194 - except FileNotFoundError: 195 - print("Warning: systemctl not found. Enable the service manually.") 196 - except subprocess.CalledProcessError as e: 197 - print(f"Warning: systemctl command failed: {e}") 173 + # Reload, enable, restart, and show status 174 + try: 175 + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 176 + subprocess.run( 177 + ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], 178 + check=True, 179 + ) 180 + subprocess.run( 181 + ["systemctl", "--user", "restart", "solstone-linux.service"], 182 + check=True, 183 + ) 184 + subprocess.run( 185 + [ 186 + "systemctl", 187 + "--user", 188 + "--no-pager", 189 + "status", 190 + "solstone-linux.service", 191 + ], 192 + check=False, 193 + ) 194 + except FileNotFoundError: 195 + print("Warning: systemctl not found. Enable the service manually.") 196 + except subprocess.CalledProcessError as e: 197 + print(f"Warning: systemctl command failed: {e}") 198 198 199 199 icon_source = Path(__file__).resolve().parent / "icons" / "hicolor" 200 200 if icon_source.is_dir(): ··· 328 328 subparsers.add_parser("setup", help="Interactive configuration") 329 329 330 330 # install-service 331 - subparsers.add_parser("install-service", help="Install systemd user service") 331 + install_svc = subparsers.add_parser( 332 + "install-service", help="Install systemd user service" 333 + ) 334 + install_svc.add_argument( 335 + "--force", 336 + action="store_true", 337 + help="Always rewrite the unit file and restart the service, even if unchanged", 338 + ) 332 339 333 340 # status 334 341 subparsers.add_parser("status", help="Show capture and sync state")
+54 -3
tests/test_cli.py
··· 6 6 from pathlib import Path 7 7 from unittest.mock import patch 8 8 9 + from solstone_linux import cli as cli_module 9 10 from solstone_linux.cli import cmd_install_service 11 + 12 + 13 + def _args(force: bool = False) -> argparse.Namespace: 14 + return argparse.Namespace(force=force) 15 + 16 + 17 + _REAL_IS_DIR = Path.is_dir 18 + 19 + 20 + def _is_dir_without_icons(self: Path) -> bool: 21 + icon_source = Path(cli_module.__file__).resolve().parent / "icons" / "hicolor" 22 + if self == icon_source: 23 + return False 24 + return _REAL_IS_DIR(self) 10 25 11 26 12 27 def test_cmd_install_service_uses_environment_path(tmp_path: Path): ··· 21 36 with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 22 37 with patch("solstone_linux.cli.subprocess.run"): 23 38 with patch("solstone_linux.cli.Path.is_dir", return_value=False): 24 - assert cmd_install_service(argparse.Namespace()) == 0 39 + assert cmd_install_service(_args()) == 0 25 40 26 41 unit_content = unit_path.read_text() 27 42 path_line = next( ··· 44 59 with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 45 60 with patch("solstone_linux.cli.subprocess.run"): 46 61 with patch("solstone_linux.cli.Path.is_dir", return_value=False): 47 - assert cmd_install_service(argparse.Namespace()) == 0 62 + assert cmd_install_service(_args()) == 0 48 63 49 64 unit_content = unit_path.read_text() 50 65 path_line = next( ··· 68 83 with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 69 84 with patch("solstone_linux.cli.subprocess.run"): 70 85 with patch("solstone_linux.cli.Path.is_dir", return_value=False): 71 - assert cmd_install_service(argparse.Namespace()) == 0 86 + assert cmd_install_service(_args()) == 0 72 87 73 88 unit_content = unit_path.read_text() 74 89 path_line = next( ··· 81 96 path_line 82 97 == "Environment=PATH=/home/user/.local/pipx/venvs/solstone-linux/bin:/usr/local/bin:/usr/bin:/bin" 83 98 ) 99 + 100 + 101 + def test_cmd_install_service_unchanged_is_noop(tmp_path: Path, capsys): 102 + binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" 103 + 104 + with patch.dict(os.environ, {"PATH": "/usr/local/bin:/usr/bin:/bin"}, clear=True): 105 + with patch("solstone_linux.cli.shutil.which", return_value=binary): 106 + with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 107 + with patch("solstone_linux.cli.subprocess.run") as run_mock: 108 + with patch("solstone_linux.cli.Path.is_dir", return_value=False): 109 + assert cmd_install_service(_args()) == 0 110 + first_call_count = run_mock.call_count 111 + assert cmd_install_service(_args()) == 0 112 + 113 + captured = capsys.readouterr() 114 + assert "Unit unchanged; nothing to do" in captured.out 115 + assert run_mock.call_count == first_call_count 116 + 117 + 118 + def test_cmd_install_service_force_always_writes(tmp_path: Path): 119 + binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" 120 + 121 + with patch.dict(os.environ, {"PATH": "/usr/local/bin:/usr/bin:/bin"}, clear=True): 122 + with patch("solstone_linux.cli.shutil.which", return_value=binary): 123 + with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 124 + with patch("solstone_linux.cli.subprocess.run") as run_mock: 125 + with patch( 126 + "solstone_linux.cli.Path.is_dir", 127 + autospec=True, 128 + side_effect=_is_dir_without_icons, 129 + ): 130 + assert cmd_install_service(_args()) == 0 131 + first_call_count = run_mock.call_count 132 + assert cmd_install_service(_args(force=True)) == 0 133 + 134 + assert run_mock.call_count == first_call_count + 4