diff --git a/.gitignore b/.gitignore index 4967cc7..a62853c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,4 @@ -__pycache__/ -*.pyc -*.egg-info/ dist/ build/ -.pytest_cache/ -.mypy_cache/ -.venv/ -.installed -uv.lock /target/ /.transparency-staging/ diff --git a/AGENTS.md b/AGENTS.md index 4e742ec..9a959cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Development guidelines for solstone-linux, a standalone Linux desktop observer. solstone-linux is a companion app that runs alongside the main [solstone](https://solstone.app) journal. It is one of the owner's observers — it experiences screen and audio along with the owner on a Linux desktop using PipeWire and GStreamer, stores segments locally, and syncs them to your solstone journal. It runs as a systemd user service on GNOME Wayland sessions. -This is **not** part of the solstone monorepo. It is a standalone Rust package with its own native release lifecycle. The retained former Python rail remains functional but is not part of the shipping product rail. +This is **not** part of the solstone monorepo. It is a standalone Rust package with its own native release lifecycle. ## Source Layout @@ -18,35 +18,6 @@ packaging/ Native package Containerfile and install notes scripts/build-release.sh Non-candidate native package drift helper scripts/install.sh Portable archive installer -src/solstone_linux/ Retained former Python implementation - __init__.py Package version - cli.py CLI entry point (run, setup, settings, install-service, status) - solstone-linux.service.in Systemd unit template (rendered by install-service) - config.py Config loading/persistence (config under ~/.config/solstone-linux/) - doctor.py Install prerequisite checks for the doctor command - install_guard.py Install ownership guard for pipx-managed service installs - observer.py Main capture loop — state machine (idle/screencast), audio + video - capture_stats.py Shared capture cache statistics - screencast.py Portal-based multi-monitor recording (xdg-desktop-portal + GStreamer) - audio_recorder.py Stereo audio recording (mic + system via soundcard) - audio_detect.py Audio device detection via ultrasonic tone - audio_mute.py PulseAudio mute state detection - activity.py Cross-desktop activity detection (screen lock, power save) via DBus - monitor_positions.py Monitor position assignment from geometry - session_env.py Desktop session environment checks and recovery - streams.py Stream name derivation (hostname-based) - event_sender.py Background sender for observer event relay - sync.py Background sync service — uploads completed segments to server - sync_health.py Sync health facts, derivation, persistence, and surface copy - upload.py HTTP upload client for solstone ingest server - recovery.py Crash recovery for orphaned .incomplete segments - chat_bridge.py Server-initiated chat event bridge to local notifications - dbus_service.py Observer status/control D-Bus service interface - dbusmenu.py D-Bus menu protocol implementation for tray menus - sni.py StatusNotifierItem D-Bus interface for tray icons - tray.py In-process D-Bus SNI tray icon, menu, and tooltip - -tests/ Legacy Python pytest suite contrib/ Reference icons for development fallback ``` @@ -57,22 +28,9 @@ The shipping observer is the Rust workspace member under the systemd user-service lifecycle. Use the Rust source and tests as the authority for current product behavior. -## Legacy Python architecture - -The retained Python implementation runs a single asyncio event loop with three -concurrent concerns. This describes legacy code, not the shipping observer: - -1. **Capture loop** (`observer.py`) — Checks activity status every 5 seconds, records audio continuously, manages screencast recording via GStreamer. Creates 5-minute segments in `~/.local/share/solstone-linux/captures/YYYYMMDD/stream/HHMMSS_DDD/`. Segment directories start as `.incomplete` and are renamed on finalization. - -2. **Sync service** (`sync.py`) — Background asyncio task that walks the captures directory, queries the server for existing segments, and uploads missing ones. Circuit breaker pattern with error-type-aware thresholds. - -3. **Chat bridge** (`chat_bridge.py`) — Background asyncio task that consumes server-sent callosum chat events, mirrors request/clear messages to an optional local FIFO, and fires click-capturing `notify-send` subprocesses when server opt-in allows Linux desktop notifications. - -State machine has two modes: `screencast` (screen active, recording video) and `idle` (screen inactive). Mode transitions, mute state changes, and 5-minute intervals all trigger segment boundaries. - -The capture loop never makes network calls. It writes locally; sync handles all uploads. - -The `observe/status` heartbeat carries top-level diagnostics-only health-beacon fields for registered observers; these contain no captured content, paths, URLs, tokens, titles, or labels. Missing or legacy beacons are liveness-only and not failures; journal-side ingest rejections (`health.ingest_rejection`) are separate and are not produced by the observer. +Comments in the Rust source that cite `tests/test_*.py` or +`src/solstone_linux/` refer to the pre-1.0 Python implementation preserved in +this repository's git history. ## Commands @@ -100,18 +58,11 @@ make uninstall-service # Remove the native systemd user service make clean # Remove build artifacts and caches make clean-install # Clean build artifacts, then reinstall the Rust observer make versions # Show installed package versions - -make legacy-python-bootstrap # Install uv if needed and set up retained Python code -make legacy-python-install # Set up the retained Python environment -make legacy-python-format # Format and lint retained Python code -make legacy-python-test # Run all retained Python tests -make legacy-python-test-only TEST= # Run selected retained Python tests -make legacy-python-ci # Run the retained Python gate ``` ## Rust rebuild -The root Cargo workspace is workspace-only: `crates/solstone-linux/` contains the shipping observer, native CLI, service lifecycle, and Linux video-capture backends. `rust-toolchain.toml` is the compiler authority. Use the canonical Make targets above; Python targets are retained for maintenance of the former rail and are not part of the shipping product rail. For the operator-run native packaging rail and its blocking release validation, see `RELEASING.md`. +The root Cargo workspace is workspace-only: `crates/solstone-linux/` contains the shipping observer, native CLI, service lifecycle, and Linux video-capture backends. `rust-toolchain.toml` is the compiler authority. Use the canonical Make targets above. For the operator-run native packaging rail and its blocking release validation, see `RELEASING.md`. ## Releasing @@ -130,24 +81,18 @@ individual `scripts/build-release.sh` lanes write only non-candidate drift evide Follow `RELEASING.md` for image and advisory preconditions, stale-lock recovery, proof resume, read-only recovery, and the separate FLAC checkpoint. -## Legacy Python development principles - -- **Simple code.** Prefer plain functions over classes. Use dataclasses for structured data. Only use classes when managing stateful lifecycle (Observer, Screencaster, SyncService, AudioRecorder). -- **Async by default.** The main loop is asyncio. DBus calls, subprocess management, and sync all use async. Audio recording uses a dedicated thread because soundcard is blocking. -- **No network in the capture loop.** The observer writes segments locally. The sync service uploads asynchronously. This keeps capture reliable even when the server is down. -- **Atomic directory operations.** Segments start as `HHMMSS.incomplete/`, are renamed to `HHMMSS_DDD/` on completion, or `HHMMSS.failed/` on recovery failure. -- **System site-packages required for legacy Python.** PyGObject and GStreamer bindings come from system packages. Its venv must use `--system-site-packages`. - ## File Headers -All `.py` source files must include this header as the first two lines: +All `.rs` source files under `crates/` must include this header as the first two +lines: -```python -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc +```rust +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc ``` -Add this header to new `.py` files in `src/solstone_linux/` and `tests/`. Do not add headers to markdown, TOML, or config files. +Add this header to new `.rs` files under `crates/`. Do not add headers to +markdown, TOML, or config files. ## Runtime Dependencies @@ -159,35 +104,17 @@ Shipping system packages: - xdg-utils for opening links - a desktop notification service; an SNI host is optional for the tray icon -Legacy Python packages (in `pyproject.toml`; not used by the shipping binary): -- `requests` — HTTP upload client -- `numpy` — Audio buffer manipulation and RMS computation -- `soundfile` — FLAC encoding -- `soundcard` — Audio device enumeration and recording -- `dbus-fast` — Async DBus client for portal and activity detection -- `PyGObject` — GDK monitor geometry (`python3-gobject` / `python3-gi`, installed from the system) - ## Data Paths - Config: `~/.config/solstone-linux/config.json` - Captures: `~/.local/share/solstone-linux/captures/` - State: `~/.local/share/solstone-linux/state/` - Restore token: `~/.config/solstone-linux/restore_token` -- Legacy Python install source marker: `~/.config/solstone-linux/.install-source` (tracks which repo clone owns the former pipx install) - -## Legacy Python implementation patterns - -- **Activity detection is cross-desktop.** Uses ordered DBus fallback chains for screen lock (freedesktop.org ScreenSaver → GNOME ScreenSaver) and power save (Mutter DisplayConfig → KDE Solid PowerManagement). All backends degrade gracefully to safe defaults. -- **Audio is stereo-interleaved.** Left channel = microphone, right channel = system audio. When muted, channels are split into separate mono FLAC files. -- **Screencast uses xdg-desktop-portal.** Session persistence via restore tokens avoids re-prompting the user. GStreamer subprocess (`gst-launch-1.0`) handles the actual PipeWire recording. -- **Crash recovery runs on startup.** `recovery.py` scans for orphaned `.incomplete` directories older than 2 minutes and finalizes or marks them as failed. ## Testing Run `make test` for the locked Rust suite or `make ci` for host evidence across Rust formatting, lint, tests, shell scripts, and offline dependency policy. -The retained Python tests use pytest with mocked audio devices, D-Bus, and -GStreamer; run them separately with `make legacy-python-test`. ## Brand canon diff --git a/INSTALL.md b/INSTALL.md index 5f8caef..b097d77 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -84,23 +84,9 @@ Activity detection uses screen-lock and power-save signals to notice when you st The tray uses the StatusNotifierItem D-Bus protocol. KDE supports it directly. GNOME requires an AppIndicator extension; without an SNI host, the observer continues normally without a tray icon. -## retained Python implementation - -**The PyPI channel is retired.** `solstone-linux` is no longer developed or -published on PyPI, and the publish path has been removed from this repository — -`scripts/release.sh`, `make legacy-python-release`, and `make -legacy-python-release-test` are gone. - -The previously published versions remain available on PyPI for legacy purposes, -and they are not the product. They stop at `0.4.5`, which predates the native -rewrite; installing one gets you the retired Python implementation, not -`solstone-linux` as it ships today. Install the `.deb`, `.rpm`, or portable -archive from the release instead; see the Install section above. - -The Python source and tests remain for maintenance and historical parity, but -they cannot publish and are not part of the shipping product rail. Their -commands are `make legacy-python-bootstrap`, `legacy-python-install`, -`legacy-python-format`, `legacy-python-test`, `legacy-python-test-only -TEST=`, and `legacy-python-ci`. They require uv and the former -system PyGObject environment. Canonical install, test, CI, service, and -release commands are Rust-native. +## PyPI history + +Previously published PyPI versions remain available at version 0.4.5 for legacy +availability. They contain the retired pre-native Python implementation; +current releases are the native Debian, RPM, and portable packages described +above. diff --git a/Makefile b/Makefile index dd05154..31b1ffc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # solstone-linux Makefile # Standalone Linux desktop observer for solstone -.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest check-transparency-minisign check-audit-signed-packet ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release release-images release-candidate release-candidate-prove release-candidate-recover publish-transparency resign-transparency-pointer legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci check-toolchain-env establish-toolchain rust-preflight check-cargo-deny +.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest check-transparency-minisign check-audit-signed-packet ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release release-images release-candidate release-candidate-prove release-candidate-recover publish-transparency resign-transparency-pointer check-toolchain-env establish-toolchain rust-preflight check-cargo-deny APP := solstone-linux UNIT := solstone-linux.service @@ -23,13 +23,6 @@ UBUNTU_STOCK_BASE := sha256:b8e6b596a32475661d9fcaf4a212fcc7736e0d8d1494973aefdb FEDORA_STOCK_BASE := sha256:1eea7f82474ec19ef359ee5a5896014df434cd44c0d6ba2b937ffbe0697dec56 SHELLCHECK_SCRIPTS := scripts/build-release.sh scripts/install.sh -VENV := .venv -VENV_BIN := $(VENV)/bin -PYTEST := $(VENV_BIN)/pytest -RUFF := $(VENV_BIN)/ruff -UV := $(shell command -v uv 2>/dev/null) -VENV_FLAGS := --system-site-packages - all: install check-toolchain-env: @@ -212,41 +205,7 @@ release-candidate-recover: rust-preflight clean: @echo "Cleaning build artifacts and cache files..." - rm -rf build/ dist/ *.egg-info/ + rm -rf build/ dist/ rm -rf target/ - rm -rf .pytest_cache/ .mypy_cache/ - find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete - find . -type f -name "*.pyo" -delete - rm -f .legacy-python-installed - rm -rf $(VENV) clean-install: clean install - -.legacy-python-installed: pyproject.toml - @command -v uv >/dev/null 2>&1 || { echo "error: uv is required for legacy Python targets" >&2; exit 1; } - @[ -f $(VENV)/pyvenv.cfg ] || $(UV) venv $(VENV_FLAGS) --python /usr/bin/python3 $(VENV) - $(UV) sync --group dev --no-install-package pygobject --no-install-package pycairo - @touch .legacy-python-installed - -legacy-python-install: .legacy-python-installed - -legacy-python-format: .legacy-python-installed - $(RUFF) format . - $(RUFF) check --fix . - -legacy-python-test: .legacy-python-installed - $(PYTEST) tests/ -q - -legacy-python-test-only: .legacy-python-installed - @test -n "$(TEST)" || { echo "Usage: make legacy-python-test-only TEST=" >&2; exit 1; } - $(PYTEST) $(TEST) - -legacy-python-ci: .legacy-python-installed - $(RUFF) format --check . - $(RUFF) check . - $(PYTEST) tests/ -q - -legacy-python-bootstrap: - @if command -v uv >/dev/null 2>&1; then echo "uv already installed"; else curl -LsSf https://astral.sh/uv/install.sh | sh; fi - @$(MAKE) legacy-python-install diff --git a/README.md b/README.md index 52b3098..35a3fb2 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,6 @@ make install-service solstone-linux setup ``` -The former Python rail remains functional behind `legacy-python-*` targets, -including its publishing targets, but it is not part of the shipping product -rail. - ## Setup ```bash diff --git a/RELEASING.md b/RELEASING.md index 4f88d51..fbdead5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -296,5 +296,4 @@ signature and validity. It never re-attests a rolled-back or foreign pointer. The surface attests what was released, that it is immutable, and that history is publicly reconstructible — not that binaries provably match source. Publication is -operator-approved and separate from the retained legacy Python publisher, which is -not a native release path. +operator-approved. diff --git a/crates/solstone-linux/src/dbus_service.rs b/crates/solstone-linux/src/dbus_service.rs index fa4284f..70bda31 100644 --- a/crates/solstone-linux/src/dbus_service.rs +++ b/crates/solstone-linux/src/dbus_service.rs @@ -349,9 +349,7 @@ mod tests { service.introspect_to_writer(&mut xml, 0); assert_eq!( normalized(&xml), - normalized(include_str!( - "../../../tests/fixtures/introspection/observer1.xml" - )) + normalized(include_str!("../testdata/introspection/observer1.xml")) ); } #[test] diff --git a/crates/solstone-linux/src/service.rs b/crates/solstone-linux/src/service.rs index 5194793..da1bf2a 100644 --- a/crates/solstone-linux/src/service.rs +++ b/crates/solstone-linux/src/service.rs @@ -190,7 +190,6 @@ pub fn uninstall(paths: &ServicePaths, runner: &dyn Runner, output: &mut dyn io: #[cfg(test)] mod tests { use super::*; - use sha2::{Digest, Sha256}; use std::{cell::RefCell, os::unix::fs::PermissionsExt}; struct FakeRunner { @@ -382,16 +381,4 @@ mod tests { assert_eq!(&calls[1][1..], &["disable", "solstone-linux.service"]); assert_eq!(&calls[2][1..], &["daemon-reload"]); } - // AC: this freezes the Python template and deliberately does not compare it to the native template. - #[test] - fn python_template_is_frozen() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../src/solstone_linux/solstone-linux.service.in"); - let bytes = fs::read(path).unwrap(); - assert_eq!(bytes.len(), 417); - assert_eq!( - format!("{:x}", Sha256::digest(bytes)), - "5bd74d6c852a3851e48a798147c913c2d5c6f875fb5c8cca2f4762fca1890dad" - ); - } } diff --git a/tests/fixtures/introspection/observer1.xml b/crates/solstone-linux/testdata/introspection/observer1.xml similarity index 100% rename from tests/fixtures/introspection/observer1.xml rename to crates/solstone-linux/testdata/introspection/observer1.xml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 73ff0e1..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[project] -name = "solstone-linux" -version = "0.4.5" -description = "Standalone Linux desktop observer for solstone" -readme = "README.md" -license = "AGPL-3.0-only" -requires-python = ">=3.10" -dependencies = [ - "requests", - "numpy", - "soundfile", - "soundcard", - "dbus-fast>=5.0", - "PyGObject", -] - -[project.scripts] -solstone-linux = "solstone_linux.cli:main" - -[dependency-groups] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", -] - -[build-system] -requires = ["hatchling>=1.18"] -build-backend = "hatchling.build" diff --git a/scripts/extract_changelog.sh b/scripts/extract_changelog.sh deleted file mode 100755 index 7e58cc7..0000000 --- a/scripts/extract_changelog.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -# -# Extract a single version's block from CHANGELOG.md. -# Usage: extract_changelog.sh [] -set -euo pipefail - -if [[ $# -lt 1 || $# -gt 2 ]]; then - echo "usage: $(basename "$0") []" >&2 - exit 2 -fi - -VERSION="$1" -CHANGELOG="${2:-CHANGELOG.md}" - -if [[ ! -f "$CHANGELOG" ]]; then - echo "error: $CHANGELOG not found" >&2 - exit 1 -fi - -# Escape regex metacharacters in the version (dots, etc.) for the awk pattern. -ESCAPED=$(printf '%s\n' "$VERSION" | sed 's/[][\\.*^$/]/\\&/g') -AWK_ESCAPED="${ESCAPED//\\/\\\\}" - -OUTPUT=$(awk -v pat="^## \\\\[${AWK_ESCAPED}\\\\]" ' - /^## \[/ { if (seen) exit } - $0 ~ pat { seen=1 } - seen -' "$CHANGELOG") - -if [[ -z "$OUTPUT" ]]; then - echo "error: no CHANGELOG.md entry for version ${VERSION}" >&2 - exit 1 -fi - -printf '%s\n' "$OUTPUT" diff --git a/src/solstone_linux/__init__.py b/src/solstone_linux/__init__.py deleted file mode 100644 index 5f34035..0000000 --- a/src/solstone_linux/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Standalone Linux desktop observer for solstone.""" - -__version__ = "0.4.5" diff --git a/src/solstone_linux/activity.py b/src/solstone_linux/activity.py deleted file mode 100644 index 2ffe0ee..0000000 --- a/src/solstone_linux/activity.py +++ /dev/null @@ -1,515 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Activity detection using DBus APIs. - -Detects screen lock and display power-save state via DBus, with ordered -fallback chains that cover GNOME and KDE desktops. Every function -degrades gracefully — returning a safe default — so the observer keeps -running regardless of desktop environment. -""" - -import asyncio -import logging -import os -import re -import shutil -import subprocess - -from dbus_fast import Variant -from dbus_fast.aio import MessageBus -from dbus_fast.errors import ( - DBusError, - InvalidIntrospectionError, - InvalidMemberNameError, -) - -logger = logging.getLogger(__name__) - -_DBUS_PROBE_TIMEOUT_SEC = 2.0 -_POWER_SAVE_WARNED_BACKENDS: set[str] = set() -# One session bus lives for the whole process, so id(bus) is stable and -# never recycled — a plain dict needs no eviction. Keyed by bus identity -# because dbus-fast's cython MessageBus cannot be weak-referenced. -_PROXY_CACHE: dict = {} - -_SERVICE_MISSING_ERRORS = ( - "org.freedesktop.DBus.Error.ServiceUnknown", - "org.freedesktop.DBus.Error.NameHasNoOwner", -) - -# GTK4/GDK4 — optional, only needed for monitor geometry detection. -# On systems without GTK4, get_monitor_geometries() will raise RuntimeError -# but screencast recording still works (monitors labeled as "monitor-N"). -try: - import gi - - gi.require_version("Gdk", "4.0") - gi.require_version("Gtk", "4.0") - from gi.repository import Gdk, Gtk - - _HAS_GTK = True -except (ImportError, ValueError): - _HAS_GTK = False - -# DBus service constants — screen lock -FDO_SCREENSAVER_BUS = "org.freedesktop.ScreenSaver" -FDO_SCREENSAVER_PATH = "/ScreenSaver" -FDO_SCREENSAVER_IFACE = "org.freedesktop.ScreenSaver" - -GNOME_SCREENSAVER_BUS = "org.gnome.ScreenSaver" -GNOME_SCREENSAVER_PATH = "/org/gnome/ScreenSaver" -GNOME_SCREENSAVER_IFACE = "org.gnome.ScreenSaver" - -# DBus service constants — power save -DISPLAY_CONFIG_BUS = "org.gnome.Mutter.DisplayConfig" -DISPLAY_CONFIG_PATH = "/org/gnome/Mutter/DisplayConfig" -DISPLAY_CONFIG_IFACE = "org.gnome.Mutter.DisplayConfig" - -# DBus service constants — monitor geometry (KDE) -KSCREEN_BUS = "org.kde.KScreen" -KSCREEN_PATH = "/backend" -KSCREEN_IFACE = "org.kde.kscreen.Backend" - - -def _is_service_missing(exc: BaseException) -> bool: - """True if exc is a DBusError meaning the bus name is not currently owned.""" - return ( - isinstance(exc, DBusError) - and getattr(exc, "type", "") in _SERVICE_MISSING_ERRORS - ) - - -def _is_gnome_desktop() -> bool: - """True if any XDG_CURRENT_DESKTOP token equals 'gnome' (case-insensitive).""" - return any( - token.strip().casefold() == "gnome" - for token in os.environ.get("XDG_CURRENT_DESKTOP", "").split(":") - ) - - -async def _cached_interface(bus: MessageBus, service: str, path: str, iface_name: str): - key = (id(bus), service, path, iface_name) - if key in _PROXY_CACHE: - return _PROXY_CACHE[key] - intro = await bus.introspect(service, path) - obj = bus.get_proxy_object(service, path, intro) - iface = obj.get_interface(iface_name) - _PROXY_CACHE[key] = iface - return iface - - -def _invalidate_interface( - bus: MessageBus, service: str, path: str, iface_name: str -) -> None: - _PROXY_CACHE.pop((id(bus), service, path, iface_name), None) - - -async def _name_has_owner(bus: MessageBus, bus_name: str) -> bool: - """Ask the bus daemon whether a well-known name is currently owned. - - Returns False on any probe failure (daemon unreachable, timeout, parser - error) after logging a warning — the service is treated as absent. - """ - - async def _probe() -> bool: - intro = await bus.introspect("org.freedesktop.DBus", "/org/freedesktop/DBus") - obj = bus.get_proxy_object( - "org.freedesktop.DBus", "/org/freedesktop/DBus", intro - ) - iface = obj.get_interface("org.freedesktop.DBus") - return bool(await iface.call_name_has_owner(bus_name)) - - try: - return await asyncio.wait_for(_probe(), timeout=_DBUS_PROBE_TIMEOUT_SEC) - except (DBusError, InvalidMemberNameError, OSError, asyncio.TimeoutError) as exc: - logger.warning( - "NameHasOwner probe failed: service=%s path=%s: %s: %s", - bus_name, - "/org/freedesktop/DBus", - type(exc).__name__, - exc, - ) - return False - - -def get_monitor_geometries_x11() -> list[dict]: - """Get monitor geometry from xrandr (X11 only). - - Returns: - List of dicts with format: - [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "..."}, ...] - Empty list if xrandr is unavailable or returns no connected monitors. - """ - try: - result = subprocess.run( - ["xrandr"], - capture_output=True, - text=True, - timeout=5, - ) - except (FileNotFoundError, subprocess.TimeoutExpired, OSError): - return [] - - if result.returncode != 0: - return [] - - from .monitor_positions import assign_monitor_positions - - monitors = [] - for line in result.stdout.splitlines(): - if " connected" not in line or "disconnected" in line: - continue - parts = line.split() - name = parts[0] - for part in parts: - m = re.match(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", part) - if m: - w, h = int(m.group(1)), int(m.group(2)) - x, y = int(m.group(3)), int(m.group(4)) - if x < 0 or y < 0: - logger.warning( - "Skipping monitor %s with negative offset (%d, %d); " - "ximagesrc requires non-negative coordinates", - name, - x, - y, - ) - break - monitors.append({"id": name, "box": [x, y, x + w, y + h]}) - break - - return assign_monitor_positions(monitors) - - -async def is_dpms_active() -> bool: - """Check if DPMS has powered off the display (X11 only). - - Runs xset q and parses the monitor state line. - Returns True if the display is in standby/suspend/off state, False otherwise. - Degrades gracefully to False when xset is unavailable or returns an error. - """ - try: - result = await asyncio.to_thread( - subprocess.run, - ["xset", "q"], - capture_output=True, - text=True, - timeout=2, - ) - except (FileNotFoundError, subprocess.TimeoutExpired, OSError): - return False - - if result.returncode != 0: - return False - - for line in result.stdout.splitlines(): - stripped = line.strip() - if stripped.startswith("Monitor is"): - return stripped != "Monitor is On" - return False - - -async def probe_activity_services(bus: MessageBus) -> dict[str, bool]: - """Check which activity DBus services are reachable.""" - services = { - "fdo_screensaver": FDO_SCREENSAVER_BUS, - "gnome_screensaver": GNOME_SCREENSAVER_BUS, - "gnome_display_config": DISPLAY_CONFIG_BUS, - "kscreen": KSCREEN_BUS, - } - results = {} - for name, bus_name in services.items(): - results[name] = await _name_has_owner(bus, bus_name) - - # DPMS is X11-only, checked via xset availability - results["dpms"] = bool(shutil.which("xset")) - results["gtk4"] = _HAS_GTK - - # Log grouped by function - lock_backends = ["fdo_screensaver", "gnome_screensaver"] - power_backends = ["gnome_display_config"] - monitor_backends = ["kscreen"] - - def _status(keys): - return ", ".join(f"{k} [{'ok' if results[k] else 'missing'}]" for k in keys) - - logger.info("Screen lock backends: %s", _status(lock_backends)) - logger.info( - "Power save backends: %s, dpms [%s]", - _status(power_backends), - "ok" if results["dpms"] else "missing", - ) - logger.info( - "Monitor backends: %s, gtk4 [%s]", - _status(monitor_backends), - "ok" if results["gtk4"] else "missing", - ) - - any_lock = any(results[k] for k in lock_backends) - any_power = any(results[k] for k in power_backends) - if not any_lock and not any_power: - logger.warning( - "No activity backends available — running in always-capture mode" - ) - - return results - - -async def is_screen_locked(bus: MessageBus) -> bool: - """Check if the screen is locked. - - On GNOME, probes only org.gnome.ScreenSaver — the FDO ScreenSaver bus - on GNOME serves idle-inhibit endpoints only and does not implement - GetActive. On non-GNOME desktops, tries FDO ScreenSaver first (KDE - kwin and other compliant desktops), then falls back to GNOME - ScreenSaver. Returns True if locked, False if unlocked or all - backends unavailable. - """ - if not _is_gnome_desktop(): - # Try freedesktop.org ScreenSaver first (KDE kwin and other non-GNOME desktops) - try: - iface = await _cached_interface( - bus, - FDO_SCREENSAVER_BUS, - FDO_SCREENSAVER_PATH, - FDO_SCREENSAVER_IFACE, - ) - return bool(await iface.call_get_active()) - except ( - DBusError, - InvalidMemberNameError, - InvalidIntrospectionError, - OSError, - ) as exc: - _invalidate_interface( - bus, - FDO_SCREENSAVER_BUS, - FDO_SCREENSAVER_PATH, - FDO_SCREENSAVER_IFACE, - ) - if not _is_service_missing(exc): - logger.warning( - "is_screen_locked FDO backend failed: service=%s path=%s: %s: %s", - FDO_SCREENSAVER_BUS, - FDO_SCREENSAVER_PATH, - type(exc).__name__, - exc, - ) - - # Fall back to GNOME ScreenSaver - try: - iface = await _cached_interface( - bus, - GNOME_SCREENSAVER_BUS, - GNOME_SCREENSAVER_PATH, - GNOME_SCREENSAVER_IFACE, - ) - return bool(await iface.call_get_active()) - except ( - DBusError, - InvalidMemberNameError, - InvalidIntrospectionError, - OSError, - ) as exc: - _invalidate_interface( - bus, - GNOME_SCREENSAVER_BUS, - GNOME_SCREENSAVER_PATH, - GNOME_SCREENSAVER_IFACE, - ) - if not _is_service_missing(exc): - logger.warning( - "is_screen_locked GNOME backend failed: service=%s path=%s: %s: %s", - GNOME_SCREENSAVER_BUS, - GNOME_SCREENSAVER_PATH, - type(exc).__name__, - exc, - ) - return False - - -async def is_power_save_active(bus: MessageBus) -> bool: - """Return True when the session reports a power-saving/display-off state. - - Checks GNOME Mutter PowerSaveMode, then falls back to X11 DPMS when - XDG_SESSION_TYPE is x11; degrades to False when no backend is available. - """ - - def log_backend_failure_once(backend: str, bus_name: str, path: str, exc) -> None: - level = logger.warning - if backend in _POWER_SAVE_WARNED_BACKENDS: - level = logger.debug - else: - _POWER_SAVE_WARNED_BACKENDS.add(backend) - level( - "is_power_save_active %s backend failed: service=%s path=%s: %s: %s", - backend, - bus_name, - path, - type(exc).__name__, - exc, - ) - - # Try GNOME Mutter DisplayConfig first - try: - iface = await _cached_interface( - bus, - DISPLAY_CONFIG_BUS, - DISPLAY_CONFIG_PATH, - "org.freedesktop.DBus.Properties", - ) - mode_variant = await iface.call_get(DISPLAY_CONFIG_IFACE, "PowerSaveMode") - mode = int(mode_variant.value) - return mode != 0 - except ( - DBusError, - InvalidMemberNameError, - InvalidIntrospectionError, - OSError, - ) as exc: - _invalidate_interface( - bus, - DISPLAY_CONFIG_BUS, - DISPLAY_CONFIG_PATH, - "org.freedesktop.DBus.Properties", - ) - if not _is_service_missing(exc): - log_backend_failure_once( - "Mutter", - DISPLAY_CONFIG_BUS, - DISPLAY_CONFIG_PATH, - exc, - ) - - # X11-only fallback: DPMS via xset - if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11": - return await is_dpms_active() - - return False - - -def get_monitor_geometries() -> list[dict]: - """ - Get structured monitor information. - - Returns: - List of dicts with format: - [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...] - where box contains [left, top, right, bottom] coordinates - - Raises: - RuntimeError: If GTK4/GDK4 is not available. - """ - if not _HAS_GTK: - raise RuntimeError("GTK4 not available for monitor geometry detection") - - from .monitor_positions import assign_monitor_positions - - # Initialize GTK before using GDK functions - Gtk.init() - - # Get the default display. If it is None, try opening one from the environment. - display = Gdk.Display.get_default() - if display is None: - env_display = os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY") - if env_display is not None: - display = Gdk.Display.open(env_display) - if display is None: - raise RuntimeError("No display available") - monitors = display.get_monitors() - - # Collect monitor geometries - geometries = [] - for monitor in monitors: - geom = monitor.get_geometry() - connector = monitor.get_connector() or f"monitor-{len(geometries)}" - geometries.append( - { - "id": connector, - "box": [geom.x, geom.y, geom.x + geom.width, geom.y + geom.height], - } - ) - - # Assign position labels using shared algorithm - return assign_monitor_positions(geometries) - - -def _unwrap_variants(obj): - """Recursively unwrap dbus-fast Variants in nested DBus structures.""" - if isinstance(obj, Variant): - return _unwrap_variants(obj.value) - if isinstance(obj, dict): - return {key: _unwrap_variants(value) for key, value in obj.items()} - if isinstance(obj, list): - return [_unwrap_variants(value) for value in obj] - if isinstance(obj, tuple): - return tuple(_unwrap_variants(value) for value in obj) - return obj - - -async def get_monitor_geometries_kscreen(bus: MessageBus) -> list[dict]: - """ - Get monitor geometry information from KDE KScreen DBus. - - Returns: - List of dicts with format: - [{"id": "connector-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...] - """ - try: - from .monitor_positions import assign_monitor_positions - - intro = await bus.introspect(KSCREEN_BUS, KSCREEN_PATH) - obj = bus.get_proxy_object(KSCREEN_BUS, KSCREEN_PATH, intro) - iface = obj.get_interface(KSCREEN_IFACE) - config = _unwrap_variants(await iface.call_get_config()) - outputs = config.get("outputs", {}) - output_values = outputs.values() if isinstance(outputs, dict) else outputs - - geometries = [] - for output in output_values: - if not isinstance(output, dict): - continue - if not output.get("enabled") or not output.get("connected"): - continue - - name = output.get("name") - pos = output.get("pos", {}) - size = output.get("size", {}) - if not isinstance(name, str) or not isinstance(pos, dict): - continue - if not isinstance(size, dict): - continue - - x = int(pos.get("x", 0)) - y = int(pos.get("y", 0)) - scale = float(output.get("scale", 1.0) or 1.0) - width = int(size.get("width", 0)) - height = int(size.get("height", 0)) - logical_width = round(width / scale) - logical_height = round(height / scale) - geometries.append( - { - "id": name, - "box": [x, y, x + logical_width, y + logical_height], - } - ) - - monitors = assign_monitor_positions(geometries) - logger.debug("KScreen monitor geometries found: %d", len(monitors)) - return monitors - except ( - DBusError, - InvalidMemberNameError, - InvalidIntrospectionError, - OSError, - ) as exc: - if not _is_service_missing(exc): - logger.warning( - "get_monitor_geometries_kscreen failed: service=%s path=%s: %s: %s", - KSCREEN_BUS, - KSCREEN_PATH, - type(exc).__name__, - exc, - ) - return [] diff --git a/src/solstone_linux/audio_detect.py b/src/solstone_linux/audio_detect.py deleted file mode 100644 index 422ebc7..0000000 --- a/src/solstone_linux/audio_detect.py +++ /dev/null @@ -1,66 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Audio device detection. - -Changes from monorepo version: -- Uses structural soundcard isloopback metadata instead of amplitude-thresholding - on a played tone, so muted sinks and silent rooms no longer fail detection. -""" - -import logging -import threading -import time - -import soundcard as sc - -logger = logging.getLogger(__name__) - - -def input_detect(timeout=3.0): - try: - # Fully wedged PulseAudio enumeration is a pre-existing out-of-scope hang. - devices = sc.all_microphones(include_loopback=True) - except Exception: - logger.warning("Failed to enumerate audio devices") - return None, None - if not devices: - logger.warning("No audio devices found") - return None, None - - results = {} - lock = threading.Lock() - - def classify(index, mic): - try: - is_loopback = bool(mic.isloopback) - except Exception: - is_loopback = None - with lock: - results[index] = is_loopback - - threads = [] - deadline = time.monotonic() + timeout - for index, mic in enumerate(devices): - thread = threading.Thread(target=classify, args=(index, mic), daemon=True) - thread.start() - threads.append(thread) - - for thread in threads: - remaining = max(0.0, deadline - time.monotonic()) - thread.join(timeout=remaining) - - with lock: - final_results = dict(results) - - mic_detected = None - loopback_detected = None - for index, mic in enumerate(devices): - is_loopback = final_results.get(index) - if is_loopback is None: - continue - if is_loopback and loopback_detected is None: - loopback_detected = mic - elif not is_loopback and mic_detected is None: - mic_detected = mic - return mic_detected, loopback_detected diff --git a/src/solstone_linux/audio_mute.py b/src/solstone_linux/audio_mute.py deleted file mode 100644 index 4ee853e..0000000 --- a/src/solstone_linux/audio_mute.py +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Linux audio mute detection using PulseAudio/PipeWire. - -Direct copy from solstone's observe/linux/audio.py — no solstone imports. -""" - -import asyncio -import logging - -logger = logging.getLogger(__name__) - - -async def is_sink_muted() -> bool: - """ - Check if the default audio sink is muted using PulseAudio. - - Uses `pactl get-sink-mute @DEFAULT_SINK@` to query mute status. - - Returns: - True if muted, False otherwise (including on error). - """ - try: - proc = await asyncio.create_subprocess_exec( - "pactl", - "get-sink-mute", - "@DEFAULT_SINK@", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - stderr_text = stderr.decode().strip() if stderr else "" - logger.warning(f"pactl failed (rc={proc.returncode}): {stderr_text}") - return False - - output = stdout.decode().strip() - return "Mute: yes" in output - - except FileNotFoundError: - logger.warning("pactl not found, assuming unmuted") - return False - except Exception as e: - logger.warning(f"Error checking sink mute status: {e}") - return False diff --git a/src/solstone_linux/audio_recorder.py b/src/solstone_linux/audio_recorder.py deleted file mode 100644 index 6ba2305..0000000 --- a/src/solstone_linux/audio_recorder.py +++ /dev/null @@ -1,241 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Audio recording for Linux desktop observer. - -Extracted from solstone's observe/hear.py — AudioRecorder class only. -load_transcript() and format_audio() remain in solstone core (used by 15+ files). - -Changes from monorepo version: -- Replaces `from observe.detect import input_detect` with local audio_detect -- Replaces conditional `think.callosum` import with local logging -- Defines SAMPLE_RATE locally (was from observe.utils) -""" - -from __future__ import annotations - -import gc -import io -import logging -import os -import signal -import threading -import time -from queue import Queue - -import numpy as np -import soundfile as sf - -logger = logging.getLogger(__name__) - -# Standard sample rate for audio processing -SAMPLE_RATE = 16000 -BLOCK_SIZE = 1024 -MAX_CONSECUTIVE_FAILURES = 3 -REDETECT_INTERVAL = 5 - - -class AudioRecorder: - """Records stereo audio from microphone and system audio.""" - - def __init__(self): - # Queue holds stereo chunks (mic=left, sys=right) - self.audio_queue = Queue() - self._running = True - self.recording_thread = None - self.fatal_error: str | None = None - self.audio_available = True - self.mic_device = None - self.sys_device = None - self._consecutive_failures = 0 - - def _set_audio_available(self, available: bool): - """Set degraded state with edge-triggered logging.""" - if self.audio_available == available: - return - self.audio_available = available - if available: - logger.info("Audio devices recovered — resuming audio capture") - else: - logger.warning( - "Audio devices unavailable — continuing with screen capture only" - ) - - def detect(self): - """Detect microphone and system audio devices.""" - from .audio_detect import input_detect - - mic, loopback = input_detect() - if mic is None or loopback is None: - # Partial availability is degraded, not mono capture; mono capture is future work. - self._set_audio_available(False) - return False - self.mic_device = mic - self.sys_device = loopback - self._consecutive_failures = 0 - # Use id instead of name: soundcard name performs a fresh metadata query. - logger.info(f"Detected microphone: {mic.id}") - logger.info(f"Detected system audio: {loopback.id}") - self._set_audio_available(True) - return True - - def _sleep_interruptibly(self, seconds: float): - """Sleep in short steps so stop_recording can interrupt re-detect waits.""" - deadline = time.monotonic() + seconds - while self._running: - remaining = deadline - time.monotonic() - if remaining <= 0: - return - time.sleep(min(1.0, remaining)) - - def record_both(self): - """Record from both mic and system audio in a loop.""" - while self._running: - if not self.audio_available: - self.detect() - if not self.audio_available: - self._sleep_interruptibly(REDETECT_INTERVAL) - continue - - if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: - logger.info( - "Re-detecting audio devices after %d consecutive recorder failures", - self._consecutive_failures, - ) - self._consecutive_failures = 0 - self.detect() - continue - - try: - with ( - self.mic_device.recorder( - samplerate=SAMPLE_RATE, channels=[-1], blocksize=BLOCK_SIZE - ) as mic_rec, - self.sys_device.recorder( - samplerate=SAMPLE_RATE, channels=[-1], blocksize=BLOCK_SIZE - ) as sys_rec, - ): - block_count = 0 - while self._running and block_count < 1000: - try: - mic_chunk = mic_rec.record(numframes=BLOCK_SIZE) - sys_chunk = sys_rec.record(numframes=BLOCK_SIZE) - - # Basic validation - if mic_chunk is None or mic_chunk.size == 0: - logger.warning("Empty microphone buffer") - # Empty buffers are intentionally not recorder failures. - continue - if sys_chunk is None or sys_chunk.size == 0: - logger.warning("Empty system buffer") - # Empty buffers are intentionally not recorder failures. - continue - - try: - stereo_chunk = np.column_stack((mic_chunk, sys_chunk)) - self.audio_queue.put(stereo_chunk) - block_count += 1 - self._consecutive_failures = 0 - except (TypeError, ValueError, AttributeError) as e: - error_msg = f"Fatal audio format error: {e}" - logger.error( - f"{error_msg} - triggering clean shutdown\n" - f" mic_chunk type={type(mic_chunk)}, " - f"shape={getattr(mic_chunk, 'shape', 'N/A')}, " - f"dtype={getattr(mic_chunk, 'dtype', 'N/A')}\n" - f" sys_chunk type={type(sys_chunk)}, " - f"shape={getattr(sys_chunk, 'shape', 'N/A')}, " - f"dtype={getattr(sys_chunk, 'dtype', 'N/A')}" - ) - # Stop recording thread and trigger shutdown - self.fatal_error = error_msg - self._running = False - os.kill(os.getpid(), signal.SIGTERM) - return - except Exception as e: - logger.error(f"Error recording audio: {e}") - self._consecutive_failures += 1 - if not self._running: - break - if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: - break - time.sleep(0.5) - del mic_rec, sys_rec - gc.collect() - except Exception as e: - logger.error(f"Error setting up recorders: {e}") - self._consecutive_failures += 1 - if self._running: - self._sleep_interruptibly(1) - - def get_buffers(self) -> np.ndarray: - """Return concatenated stereo audio data from the queue.""" - stereo_buffer = np.array([], dtype=np.float32).reshape(0, 2) - - while not self.audio_queue.empty(): - stereo_chunk = self.audio_queue.get() - - if stereo_chunk is None or stereo_chunk.size == 0: - logger.warning("Queue contained empty chunk") - continue - - # Clean the data - stereo_chunk = np.nan_to_num( - stereo_chunk, nan=0.0, posinf=1e10, neginf=-1e10 - ) - stereo_buffer = np.vstack((stereo_buffer, stereo_chunk)) - - if stereo_buffer.size == 0: - logger.warning("No valid audio data retrieved from queue") - - return stereo_buffer - - def create_flac_bytes(self, stereo_data: np.ndarray) -> bytes: - """Create FLAC bytes from stereo audio data.""" - if stereo_data is None or stereo_data.size == 0: - logger.warning("Audio data is empty. Returning empty bytes.") - return b"" - - audio_data = (np.clip(stereo_data, -1.0, 1.0) * 32767).astype(np.int16) - - buf = io.BytesIO() - try: - sf.write(buf, audio_data, SAMPLE_RATE, format="FLAC") - except Exception as e: - logger.error( - f"Error creating FLAC: {e}. Audio data shape: {audio_data.shape}, dtype: {audio_data.dtype}" - ) - return b"" - - return buf.getvalue() - - def create_mono_flac_bytes(self, mono_data: np.ndarray) -> bytes: - """Create FLAC bytes from mono audio data.""" - if mono_data is None or mono_data.size == 0: - logger.warning("Mono audio data is empty. Returning empty bytes.") - return b"" - - audio_data = (np.clip(mono_data, -1.0, 1.0) * 32767).astype(np.int16) - - buf = io.BytesIO() - try: - sf.write(buf, audio_data, SAMPLE_RATE, format="FLAC") - except Exception as e: - logger.error( - f"Error creating mono FLAC: {e}. Audio shape: {audio_data.shape}" - ) - return b"" - - return buf.getvalue() - - def start_recording(self): - """Start the recording thread.""" - self._running = True - self.recording_thread = threading.Thread(target=self.record_both, daemon=True) - self.recording_thread.start() - - def stop_recording(self): - """Stop the recording thread.""" - self._running = False - if self.recording_thread: - self.recording_thread.join(timeout=2.0) diff --git a/src/solstone_linux/capture_stats.py b/src/solstone_linux/capture_stats.py deleted file mode 100644 index 413aec1..0000000 --- a/src/solstone_linux/capture_stats.py +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Shared capture cache statistics.""" - -from pathlib import Path -import time - - -def compute_capture_stats(captures_dir: Path, today: str) -> dict[str, int]: - captures_today = 0 - total_size = 0 - - try: - if captures_dir.exists(): - for day_dir in captures_dir.iterdir(): - if not day_dir.is_dir(): - continue - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - for seg_dir in stream_dir.iterdir(): - if not seg_dir.is_dir(): - continue - if seg_dir.name.endswith(".incomplete"): - continue - if seg_dir.name.endswith(".failed"): - continue - if day_dir.name == today: - captures_today += 1 - for file_path in seg_dir.iterdir(): - if file_path.is_file(): - total_size += file_path.stat().st_size - except OSError: - pass - - return { - "captures_today": captures_today, - "total_size_mb": int(total_size / (1024 * 1024)), - } - - -def compute_quarantine_stats(captures_dir: Path, now: float | None = None) -> dict: - """Count quarantined (.failed) segments and the oldest quarantine-entry age. - - Both name shapes (HHMMSS_DDD.failed and bare HHMMSS.failed) count. - Returns {"count": int, "oldest_age_seconds": float | None}. - """ - if now is None: - now = time.time() - count = 0 - oldest_mtime: float | None = None - try: - if captures_dir.exists(): - for day_dir in captures_dir.iterdir(): - if not day_dir.is_dir(): - continue - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - for seg_dir in stream_dir.iterdir(): - if not seg_dir.is_dir(): - continue - if not seg_dir.name.endswith(".failed"): - continue - count += 1 - try: - mtime = seg_dir.stat().st_mtime - except OSError: - continue - if oldest_mtime is None or mtime < oldest_mtime: - oldest_mtime = mtime - except OSError: - pass - oldest_age = None if oldest_mtime is None else max(0.0, now - oldest_mtime) - return {"count": count, "oldest_age_seconds": oldest_age} - - -def format_quarantine_line(stats: dict) -> str | None: - """One-line quarantine summary (count + oldest age), or None when empty.""" - count = stats.get("count", 0) - if not count: - return None - oldest = stats.get("oldest_age_seconds") - if oldest is None: - return f"Quarantine: {count} rejected segment(s) held" - days = int(oldest // 86400) - return f"Quarantine: {count} rejected segment(s) held, oldest {days}d" diff --git a/src/solstone_linux/chat_bridge.py b/src/solstone_linux/chat_bridge.py deleted file mode 100644 index 34161db..0000000 --- a/src/solstone_linux/chat_bridge.py +++ /dev/null @@ -1,515 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Bridge server-initiated chat events into local notification surfaces. - -The bridge consumes callosum SSE frames, mirrors requests into an optional FIFO, -and fires click-capturing desktop notifications when the server opt-in allows it. -""" - -from __future__ import annotations - -import asyncio -import errno -import json -import logging -import os -import stat -import subprocess -import threading -import time -from collections import OrderedDict -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any - -import requests - -from .config import Config - -logger = logging.getLogger(__name__) - -# Keep these event names and owner-facing copy hand-synced with -# solstone/convey/sol_initiated/copy.py; this repo does not vendor that canon. -EVENT_SOL_CHAT_REQUEST = "sol_chat_request" -EVENT_SOL_CHAT_REQUEST_SUPERSEDED = "sol_chat_request_superseded" -EVENT_OWNER_CHAT_OPEN = "owner_chat_open" -EVENT_OWNER_CHAT_DISMISSED = "owner_chat_dismissed" - -NOTIFY_TITLE = "sol" -SURFACE = "linux" -FIFO_PATH = Path.home() / ".solstone" / "notify" -_HANDLED_EVENTS = frozenset( - { - EVENT_SOL_CHAT_REQUEST, - EVENT_SOL_CHAT_REQUEST_SUPERSEDED, - EVENT_OWNER_CHAT_OPEN, - EVENT_OWNER_CHAT_DISMISSED, - } -) -RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30] -HEARTBEAT_STALE_SECONDS = 60 -SSE_CONNECT_TIMEOUT_SECONDS = 10 -# Read must outlast the soft-stale window; deriving it keeps the invariant fixed. -SSE_READ_TIMEOUT_SECONDS = HEARTBEAT_STALE_SECONDS + 30 -NOTIFY_ACTION_KEY = "open" -# A body that ran at least this long before crashing resets the restart ladder. -HEALTHY_RUN_SECONDS = 60 -OPT_IN_POLL_SECONDS = 300 -PENDING_CAP = 32 - - -@dataclass -class PendingRequest: - request_id: str - summary: str - chat_url: str - notify_task: asyncio.Task | None = None - - -class _SseParser: - def __init__(self) -> None: - self._event: str | None = None - self._data: list[str] = [] - self._id: str | None = None - - def feed_line(self, line: str) -> dict[str, str | None] | None: - line = line.rstrip("\r\n") - if line == "": - if not self._data: - self._event = None - self._id = None - return None - frame = { - "event": self._event, - "data": "\n".join(self._data), - "id": self._id, - } - self._event = None - self._data = [] - self._id = None - return frame - - if line.startswith(":"): - return None - - field, sep, value = line.partition(":") - if sep and value.startswith(" "): - value = value[1:] - - if field == "data": - self._data.append(value) - elif field == "event": - self._event = value - elif field == "id": - self._id = value - - return None - - -def _auth_headers(key: str) -> dict[str, str]: - return {"Authorization": f"Bearer {key}"} - - -def _write_fifo(line: str, path: Path = FIFO_PATH) -> None: - try: - if not path.exists(): - logger.debug("Chat bridge FIFO missing: %s", path) - return - if not stat.S_ISFIFO(path.stat().st_mode): - logger.debug("Chat bridge path is not a FIFO: %s", path) - return - - fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK) - try: - os.write(fd, line.encode("utf-8")) - finally: - os.close(fd) - except FileNotFoundError: - logger.debug("Chat bridge FIFO missing: %s", path) - except BlockingIOError: - logger.debug("Chat bridge FIFO has no reader: %s", path) - except OSError as e: - if e.errno in (errno.ENXIO, errno.EAGAIN, errno.EWOULDBLOCK): - logger.debug("Chat bridge FIFO unavailable: %s", e) - return - logger.warning("Chat bridge FIFO write failed: %s", e) - - -def _push_frame( - queue: asyncio.Queue, - loop: asyncio.AbstractEventLoop, - frame: dict[str, Any], -) -> None: - loop.call_soon_threadsafe(queue.put_nowait, frame) - - -def _sse_worker( - url: str, - key: str, - queue: asyncio.Queue, - loop: asyncio.AbstractEventLoop, - stop_event: threading.Event, -) -> None: - parser = _SseParser() - try: - response = requests.get( - url, - stream=True, - headers=_auth_headers(key), - timeout=(SSE_CONNECT_TIMEOUT_SECONDS, SSE_READ_TIMEOUT_SECONDS), - ) - if response.status_code in (401, 403): - _push_frame( - queue, loop, {"_terminal": True, "status": response.status_code} - ) - return - if response.status_code != 200: - _push_frame( - queue, - loop, - { - "_transport_error": True, - "error": f"status {response.status_code}", - }, - ) - return - - for raw_line in response.iter_lines(decode_unicode=True): - if stop_event.is_set(): - return - if raw_line is None: - continue - line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line - if line.startswith(":"): - _push_frame(queue, loop, {"_heartbeat": True}) - frame = parser.feed_line(line) - if frame is not None: - _push_frame(queue, loop, frame) - except requests.RequestException as e: - _push_frame(queue, loop, {"_transport_error": True, "error": str(e)}) - - -async def _poll_opt_in(server_url: str, key: str) -> bool: - url = f"{server_url.rstrip('/')}/api/sol_voice" - - try: - response = await asyncio.to_thread( - requests.get, - url, - headers=_auth_headers(key), - timeout=10, - ) - if response.status_code != 200: - return False - data = response.json() - except (requests.RequestException, ValueError, TypeError): - return False - - return bool(data.get("linux_notify_send", False)) - - -def _chat_url(server_url: str, day: str | None, event_index: int | None) -> str: - base = server_url.rstrip("/") - if day and event_index is not None: - return f"{base}/app/chat/{day}#event-{event_index}" - today = datetime.now().strftime("%Y%m%d") - return f"{base}/app/chat/{today}" - - -async def _handle_one_notification( - req: PendingRequest, server_url: str, key: str -) -> None: - proc = await asyncio.create_subprocess_exec( - "notify-send", - "--wait", - "--app-name", - "sol", - f"--action={NOTIFY_ACTION_KEY}=Open", - NOTIFY_TITLE, - req.summary, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - try: - stdout, _ = await proc.communicate() - except asyncio.CancelledError: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=1) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - raise - - if proc.returncode != 0: - logger.debug("notify-send exited with status %s", proc.returncode) - return - - action = stdout.decode("utf-8", "replace").strip() if stdout else "" - if action != NOTIFY_ACTION_KEY: - logger.debug("notify-send dismissed without action") - return - - logger.info("Opening chat request: %s", req.request_id) - url = f"{server_url.rstrip('/')}/api/chat/{EVENT_SOL_CHAT_REQUEST}/open" - try: - response = await asyncio.to_thread( - requests.post, - url, - json={"request_id": req.request_id}, - headers=_auth_headers(key), - timeout=10, - ) - if response.status_code >= 400: - logger.debug("Chat open ack failed: status %s", response.status_code) - except requests.RequestException as e: - logger.debug("Chat open ack failed: %s", e) - - try: - subprocess.Popen( - ["xdg-open", req.chat_url], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError as e: - logger.debug("xdg-open failed: %s", e) - - -async def _opt_in_poll_loop(server_url: str, key: str, state: dict[str, bool]) -> None: - while True: - state["value"] = await _poll_opt_in(server_url, key) - await asyncio.sleep(OPT_IN_POLL_SECONDS) - - -def _cancel_pending_task(task: asyncio.Task | None) -> None: - if task is not None and not task.done(): - task.cancel() - - -def _enforce_pending_cap(pending: OrderedDict[str, PendingRequest]) -> None: - while len(pending) > PENDING_CAP: - request_id, old_req = pending.popitem(last=False) - _cancel_pending_task(old_req.notify_task) - logger.debug("Evicted pending chat request: %s", request_id) - - -def _mark_stale_if_needed( - last_frame_at: float, is_stale: bool, stale_logged: bool -) -> tuple[bool, bool]: - if time.monotonic() - last_frame_at > HEARTBEAT_STALE_SECONDS and not is_stale: - logger.warning("Chat bridge heartbeat stale") - return True, True - return is_stale, stale_logged - - -def _mark_live_frame(is_stale: bool, stale_logged: bool) -> tuple[bool, bool]: - if is_stale: - if stale_logged: - logger.info("Chat bridge heartbeat recovered") - return False, False - return is_stale, stale_logged - - -async def _dispatch_event( - payload: dict[str, Any], - pending: OrderedDict[str, PendingRequest], - opt_in: bool, - is_stale: bool, - config: Config, -) -> None: - if payload.get("tract") != "chat": - return - - event = payload.get("event") - if event not in _HANDLED_EVENTS: - return - - request_id = payload.get("request_id") - if not request_id: - logger.debug("Chat event missing request_id: %s", event) - return - request_id = str(request_id) - - if event == EVENT_SOL_CHAT_REQUEST: - summary = str(payload.get("summary") or "") - _write_fifo(f"sol-ping {request_id} {summary}\n") - - old_req = pending.pop(request_id, None) - if old_req is not None: - _cancel_pending_task(old_req.notify_task) - - if opt_in and not is_stale: - event_index = payload.get("event_index") - if not isinstance(event_index, int): - event_index = None - req = PendingRequest( - request_id=request_id, - summary=summary, - chat_url=_chat_url(config.server_url, payload.get("day"), event_index), - ) - req.notify_task = asyncio.create_task( - _handle_one_notification(req, config.server_url, config.key) - ) - pending[request_id] = req - _enforce_pending_cap(pending) - return - - if event in ( - EVENT_SOL_CHAT_REQUEST_SUPERSEDED, - EVENT_OWNER_CHAT_OPEN, - EVENT_OWNER_CHAT_DISMISSED, - ): - old_req = pending.pop(request_id, None) - if old_req is not None: - _cancel_pending_task(old_req.notify_task) - _write_fifo(f"clear {request_id}\n") - - -async def _cancel_pending_notifications( - pending: OrderedDict[str, PendingRequest], -) -> None: - tasks = [req.notify_task for req in pending.values() if req.notify_task is not None] - for task in tasks: - _cancel_pending_task(task) - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - pending.clear() - - -async def _await_worker(worker_task: asyncio.Task | None) -> None: - if worker_task is None: - return - try: - await asyncio.wait_for(worker_task, timeout=1) - except asyncio.TimeoutError: - worker_task.cancel() - await asyncio.gather(worker_task, return_exceptions=True) - - -async def _sleep_reconnect(delay: int, stop_event: asyncio.Event) -> None: - if not stop_event.is_set(): - await asyncio.sleep(delay) - - -async def _run_bridge_body(config: Config, stop_event: asyncio.Event) -> None: - server_url = config.server_url.rstrip("/") - key = config.key - sse_url = f"{server_url}/app/observer/callosum" - pending: OrderedDict[str, PendingRequest] = OrderedDict() - opt_in_state = {"value": False} - opt_in_task = asyncio.create_task(_opt_in_poll_loop(server_url, key, opt_in_state)) - reconnect_index = 0 - is_stale = False - stale_logged = False - worker_task: asyncio.Task | None = None - thread_stop: threading.Event | None = None - - try: - while not stop_event.is_set(): - queue: asyncio.Queue = asyncio.Queue() - thread_stop = threading.Event() - loop = asyncio.get_running_loop() - worker_task = asyncio.create_task( - asyncio.to_thread(_sse_worker, sse_url, key, queue, loop, thread_stop) - ) - last_frame_at = time.monotonic() - reconnect = False - - while not stop_event.is_set(): - try: - frame = await asyncio.wait_for(queue.get(), timeout=5) - except asyncio.TimeoutError: - is_stale, stale_logged = _mark_stale_if_needed( - last_frame_at, is_stale, stale_logged - ) - if worker_task.done(): - reconnect = True - break - continue - - if frame.get("_terminal"): - logger.error( - "Chat bridge SSE authorization failed: status %s", - frame.get("status"), - ) - thread_stop.set() - return - - if frame.get("_transport_error"): - logger.debug("Chat bridge transport error: %s", frame.get("error")) - reconnect = True - break - - last_frame_at = time.monotonic() - reconnect_index = 0 - is_stale, stale_logged = _mark_live_frame(is_stale, stale_logged) - - if frame.get("_heartbeat"): - continue - - data = frame.get("data") - if not isinstance(data, str): - continue - try: - payload = json.loads(data) - except json.JSONDecodeError as e: - logger.debug("Chat bridge frame JSON decode failed: %s", e) - continue - if not isinstance(payload, dict): - continue - await _dispatch_event( - payload, - pending, - opt_in_state["value"], - is_stale, - config, - ) - - if thread_stop: - thread_stop.set() - await _await_worker(worker_task) - worker_task = None - if stop_event.is_set(): - break - if reconnect: - delay = RECONNECT_DELAYS[ - min(reconnect_index, len(RECONNECT_DELAYS) - 1) - ] - reconnect_index += 1 - logger.info("Chat bridge reconnecting in %ss", delay) - await _sleep_reconnect(delay, stop_event) - finally: - if thread_stop: - thread_stop.set() - opt_in_task.cancel() - await asyncio.gather(opt_in_task, return_exceptions=True) - await _cancel_pending_notifications(pending) - await _await_worker(worker_task) - - -async def run_chat_bridge(config: Config, stop_event: asyncio.Event) -> None: - if not config.chat_bridge_enabled: - return - if not config.server_url or not config.key: - logger.debug("Chat bridge disabled: server_url or key missing") - return - - supervise_index = 0 - while not stop_event.is_set(): - started = time.monotonic() - try: - await _run_bridge_body(config, stop_event) - return - except Exception as e: - logger.error("Chat bridge crashed: %s", e, exc_info=True) - if stop_event.is_set(): - break - ran_for = time.monotonic() - started - if ran_for >= HEALTHY_RUN_SECONDS: - supervise_index = 0 - delay = RECONNECT_DELAYS[min(supervise_index, len(RECONNECT_DELAYS) - 1)] - supervise_index += 1 - logger.info("Chat bridge restarting in %ss", delay) - await _sleep_reconnect(delay, stop_event) diff --git a/src/solstone_linux/cli.py b/src/solstone_linux/cli.py deleted file mode 100644 index 55e7ec9..0000000 --- a/src/solstone_linux/cli.py +++ /dev/null @@ -1,565 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI entry point for solstone-linux. - -Subcommands: - run Start capture loop + sync service (default) - setup Interactive configuration - doctor Verify install prerequisites - settings Edit capture/behavior settings - install-service Write systemd user unit, enable, start - status Show capture and sync state -""" - -from __future__ import annotations - -import argparse -import asyncio -import importlib.resources -import logging -import os -import shutil -import socket -import subprocess -import sys -import time -from pathlib import Path - -from . import __version__, doctor, streams -from .config import DEFAULT_SERVER_URL, load_config, save_config -from .capture_stats import compute_quarantine_stats, format_quarantine_line -from .streams import stream_name -from .sync_health import derive_health, load_facts - - -def _setup_logging(verbose: bool = False) -> None: - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", - ) - - -def _prompt_bool(label: str, current: bool) -> bool: - while True: - value = input(f"{label} [{'y' if current else 'n'}]: ").strip().lower() - if value == "": - return current - if value in ("y", "yes"): - return True - if value in ("n", "no"): - return False - print("Enter y or n.") - - -def _prompt_positive_int(label: str, current: int) -> int: - while True: - value = input(f"{label} [{current}]: ").strip() - if value == "": - return current - try: - parsed = int(value) - except ValueError: - print("Enter a positive integer.") - continue - if parsed > 0: - return parsed - print("Enter a positive integer.") - - -def _prompt_framerate(current: int) -> int: - while True: - value = input(f"Framerate [{current}]: ").strip() - if value == "": - return current - try: - parsed = int(value) - except ValueError: - print("Enter an integer.") - continue - clamped = max(1, min(parsed, 10)) - if clamped != parsed: - print(f"(clamped to {clamped})") - return clamped - - -def _prompt_retention(current: int) -> int: - while True: - value = input( - "Cache retention days (-1 = keep forever, " - "0 = delete synced segments after the day ends, " - f"N = keep N days) [{current}]: " - ).strip() - if value == "": - return current - try: - return int(value) - except ValueError: - print("Enter an integer.") - - -def cmd_run(args: argparse.Namespace) -> int: - """Start the capture loop + sync service.""" - from .observer import async_run - - config = load_config() - config.ensure_dirs() - - if not config.stream: - try: - config.stream = stream_name(host=socket.gethostname()) - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - - interval = getattr(args, "interval", None) - if interval: - config.segment_interval = interval - - try: - return asyncio.run(async_run(config)) - except KeyboardInterrupt: - return 0 - - -def cmd_setup(args: argparse.Namespace) -> int: - """Interactive setup — configure server URL and register.""" - cli_token = args.token if getattr(args, "token", None) else None - env_token = os.environ.get("SOLSTONE_TOKEN") - token = cli_token or env_token - non_interactive = getattr(args, "non_interactive", False) - - if ( - cli_token is None - and env_token is None - and getattr(args, "server_url", None) is None - and getattr(args, "stream_name", None) is None - and not non_interactive - ): - return _cmd_setup_interactive() - - if cli_token: - print( - "warning: --token on the command line may be visible in shell history and /proc on shared computers", - file=sys.stderr, - ) - - from .upload import UploadClient - - config = load_config() - - # Resolve the journal URL: an explicit --server-url wins, then any saved - # URL, otherwise the local link default. Under pure-PL the journal is - # reached over the localhost link, so no URL needs to be typed. - server_url = ( - getattr(args, "server_url", None) or config.server_url or DEFAULT_SERVER_URL - ) - config.server_url = server_url - - stream_override = getattr(args, "stream_name", None) - if stream_override: - config.stream = stream_override - elif not config.stream: - try: - config.stream = streams.stream_name(host=socket.gethostname()) - except ValueError as e: - print(f"Error deriving stream name: {e}", file=sys.stderr) - return 1 - - config.ensure_dirs() - - if token: - config.key = token - save_config(config) - print(f"Journal: {config.server_url}") - print(f"Stream: {config.stream}") - print("Using provided token; skipping registration.") - print(f"\nConfig saved to {config.config_path}") - print(f"segments are kept in {config.captures_dir}") - print( - "\nRun 'solstone-linux run' to start, or 'solstone-linux install-service' for systemd." - ) - return 0 - - save_config(config) - - if not config.key: - print("Registering with your journal...") - client = UploadClient(config) - if client.ensure_registered(config): - print(f"Registered (key: {config.key[:8]}...)") - print(f"Stream: {config.stream}") - else: - print( - "Warning: registration failed. Run setup again when your journal is available." - ) - if non_interactive: - return 1 - else: - print(f"Already registered (key: {config.key[:8]}...)") - print(f"Stream: {config.stream}") - - print(f"\nConfig saved to {config.config_path}") - print(f"segments are kept in {config.captures_dir}") - print( - "\nRun 'solstone-linux run' to start, or 'solstone-linux install-service' for systemd." - ) - return 0 - - -def _cmd_setup_interactive() -> int: - # Keep the legacy no-flags setup path separate so its output stays stable. - from .upload import UploadClient - - config = load_config() - - # No prompt: default to the local link. Under pure-PL the journal is reached - # over the localhost link, so no URL needs to be typed; a saved URL (or - # `solstone-linux setup --server-url `) points at a journal reached - # directly. - config.server_url = config.server_url or DEFAULT_SERVER_URL - - # Derive stream name - if not config.stream: - try: - config.stream = stream_name(host=socket.gethostname()) - except ValueError as e: - print(f"Error deriving stream name: {e}", file=sys.stderr) - return 1 - - # Save config before registration (so URL is persisted) - config.ensure_dirs() - save_config(config) - - if not config.key: - print("Registering with your journal...") - client = UploadClient(config) - if client.ensure_registered(config): - print(f"Registered (key: {config.key[:8]}...)") - print(f"Stream: {config.stream}") - else: - print( - "Warning: registration failed. Run setup again when your journal is available." - ) - else: - print(f"Already registered (key: {config.key[:8]}...)") - print(f"Stream: {config.stream}") - - print(f"\nConfig saved to {config.config_path}") - print(f"segments are kept in {config.captures_dir}") - print( - "\nRun 'solstone-linux run' to start, or 'solstone-linux install-service' for systemd." - ) - return 0 - - -def cmd_settings(args: argparse.Namespace) -> int: - config = load_config() - config.capture_framerate = _prompt_framerate(config.capture_framerate) - config.draw_cursor = _prompt_bool("Draw cursor", config.draw_cursor) - config.start_paused = _prompt_bool("Start paused", config.start_paused) - config.segment_interval = _prompt_positive_int( - "Segment interval seconds", config.segment_interval - ) - config.chat_bridge_enabled = _prompt_bool( - "Chat bridge enabled", config.chat_bridge_enabled - ) - config.cache_retention_days = _prompt_retention(config.cache_retention_days) - save_config(config) - print(f"\nSettings saved to {config.config_path}") - return 0 - - -def cmd_doctor(args: argparse.Namespace) -> int: - return doctor.run_doctor() - - -def cmd_install_service(args: argparse.Namespace) -> int: - """Write systemd user unit file, enable, and start the service.""" - binary = shutil.which("solstone-linux") - if not binary: - print("Error: solstone-linux not found on PATH", file=sys.stderr) - print( - "Install with: pipx install --system-site-packages solstone-linux", - file=sys.stderr, - ) - return 1 - - venv_bin = str(Path(binary).resolve().parent) - raw_path = os.environ.get("PATH") or "/usr/local/bin:/usr/bin:/bin" - path_entries = [venv_bin] + raw_path.split(":") - service_path = ":".join(dict.fromkeys(path_entries)) - - unit_dir = Path.home() / ".config" / "systemd" / "user" - unit_path = unit_dir / "solstone-linux.service" - template = ( - importlib.resources.files("solstone_linux") - .joinpath("solstone-linux.service.in") - .read_text() - ) - unit = template.replace("{BINARY}", binary).replace("{PATH}", service_path) - unit_dir.mkdir(parents=True, exist_ok=True) - unit_path.write_text(unit) - print(f"Wrote {unit_path}") - - # XDG autostart entry — X11 session managers that don't activate - # graphical-session.target (the systemd unit's WantedBy target) need this - # to autostart the service. On Wayland, `start` is a no-op when the - # service is already running. - autostart_dir = Path.home() / ".config" / "autostart" - autostart_dir.mkdir(parents=True, exist_ok=True) - autostart_path = autostart_dir / "solstone-linux.desktop" - autostart_path.write_text( - "[Desktop Entry]\n" - "Version=1.2\n" - "Type=Application\n" - "Name=sol\n" - "Comment=sol takes in your screen and audio and keeps it in your journal\n" - "Exec=/bin/sh -c 'systemctl --user import-environment" - " DISPLAY XAUTHORITY XDG_SESSION_TYPE 2>/dev/null;" - " systemctl --user start solstone-linux.service'\n" - "Icon=solstone-observer\n" - "StartupNotify=false\n" - "X-GNOME-Autostart-enabled=true\n" - "Hidden=false\n" - ) - print(f"Wrote {autostart_path}") - - # Reload, enable, restart, and show status - try: - subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) - subprocess.run( - ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], - check=True, - ) - subprocess.run( - ["systemctl", "--user", "restart", "solstone-linux.service"], - check=True, - ) - subprocess.run( - [ - "systemctl", - "--user", - "--no-pager", - "status", - "solstone-linux.service", - ], - check=False, - ) - except FileNotFoundError: - print("Warning: systemctl not found. Enable the service manually.") - except subprocess.CalledProcessError as e: - print(f"Warning: systemctl command failed: {e}") - - icon_source = Path(__file__).resolve().parent / "icons" / "hicolor" - if icon_source.is_dir(): - icon_dest = Path.home() / ".local" / "share" / "icons" / "hicolor" - status_dir = icon_dest / "scalable" / "status" - status_dir.mkdir(parents=True, exist_ok=True) - - for svg in sorted((icon_source / "scalable" / "status").iterdir()): - if svg.suffix == ".svg": - shutil.copy2(svg, status_dir / svg.name) - print(f"Installed {status_dir / svg.name}") - - # Application icon — the unified sol app icon (wordmark, transparent - # ground), installed into the hicolor *apps* context under the name - # "solstone-observer" (matches the SNI app_id and the .desktop Icon=). - # Mirrors every freedesktop size dir the repo ships (PNG ladder + - # scalable SVG). Distinct from the status/ tray icons above. - for ctx_dir in sorted(icon_source.glob("*/apps")): - dest_ctx = icon_dest / ctx_dir.parent.name / "apps" - dest_ctx.mkdir(parents=True, exist_ok=True) - for asset in sorted(ctx_dir.iterdir()): - if asset.suffix in (".png", ".svg"): - shutil.copy2(asset, dest_ctx / asset.name) - print(f"Installed {dest_ctx / asset.name}") - - # Self-heal: earlier installs copied a solstone index.theme into this - # shared hicolor dir. Because the user icon dir out-ranks - # /usr/share/icons, that file shadowed the system hicolor index (which - # declares ~649 dirs) with one that declared only scalable/status, so - # every unrelated app-icon lookup fell back to hicolor, missed, and - # rendered as our diamond. Remove only our own file — matched on the - # exact "Name=solstone" line — and never touch a foreign index.theme. - legacy_index = icon_dest / "index.theme" - if legacy_index.exists(): - try: - content = legacy_index.read_text() - except (OSError, UnicodeDecodeError): - print(f"Left existing icon theme index in place: {legacy_index}") - else: - if "Name=solstone" in content.splitlines(): - legacy_index.unlink() - print(f"Removed stale solstone icon theme index: {legacy_index}") - - # Refresh the icon cache (non-fatal). --ignore-theme-index keeps it - # quiet now that this dir ships no index.theme of its own. - try: - subprocess.run( - ["gtk-update-icon-cache", "--ignore-theme-index", str(icon_dest)], - check=False, - ) - except FileNotFoundError: - pass - - return 0 - - -def cmd_status(args: argparse.Namespace) -> int: - """Show capture and sync state.""" - config = load_config() - - print(f"Config: {config.config_path}") - print(f"Journal: {config.server_url or '(not configured)'}") - print(f"Key: {config.key[:8] + '...' if config.key else '(not registered)'}") - print(f"Stream: {config.stream or '(not set)'}") - print() - - # Cache size - captures_dir = config.captures_dir - if captures_dir.exists(): - total_size = 0 - segment_count = 0 - day_count = 0 - incomplete_count = 0 - - for day_dir in sorted(captures_dir.iterdir()): - if not day_dir.is_dir(): - continue - day_count += 1 - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - for seg_dir in stream_dir.iterdir(): - if not seg_dir.is_dir(): - continue - if seg_dir.name.endswith(".incomplete"): - incomplete_count += 1 - continue - if seg_dir.name.endswith(".failed"): - continue - segment_count += 1 - for f in seg_dir.iterdir(): - if f.is_file(): - total_size += f.stat().st_size - - size_mb = total_size / (1024 * 1024) - print(f"Cache: {captures_dir}") - print( - f" {segment_count} segments across {day_count} day(s), {size_mb:.1f} MB" - ) - if incomplete_count: - print(f" {incomplete_count} incomplete segment(s)") - quarantine_line = format_quarantine_line(compute_quarantine_stats(captures_dir)) - if quarantine_line: - print(f" {quarantine_line}") - else: - print(f"Cache: {captures_dir} (not created yet)") - - # Retention policy - retention = config.cache_retention_days - if retention < 0: - print("Retain: forever") - elif retention == 0: - print("Retain: delete synced segments after the day ends") - else: - print(f"Retain: {retention} day(s)") - - facts = load_facts(config.state_dir) - health = derive_health(facts, time.time(), config.sync_stale_threshold) - print(health.cli) - - # Systemd status - try: - result = subprocess.run( - ["systemctl", "--user", "is-active", "solstone-linux.service"], - capture_output=True, - text=True, - ) - state = result.stdout.strip() - print(f"\nService: {state}") - except FileNotFoundError: - pass - - return 0 - - -def main() -> None: - """CLI entry point.""" - parser = argparse.ArgumentParser( - prog="solstone-linux", - description="sol for Linux — takes in your screen and audio and keeps it in your journal. part of solstone.", - ) - parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable debug logging" - ) - parser.add_argument( - "--version", action="version", version=f"%(prog)s {__version__}" - ) - subparsers = parser.add_subparsers(dest="command") - - # run - run_parser = subparsers.add_parser("run", help="start sol") - run_parser.add_argument( - "--interval", - type=int, - default=None, - help="Segment duration in seconds (default: 300)", - ) - - # setup - setup_parser = subparsers.add_parser("setup", help="Interactive configuration") - setup_parser.add_argument("--server-url", help="Journal URL (skips prompt)") - setup_parser.add_argument( - "--token", - help="Pre-issued registration key; skips journal registration", - ) - setup_parser.add_argument( - "--stream-name", - help="Stream name (defaults to hostname-derived)", - ) - setup_parser.add_argument( - "--non-interactive", - action="store_true", - help="Fail instead of prompting for missing values", - ) - - # doctor - subparsers.add_parser( - "doctor", - help="Verify install prerequisites", - ) - - # settings - subparsers.add_parser("settings", help="edit settings") - - # install-service - subparsers.add_parser("install-service", help="Install systemd user service") - - # status - subparsers.add_parser("status", help="show status") - - args = parser.parse_args() - _setup_logging(args.verbose) - - # Default to run if no subcommand - command = args.command or "run" - - commands = { - "run": cmd_run, - "setup": cmd_setup, - "doctor": cmd_doctor, - "settings": cmd_settings, - "install-service": cmd_install_service, - "status": cmd_status, - } - - handler = commands.get(command) - if handler: - sys.exit(handler(args)) - else: - parser.print_help() - sys.exit(1) diff --git a/src/solstone_linux/config.py b/src/solstone_linux/config.py deleted file mode 100644 index 88493b0..0000000 --- a/src/solstone_linux/config.py +++ /dev/null @@ -1,215 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Configuration loading and persistence for solstone-linux. - -Config lives at ~/.config/solstone-linux/config.json. -Captures go to ~/.local/share/solstone-linux/captures/. -Screencast restore token at ~/.config/solstone-linux/restore_token. -""" - -from __future__ import annotations - -import json -import logging -import os -import shutil -import stat -from dataclasses import dataclass, field -from pathlib import Path - -logger = logging.getLogger(__name__) - -DEFAULT_BASE_DIR = Path.home() / ".local" / "share" / "solstone-linux" -DEFAULT_SERVER_URL = "http://localhost:5015" -DEFAULT_SEGMENT_INTERVAL = 300 -DEFAULT_SYNC_RETRY_DELAYS = [5, 30, 120, 300] -DEFAULT_SYNC_MAX_RETRIES = 10 -DEFAULT_SYNC_STALE_THRESHOLD = 600 -INVALID_CONFIG_VALUE_WARNING = "Invalid config value for %s=%r; using default %r" - - -def _default_config_dir() -> Path: - xdg = os.environ.get("XDG_CONFIG_HOME") - if xdg: - base = Path(xdg) - if base.is_absolute(): - return base / "solstone-linux" - return Path.home() / ".config" / "solstone-linux" - - -@dataclass -class Config: - """Configuration for the Linux desktop observer.""" - - server_url: str = "" - key: str = "" - stream: str = "" - segment_interval: int = DEFAULT_SEGMENT_INTERVAL - sync_retry_delays: list[int] = field( - default_factory=lambda: list(DEFAULT_SYNC_RETRY_DELAYS) - ) - sync_max_retries: int = DEFAULT_SYNC_MAX_RETRIES - sync_stale_threshold: int = DEFAULT_SYNC_STALE_THRESHOLD - cache_retention_days: int = 7 - chat_bridge_enabled: bool = True - capture_framerate: int = 1 - draw_cursor: bool = True - start_paused: bool = False - base_dir: Path = DEFAULT_BASE_DIR - config_dir: Path = field(default_factory=_default_config_dir) - - @property - def captures_dir(self) -> Path: - return self.base_dir / "captures" - - @property - def state_dir(self) -> Path: - return self.base_dir / "state" - - @property - def config_path(self) -> Path: - return self.config_dir / "config.json" - - @property - def restore_token_path(self) -> Path: - return self.config_dir / "restore_token" - - def ensure_dirs(self) -> None: - """Create all required directories.""" - self.captures_dir.mkdir(parents=True, exist_ok=True) - self.config_dir.mkdir(parents=True, exist_ok=True) - self.state_dir.mkdir(parents=True, exist_ok=True) - - -def _migrate_legacy_config(config: Config) -> None: - old_dir = config.base_dir / "config" - if config.config_dir == old_dir: - return - if config.config_path.exists(): - return - old_config = old_dir / "config.json" - if not old_config.exists(): - return - try: - config.config_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(old_config, config.config_path) - os.chmod(config.config_path, stat.S_IRUSR | stat.S_IWUSR) - old_token = old_dir / "restore_token" - if old_token.exists(): - shutil.copy2(old_token, config.restore_token_path) - logger.info(f"Migrated config to {config.config_dir}") - except OSError as e: - logger.warning(f"Config migration failed: {e}") - return - for p in (old_config, old_dir / "restore_token"): - try: - p.unlink() - except OSError: - pass - try: - old_dir.rmdir() - except OSError: - pass - - -def _load_int(data: dict, key: str, default: int) -> int: - if key not in data: - return default - value = data[key] - if isinstance(value, (int, float)) and not isinstance(value, bool): - return int(value) - logger.warning(INVALID_CONFIG_VALUE_WARNING, key, value, default) - return default - - -def _load_int_list(data: dict, key: str, default: list[int]) -> list[int]: - if key not in data: - return list(default) - value = data[key] - if isinstance(value, list) and all( - isinstance(item, (int, float)) and not isinstance(item, bool) for item in value - ): - return [int(item) for item in value] - logger.warning(INVALID_CONFIG_VALUE_WARNING, key, value, default) - return list(default) - - -def load_config(base_dir: Path | None = None, config_dir: Path | None = None) -> Config: - """Load config from disk, returning defaults if not found.""" - config = Config() - if base_dir: - config.base_dir = base_dir - if config_dir: - config.config_dir = config_dir - _migrate_legacy_config(config) - - config_path = config.config_path - if not config_path.exists(): - return config - - try: - with open(config_path, encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError) as e: - logger.warning(f"Failed to load config from {config_path}: {e}") - return config - - if not isinstance(data, dict): - logger.warning(f"Config at {config_path} is not a JSON object; using defaults") - return config - - config.server_url = data.get("server_url", "") - config.key = data.get("key", "") - config.stream = data.get("stream", "") - config.segment_interval = _load_int( - data, "segment_interval", DEFAULT_SEGMENT_INTERVAL - ) - config.sync_retry_delays = _load_int_list( - data, "sync_retry_delays", config.sync_retry_delays - ) - config.sync_max_retries = _load_int( - data, "sync_max_retries", config.sync_max_retries - ) - config.sync_stale_threshold = _load_int( - data, "sync_stale_threshold", DEFAULT_SYNC_STALE_THRESHOLD - ) - config.cache_retention_days = _load_int(data, "cache_retention_days", 7) - config.chat_bridge_enabled = data.get("chat_bridge_enabled", True) - config.capture_framerate = max(1, min(_load_int(data, "capture_framerate", 1), 10)) - config.draw_cursor = bool(data.get("draw_cursor", True)) - config.start_paused = bool(data.get("start_paused", False)) - - return config - - -def save_config(config: Config) -> None: - """Save config to disk with user-only permissions.""" - config.ensure_dirs() - - data = { - "server_url": config.server_url, - "key": config.key, - "stream": config.stream, - "segment_interval": config.segment_interval, - "sync_retry_delays": config.sync_retry_delays, - "sync_max_retries": config.sync_max_retries, - "sync_stale_threshold": config.sync_stale_threshold, - "cache_retention_days": config.cache_retention_days, - "chat_bridge_enabled": config.chat_bridge_enabled, - "capture_framerate": config.capture_framerate, - "draw_cursor": config.draw_cursor, - "start_paused": config.start_paused, - } - - config_path = config.config_path - tmp_path = config_path.with_suffix(f".{os.getpid()}.tmp") - - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") - - # Set user-only read/write before moving into place - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - os.rename(str(tmp_path), str(config_path)) - logger.info(f"Config saved to {config_path}") diff --git a/src/solstone_linux/dbus_service.py b/src/solstone_linux/dbus_service.py deleted file mode 100644 index 4c8a6be..0000000 --- a/src/solstone_linux/dbus_service.py +++ /dev/null @@ -1,115 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -# ruff: noqa: F722, F821 - -import logging -import time - -from dbus_fast import PropertyAccess, Variant -from dbus_fast.service import ( - ServiceInterface, - dbus_property, - method, - signal as dbus_signal, -) - -logger = logging.getLogger(__name__) - -BUS_NAME = "org.solpbc.solstone.Observer1" -OBJECT_PATH = "/org/solpbc/solstone/Observer1" - - -class ObserverService(ServiceInterface): - """D-Bus service interface for the observer.""" - - def __init__(self, observer): - super().__init__("org.solpbc.solstone.Observer1") - self._observer = observer - - @dbus_property(access=PropertyAccess.READ) - def Status(self) -> "s": - if self._observer._paused: - return "paused" - if self._observer.current_mode == "screencast": - return "recording" - return "idle" - - @dbus_property(access=PropertyAccess.READ) - def SyncStatus(self) -> "s": - if self._observer._sync: - return self._observer._sync.health.state.value - return "unknown" - - @dbus_property(access=PropertyAccess.READ) - def SyncProgress(self) -> "s": - if self._observer._sync: - return self._observer._sync.progress - return "" - - @dbus_property(access=PropertyAccess.READ) - def CaptureDir(self) -> "s": - return str(self._observer.config.captures_dir) - - @dbus_property(access=PropertyAccess.READ) - def SegmentTimer(self) -> "i": - if self._observer._paused or self._observer.segment_dir is None: - return 0 - remaining = self._observer.interval - ( - time.monotonic() - self._observer.start_at_mono - ) - return max(0, int(remaining)) - - @dbus_property(access=PropertyAccess.READ) - def PauseRemaining(self) -> "i": - if not self._observer._paused or self._observer._pause_until <= 0: - return 0 - return max(0, int(self._observer._pause_until - time.monotonic())) - - @dbus_property(access=PropertyAccess.READ) - def Error(self) -> "s": - return "" - - @dbus_property(access=PropertyAccess.READ) - def ServerUrl(self) -> "s": - return self._observer.config.server_url or "" - - @dbus_property(access=PropertyAccess.READ) - def Stream(self) -> "s": - return self._observer.stream - - @dbus_property(access=PropertyAccess.READ) - def SegmentInterval(self) -> "i": - return self._observer.interval - - @method() - def Pause(self, duration_seconds: "i") -> "s": - self._observer.pause(duration_seconds) - return "ok" - - @method() - def Resume(self) -> "s": - self._observer.resume() - return "ok" - - @method() - def GetStats(self) -> "a{sv}": - stats = self._observer.capture_stats - uptime_seconds = int(time.monotonic() - self._observer._start_mono) - - return { - "captures_today": Variant("i", stats["captures_today"]), - "total_size_mb": Variant("i", stats["total_size_mb"]), - "uptime_seconds": Variant("i", uptime_seconds), - } - - @dbus_signal() - def StatusChanged(self, status) -> "s": - return status - - @dbus_signal() - def SyncProgressChanged(self, progress) -> "s": - return progress - - @dbus_signal() - def ErrorOccurred(self, message) -> "s": - return message diff --git a/src/solstone_linux/dbusmenu.py b/src/solstone_linux/dbusmenu.py deleted file mode 100644 index fbed65f..0000000 --- a/src/solstone_linux/dbusmenu.py +++ /dev/null @@ -1,250 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -# ruff: noqa: F722, F821 -"""com.canonical.dbusmenu implementation over dbus-fast. - -This implements the D-Bus menu protocol used by StatusNotifierItem -to export application menus to the desktop environment's tray host. -Both KDE Plasma and GNOME's AppIndicator extension consume this. - -Reference: https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml -""" - -import logging - -from dbus_fast import PropertyAccess, Variant -from dbus_fast.service import ( - ServiceInterface, - dbus_property, - method, - signal as dbus_signal, -) - -log = logging.getLogger(__name__) - - -class MenuItem: - """A menu item in the dbusmenu tree.""" - - _next_id = 1 - - def __init__( - self, - label="", - icon_name="", - enabled=True, - visible=True, - toggle_type="", - toggle_state=-1, - item_type="", - children_display="", - shortcut=None, - callback=None, - ): - self.id = MenuItem._next_id - MenuItem._next_id += 1 - self.label = label - self.icon_name = icon_name - self.enabled = enabled - self.visible = visible - self.toggle_type = toggle_type # "", "checkmark", "radio" - self.toggle_state = toggle_state # -1 = none, 0 = off, 1 = on - self.item_type = item_type # "" = standard, "separator" - self.children_display = children_display # "" or "submenu" - self.shortcut = shortcut - self.callback = callback - self.children: list["MenuItem"] = [] - - def get_properties(self) -> dict: - """Return non-default properties as a dict of Variants.""" - props = {} - if self.label: - props["label"] = Variant("s", self.label) - if self.icon_name: - props["icon-name"] = Variant("s", self.icon_name) - # Some hosts cache booleans and won't default missing keys back to True. - props["enabled"] = Variant("b", self.enabled) - props["visible"] = Variant("b", self.visible) - if self.toggle_type: - props["toggle-type"] = Variant("s", self.toggle_type) - props["toggle-state"] = Variant("i", self.toggle_state) - if self.item_type: - props["type"] = Variant("s", self.item_type) - if self.children_display: - props["children-display"] = Variant("s", self.children_display) - return props - - -def _separator(): - """Create a separator menu item.""" - item = MenuItem(item_type="separator") - return item - - -class DBusMenu(ServiceInterface): - """com.canonical.dbusmenu service interface.""" - - def __init__(self): - super().__init__("com.canonical.dbusmenu") - self.on_about_to_show = None - self._props_emitted = 0 - self._revision = 1 - self._root = MenuItem() # id 0 is root - self._root.id = 0 - self._root.children_display = "submenu" - self._items: dict[int, MenuItem] = {0: self._root} - MenuItem._next_id = 1 - - def set_menu(self, items: list[MenuItem]): - """Replace the entire menu tree.""" - self._root.children = items - self._items = {0: self._root} - self._register_items(items) - self._revision += 1 - self.LayoutUpdated(self._revision, 0) - - def update_properties(self, item: MenuItem, *names: str): - if not names: - return - - updated = {name: self._property_variant(item, name) for name in names} - self._props_emitted += 1 - self.ItemsPropertiesUpdated([[item.id, updated]], []) - - def _register_items(self, items: list[MenuItem]): - for item in items: - self._items[item.id] = item - if item.children: - self._register_items(item.children) - - def _property_variant(self, item: MenuItem, name: str) -> Variant: - if name == "label": - return Variant("s", item.label) - if name == "visible": - return Variant("b", item.visible) - if name == "enabled": - return Variant("b", item.enabled) - if name == "icon-name": - return Variant("s", item.icon_name) - if name == "toggle-state": - return Variant("i", item.toggle_state) - - raise ValueError(f"unsupported menu property: {name}") - - def _build_layout(self, item: MenuItem, depth: int, props: list[str]): - """Build the (ia{sv}av) layout tuple for GetLayout.""" - item_props = item.get_properties() - if props: - item_props = {k: v for k, v in item_props.items() if k in props} - - children_variants = [] - if depth != 0 and item.children: - for child in item.children: - child_layout = self._build_layout( - child, - depth - 1 if depth > 0 else -1, - props, - ) - children_variants.append(Variant("(ia{sv}av)", child_layout)) - - return [item.id, item_props, children_variants] - - # ── D-Bus Methods ── - - @method() - def GetLayout( - self, parent_id: "i", recursion_depth: "i", property_names: "as" - ) -> "u(ia{sv}av)": - parent = self._items.get(parent_id, self._root) - layout = self._build_layout(parent, recursion_depth, property_names) - return [self._revision, layout] - - @method() - def GetGroupProperties(self, ids: "ai", property_names: "as") -> "a(ia{sv})": - result = [] - for item_id in ids: - item = self._items.get(item_id) - if item: - props = item.get_properties() - if property_names: - props = {k: v for k, v in props.items() if k in property_names} - result.append([item_id, props]) - return result - - @method() - def GetProperty(self, item_id: "i", name: "s") -> "v": - item = self._items.get(item_id) - if item: - props = item.get_properties() - if name in props: - return props[name] - return Variant("s", "") - - @method() - def Event(self, item_id: "i", event_id: "s", data: "v", timestamp: "u"): - item = self._items.get(item_id) - if item and event_id == "clicked" and item.callback: - log.info(f"Menu item clicked: {item.label!r} (id={item_id})") - item.callback() - elif item: - log.debug(f"Menu event: {event_id} on {item.label!r} (id={item_id})") - - @method() - def EventGroup(self, events: "a(isvu)") -> "ai": - errors = [] - for item_id, event_id, data, timestamp in events: - item = self._items.get(item_id) - if item and event_id == "clicked" and item.callback: - log.info(f"Menu item clicked: {item.label!r} (id={item_id})") - item.callback() - return errors - - @method() - def AboutToShow(self, item_id: "i") -> "b": - if self.on_about_to_show is None: - return False - return bool(self.on_about_to_show()) - - @method() - def AboutToShowGroup(self, ids: "ai") -> "aiai": - if self.on_about_to_show is None: - return [[], []] - changed = bool(self.on_about_to_show()) - return [list(ids), []] if changed else [[], []] - - # ── D-Bus Properties ── - - @dbus_property(access=PropertyAccess.READ) - def Version(self) -> "u": - return 3 - - @dbus_property(access=PropertyAccess.READ) - def TextDirection(self) -> "s": - return "ltr" - - @dbus_property(access=PropertyAccess.READ) - def Status(self) -> "s": - return "normal" - - @dbus_property(access=PropertyAccess.READ) - def IconThemePath(self) -> "as": - return [] - - # ── D-Bus Signals ── - - @dbus_signal() - def ItemsPropertiesUpdated(self, updated_props, removed_props) -> "a(ia{sv})a(ias)": - return [updated_props, removed_props] - - @dbus_signal() - def LayoutUpdated(self, revision, parent) -> "ui": - return [revision, parent] - - @dbus_signal() - def ItemActivationRequested(self, item_id, timestamp) -> "iu": - return [item_id, timestamp] - - -def separator(): - """Create a separator menu item.""" - return _separator() diff --git a/src/solstone_linux/doctor.py b/src/solstone_linux/doctor.py deleted file mode 100644 index c7f15ca..0000000 --- a/src/solstone_linux/doctor.py +++ /dev/null @@ -1,343 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Install prerequisite checks for solstone-linux. - -Exit code rule: fail anywhere -> 1; otherwise 0. Warn does not flip exit code. -""" - -from __future__ import annotations - -import asyncio -import os -import shutil -import subprocess -import sys -import time -from typing import Callable, NamedTuple - -from .capture_stats import compute_quarantine_stats, format_quarantine_line -from .config import load_config -from .sync_health import derive_health, load_facts - -CheckResult = NamedTuple( - "CheckResult", - [("name", str), ("severity", str), ("detail", str)], -) - -_PORTAL_CHECK_TIMEOUT_SEC: float = 2.0 - - -def check_python_version() -> CheckResult: - version = tuple(sys.version_info[:2]) - if version >= (3, 10): - return CheckResult("python version", "ok", f"{version[0]}.{version[1]}") - return CheckResult( - "python version", - "fail", - f"need >=3.10, got {version[0]}.{version[1]}", - ) - - -def check_gtk4_typelib() -> CheckResult: - try: - import gi - - gi.require_version("Gtk", "4.0") - from gi.repository import Gtk # noqa: F401 - except (ImportError, ValueError): - return CheckResult( - "gtk4 typelib", - "fail", - "install gir1.2-gtk-4.0 (or distro equivalent)", - ) - return CheckResult("gtk4 typelib", "ok", "Gtk 4.0 available") - - -def check_gstreamer() -> CheckResult: - if shutil.which("gst-launch-1.0") is None: - return CheckResult( - "gstreamer", - "fail", - "gst-launch-1.0 not on PATH; install gstreamer1.0-tools or equivalent", - ) - try: - import gi - - gi.require_version("Gst", "1.0") - from gi.repository import Gst # noqa: F401 - except (ImportError, ValueError): - return CheckResult("gstreamer", "fail", "gir1.2-gstreamer-1.0 missing") - return CheckResult("gstreamer", "ok", "gst-launch-1.0 and Gst typelib available") - - -def check_cairo() -> CheckResult: - try: - import cairo # noqa: F401 - except ImportError: - return CheckResult( - "cairo binding", - "fail", - "install python3-cairo (or distro equivalent)", - ) - return CheckResult("cairo binding", "ok", "cairo import ok") - - -def check_session_type() -> CheckResult: - session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() - if session_type == "wayland": - return CheckResult("session type", "ok", "wayland") - if session_type == "x11": - return CheckResult("session type", "ok", "x11 (using ximagesrc capture)") - if not session_type: - return CheckResult( - "session type", - "warn", - "XDG_SESSION_TYPE not set; Wayland or X11 required", - ) - return CheckResult( - "session type", - "warn", - f"unrecognized session type '{session_type}'; Wayland or X11 required", - ) - - -def check_pipewire() -> CheckResult: - try: - result = subprocess.run( - ["pactl", "info"], - capture_output=True, - timeout=5, - text=True, - ) - except FileNotFoundError: - return CheckResult( - "pipewire (pactl)", - "fail", - "pactl missing; install pipewire-pulse or pulseaudio-utils", - ) - if result.returncode != 0: - detail = result.stderr.strip().splitlines()[0] if result.stderr.strip() else "" - return CheckResult("pipewire (pactl)", "fail", detail) - detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" - return CheckResult("pipewire (pactl)", "ok", detail) - - -async def check_portal() -> CheckResult: - from dbus_fast.aio import MessageBus - from dbus_fast.constants import BusType - from dbus_fast.errors import AuthError, DBusError, InvalidAddressError - - async def _body() -> CheckResult: - bus = None - try: - try: - bus = await MessageBus(bus_type=BusType.SESSION).connect() - except (OSError, AuthError, InvalidAddressError, DBusError) as e: - return CheckResult( - "xdg-desktop-portal", - "fail", - f"session bus unreachable: {e}", - ) - try: - intro = await bus.introspect( - "org.freedesktop.DBus", "/org/freedesktop/DBus" - ) - obj = bus.get_proxy_object( - "org.freedesktop.DBus", "/org/freedesktop/DBus", intro - ) - iface = obj.get_interface("org.freedesktop.DBus") - owned = await iface.call_name_has_owner( - "org.freedesktop.portal.Desktop" - ) - except (DBusError, OSError) as e: - return CheckResult( - "xdg-desktop-portal", - "fail", - f"session bus unreachable: {e}", - ) - if owned: - return CheckResult( - "xdg-desktop-portal", - "ok", - "org.freedesktop.portal.Desktop registered on session bus", - ) - session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() - if session_type == "x11": - return CheckResult( - "xdg-desktop-portal", - "warn", - "not registered — not needed on X11 (using ximagesrc)", - ) - return CheckResult( - "xdg-desktop-portal", - "fail", - "org.freedesktop.portal.Desktop not registered on session bus", - ) - finally: - if bus is not None: - bus.disconnect() - - try: - return await asyncio.wait_for(_body(), timeout=_PORTAL_CHECK_TIMEOUT_SEC) - except asyncio.TimeoutError: - return CheckResult( - "xdg-desktop-portal", - "fail", - f"timed out after {_PORTAL_CHECK_TIMEOUT_SEC:g}s", - ) - - -def check_x11_capture() -> CheckResult: - session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() - if session_type == "wayland": - return CheckResult("x11 capture", "ok", "not applicable (wayland session)") - if not os.environ.get("DISPLAY"): - if session_type != "x11": - return CheckResult("x11 capture", "ok", "not applicable (no X11 display)") - return CheckResult("x11 capture", "fail", "DISPLAY not set") - if shutil.which("xrandr") is None: - return CheckResult( - "x11 capture", - "fail", - "xrandr not on PATH; install x11-xserver-utils or equivalent", - ) - try: - result = subprocess.run( - ["gst-inspect-1.0", "ximagesrc"], - capture_output=True, - timeout=5, - ) - if result.returncode != 0: - return CheckResult( - "x11 capture", - "fail", - "ximagesrc plugin missing; install gstreamer1.0-plugins-good", - ) - except FileNotFoundError: - return CheckResult( - "x11 capture", - "warn", - "could not verify ximagesrc (gst-inspect-1.0 not found)", - ) - except subprocess.TimeoutExpired: - return CheckResult( - "x11 capture", - "warn", - "could not verify ximagesrc (gst-inspect-1.0 timed out)", - ) - return CheckResult("x11 capture", "ok", "xrandr and ximagesrc available") - - -def check_user_systemd() -> CheckResult: - try: - result = subprocess.run( - ["systemctl", "--user", "is-system-running"], - capture_output=True, - timeout=5, - text=True, - ) - except FileNotFoundError: - return CheckResult( - "systemd --user", - "fail", - "systemctl --user not reachable", - ) - detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" - if detail: - return CheckResult("systemd --user", "ok", detail) - return CheckResult("systemd --user", "fail", "systemctl --user not reachable") - - -def check_pipx() -> CheckResult: - if shutil.which("pipx") is None: - return CheckResult( - "pipx", - "fail", - "pipx missing; install via 'python3 -m pip install --user pipx' or distro package", - ) - return CheckResult("pipx", "ok", "pipx on PATH") - - -def check_appindicator_ext() -> CheckResult: - desktop = os.environ.get("XDG_CURRENT_DESKTOP", "") - if "GNOME" not in desktop: - return CheckResult( - "appindicator ext (soft)", - "ok", - "not applicable (non-GNOME desktop)", - ) - try: - result = subprocess.run( - ["gnome-extensions", "list"], - capture_output=True, - timeout=5, - text=True, - ) - except FileNotFoundError: - return CheckResult( - "appindicator ext (soft)", - "warn", - "install gnome-shell-extension-appindicator", - ) - if "appindicator" in result.stdout.lower(): - return CheckResult( - "appindicator ext (soft)", "ok", "appindicator extension present" - ) - return CheckResult( - "appindicator ext (soft)", - "warn", - "install gnome-shell-extension-appindicator", - ) - - -def check_sync_health() -> CheckResult: - config = load_config() - facts = load_facts(config.state_dir) - health = derive_health(facts, time.time(), config.sync_stale_threshold) - return CheckResult("sync health", health.doctor_severity, health.doctor_detail) - - -def run_doctor() -> int: - checks: list[tuple[str, Callable[[], CheckResult]]] = [ - ("python version", check_python_version), - ("session type", check_session_type), - ("gtk4 typelib", check_gtk4_typelib), - ("gstreamer", check_gstreamer), - ("cairo binding", check_cairo), - ("pipewire (pactl)", check_pipewire), - ("xdg-desktop-portal", lambda: asyncio.run(check_portal())), - ("x11 capture", check_x11_capture), - ("systemd --user", check_user_systemd), - ("sync health", check_sync_health), - ("pipx", check_pipx), - ("appindicator ext (soft)", check_appindicator_ext), - ] - fail_count = 0 - warn_count = 0 - - for name, fn in checks: - try: - result = fn() - except Exception as e: - result = CheckResult(name, "fail", repr(e)) - if not result.name or result.name != name: - result = CheckResult(name, result.severity, result.detail) - print(f"{result.severity:<4} {result.name:<28} {result.detail}") - if result.severity == "fail": - fail_count += 1 - elif result.severity == "warn": - warn_count += 1 - - try: - q_line = format_quarantine_line( - compute_quarantine_stats(load_config().captures_dir) - ) - except Exception: - q_line = None - if q_line: - print(q_line) - - print() - print(f"doctor: {len(checks)} checks, {fail_count} failed, {warn_count} warnings") - return 1 if fail_count else 0 diff --git a/src/solstone_linux/event_sender.py b/src/solstone_linux/event_sender.py deleted file mode 100644 index 92f2c65..0000000 --- a/src/solstone_linux/event_sender.py +++ /dev/null @@ -1,123 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Background sender for observer event relay.""" - -from __future__ import annotations - -import logging -import threading -from collections import deque -from collections.abc import Callable -from typing import Any - -logger = logging.getLogger(__name__) - -SILENT_QUEUE_MAX = 64 - - -class EventSender: - """Single background sender for observe.status and stream_silent events.""" - - def __init__(self, relay: Callable[[str, str], bool]): - self._relay = relay - self._condition = threading.Condition() - self._latest_status: dict[str, Any] | None = None - self._silent: deque[dict[str, Any]] = deque() - self._thread: threading.Thread | None = None - self._stopping = False - self._inflight_count = 0 - - def submit_status(self, fields: dict[str, Any]) -> None: - """Enqueue status fields, superseding any undelivered status.""" - with self._condition: - if self._latest_status is not None: - logger.debug("Superseding undelivered observe.status event") - self._latest_status = dict(fields) - self._condition.notify() - - def submit_stream_silent(self, fields: dict[str, Any]) -> None: - """Enqueue a stream_silent event unless the bounded queue is full.""" - with self._condition: - if len(self._silent) >= SILENT_QUEUE_MAX: - logger.warning( - "Dropping stream_silent event because queue is full: " - "connector=%s position=%s", - fields.get("connector", ""), - fields.get("position", ""), - ) - return - self._silent.append(dict(fields)) - self._condition.notify() - - def start(self) -> None: - """Start the sender thread once.""" - with self._condition: - if self._thread is not None and self._thread.is_alive(): - return - if self._stopping: - return - self._thread = threading.Thread( - target=self._run, - name="solstone-event-sender", - daemon=True, - ) - self._thread.start() - - def _run(self) -> None: - while True: - with self._condition: - while ( - not self._stopping - and self._latest_status is None - and not self._silent - ): - self._condition.wait() - - if self._stopping and self._latest_status is None and not self._silent: - return - - status = self._latest_status - self._latest_status = None - silent_batch = list(self._silent) - self._silent.clear() - self._inflight_count = len(silent_batch) + (1 if status else 0) - - try: - for fields in silent_batch: - self._relay_safely("observe", "stream_silent", fields) - if status is not None: - self._relay_safely("observe", "status", status) - finally: - with self._condition: - self._inflight_count = 0 - - def _relay_safely(self, tract: str, event: str, fields: dict[str, Any]) -> None: - try: - self._relay(tract, event, **fields) - except Exception: - logger.debug("Event relay failed: %s.%s", tract, event, exc_info=True) - - def stop(self, timeout: float) -> None: - """Stop the sender without blocking beyond timeout.""" - with self._condition: - self._stopping = True - self._condition.notify_all() - thread = self._thread - - if thread is None: - return - - thread.join(timeout) - if thread.is_alive(): - with self._condition: - undelivered = ( - self._inflight_count - + len(self._silent) - + (1 if self._latest_status else 0) - ) - logger.warning( - "Event sender did not stop within %.1fs; %d event(s) may be undelivered", - timeout, - undelivered, - ) diff --git a/src/solstone_linux/icons/hicolor/128x128/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/128x128/apps/solstone-observer.png deleted file mode 100644 index 1052502c67322c2e3052371f408542d81b8f3157..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8824 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 z z`qIQ)9nP{ehFc*se19L2 zy(@ghnLn0>-7;7zU>wkQVyY+apX&xE@0ojW3G?T8ip~^lc(N!iDN&Mfoxt+rmu74M z^*?Xln=_e5`mt~7mJU8<$tKCD&%9;L@4}|Wwtbi>nGm~8FygwZ%WiA%1kTG~h0HnI znAJTC54XR3R_cGEhrz_+oLi+W@0It5uN<9GRR4URLv7zoh5L?Y%qKE#U1=2x8jNLX ze94jC@Xxu+R{zq@d(u_fZ7~;hwY@&oTB_g8OnB_LK83S;@%H54J#8Pjr?}tDKQn#h z>RsydZp_}>;`o+-VUg^u#$Ph?l`l2CvYL=K#h2rDK&JZ*)&?V1)< zb=W;}3*vgSs)AQ{#p$}hH0lUc>z;0u8-K?-#j~&{+FmQVR zRzDEKRO8(BdUe&8y1xl;k`(5K9r}K=FvZ7ruFs0UL0z`oC+?YxP1SBV^3B4jeiq}L z)sKAiHnKfoEaR;-yC}VJ%fUKEt$NQVvn6&oblKMXuBbhlu=Vfm8Fx61RQvS9JU5nS z*_199mK0(*ZkCYJqqJ3`N`CSl@1UJNQMG;M&L@`yD@@yd;BK$lpCid4GET{P)bi;uwy+c#{%6fwPe_=*`XmnK=J0Q+LN}^)3ch zb|nd~>c4Sut6kr(?RqbAg(crYsUKVnhhMNWF&qwf=Xf@G=?UxA+qcFCeL2U@(J9Z_ zdcX7V@47?xRWG&N{k~G9QY-MYw8r-aK7Z$}oMoHk9i-KtChVQa-)X<@o&ge*^pP!w)XUvoJI;{;m@~^Ygm$woUr44_ci1>%HJUM}vsj zTeqL7=UJb=3(-rqyK*6)g@Ix6eWr~zt>y*^?~^&NRGe5G&)HyM`&)3Yauws>Otm>i zMyDqG2w%7!;F?jg6%}Q85lZh zui6V%zJ0C!Yt@a~|C7Duwrl-b%D_^+ZhPw+0kxz*I+vfwxUntZ{kHtd^N2ZZtN0>Q zPN;uSXk1yo?8WQ~=u(FE!WEC|87Pnu;?*`STaFw*+mvv4FO#ng*!E*or diff --git a/src/solstone_linux/icons/hicolor/16x16/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/16x16/apps/solstone-observer.png deleted file mode 100644 index 0e3d0c5a561ac5a6e0754b240b6f5b6a349b0c24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 848 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4mJh`2Kmqb6B!s76p}rHd>I(3)EF2VS{N99 zF)%PRykKA`HDF+PmB7GYHG_dcykO3*KpO@I2DT(`cNYdQ`02d6o`HdZv%n*=n1O+T z5sdv+k6dG5VAAt+aSX9I-5c!d5t1lk`~6(C|0n(QL-Tory58_#mProMjM*xpv?*#w zuUQtiIBS!hPE*s7qXBx_968s6I>e?X-OTWv#&wy0sYF|{#?v!UQVm_WhW?bRweI`Y_#eS zZ#k$Mz_zsP&A<5G`b8(y8_v#rziZZZ|Pf}GW@cq_7r!W(Q}A9`17VZ!-39QpBLQu_3h41_N@J$Gu1lwnRjKH zTDT@~9FSCgwk`Gb(?=(=v|GfNsF^KbP%z!dC?K21RT$;&XRPPVGwW z4CgmsJSe?zUQ0zkkLuzJwvT2AAD#GRzo^-a9Y1c}44)>G7n!eAz2Ni0U+fFBK3~%; z`Kz|B_uVO;-C_G8Zm)c!l`lP$sbS}A_wU{gRiDd?%<9e8dt7~S{p@6mx+RPT{zj** zX7t=XeeAIr!vzMhrmJ}q=cfBHSNZVmm$+x+k;2Nt|90)N^-C`LHr+dIAIRtIBfw^< zpv}OOY`mskq_2Ik48QwkyBM=S=dJ%Uv39W@^I1Ltl=4(dTq8}spaHj` eBr`X)xFj*R0Jk2q%(bqd)b8o(=d#Wzp$P!#+DwlC diff --git a/src/solstone_linux/icons/hicolor/24x24/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/24x24/apps/solstone-observer.png deleted file mode 100644 index b3de23911bc2ee9d6379076b990b8f830554da90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1337 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 diff --git a/src/solstone_linux/icons/hicolor/32x32/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/32x32/apps/solstone-observer.png deleted file mode 100644 index 611e555dac74dab4ff3380900ec86cff5dccc1fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1846 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFCb2`N02WALzNl>LqiJ#!!HH~ zhK3gm45bDP46hOx7_4S6Fo+k-*%fHRz`($k4nJFffC$ z!EN?<1_m|}PZ!6Kh{JoQ=lV>2E_uBE`sX>~4`iltHid~?%vvpS%p#%rQBvCM+Kf%h z%sx85z4qeP#sghj7aK+fzx{Ub=#Cpk@nYW6uP&*o_THRS7j-Q)x=rKp(*rt>yrXwD zawu5{UMZgU`ObB_0*4Pz6Z%fCd-C(so$s}O*Z;1M{r-PnUIj<+(V(MM67uufBYk$H zXip2w_^%NuKIzN5-Jfb*f_E{_ez5g;%2V-)DSuR!_(wZ`erNq+Nf_hl#+YN5wI_cv zlxCLF`zf9D&(-Ifm*?%9xBquvI{1(2M92SKUFW~AcrssR=B&#*&G<{>4}WM8*`Djw z#dx86MUDLI^QJOc7tRHIbh+_O&!KVMkLQnE?$2Q06V0}`(om?sAVir*&1uF`j<2gN zJk2(+I@f)1dAaSOng4dg|KWhl>-o%YMajA3h)0PBuKne6Wu zH1n-Kq$_AFdfw12Z1QT?4c@TcEtSVZzT91NDPr$ab9?qn0jt%2GUQxtp15$M-TT## z%UNvo!dVeHdgwV5j~X|U?<b|CV;jtX02G<|J)AZ}6m9FofY8Z+oy3hvc-kYtG-kW}xl zFZ@QqGdjN ztS_Ip&t|{DFUACcTF0(i(rSk391}&h{+FAm)xww_GmUf3`IUL^f)|^1yo-*wYa)Je zd1$HKzXx%lV(vC!e%tIPty6lN6wQ12Z|c6fPm2z2ge7Qti4mCZ&rkA-I~+7FWR1&OZ{kO)YR`?ny`u0fR^3PW!?X!H>}bx_Dycr-5M9ZB&N46OY*MqhW!Q$x_D|1-{hUM zeL`8n(JhB!7G>BkD!mmLR4D%VyY3;s^JVE*{vFtAyEE)et*c8p)Aoi2F4MaBC*^)B zOZ_gUKU)5LPWBV_eH?%DR?TCGToAY2N9MJ5{jQty;vQUKW}CV;=+3W?D$CNPev}9P zw5a@G`mD_Tj zfArL?26=zAtB)z)4X^w4p)E>vsl^c)zd30(0^||6Ny46kH4OWLpif-jg zv8yaT^XaqVgs$wTs`3pp_Eb!}q_h9#{7^Z|$9Fc&ld^qYdq(o|RE5H;m)-mW^v=YJ zTEFJqv7a^U{FjM23@1K$>NPx$c=LH}e&+SQ3u#m24~86`+52Ih$ky5=a~FymzG0d- z-{MlR-Miii86WoLR%U0&nZ=*WIQ{kom!^&FQda(3)6}=P^mDz{xViG-ge;3Cw*ogA zOUiFHnqaWww!d-rgRRFW*Uqw?lIi}jTT1WVuX^@*To39$*!d1_lPz z64!{5l*E!$tK_28#FA77BLgEdT>~>+V}lR_6DwmgD+3d40|P4q0|7-7H53iG`6-!c smAEw|*7VF`U|`UI+fb63n_66wm|K8bk6GqgS5Tei>FVdQ&MBb@0IJJb8~^|S diff --git a/src/solstone_linux/icons/hicolor/48x48/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/48x48/apps/solstone-observer.png deleted file mode 100644 index 35e506bd1ac14d4beb0237057f5d068be4452e1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2900 zcmeAS@N?(olHy`uVBq!ia0y~yU@!n-4mJh`hH$2z?F5Hn!&&zUNC1@pbY~916z`}y9)yt{B+)3&%nUIS>O>_%)r3F z0>(-67b-9?a2a{JIEG~0dpoPT;L26e)%rm^f(mkq8KW7(|6HXJ&mZv{h+O7o;O@@}|1)9-xz@7>?;RPVcA{H^?*Wx4o2eRi?6`f|&^9(`<-d3atRo7;Ow z*G9>iMv60UMZE6vcl-Lch4-}jyH~4xZfrDiWw=`X-O}Ui-kC;|%@}!iu)j`=|G%Z= zwe54Jj)<6iBqzmcuGjG1{i&ryeIiI;z;CcR}_tM%6R{Rwvm z{VD!}WoifIn+>=!mfy7u+w=O?)`b~Ng*zp`JbIscc>R&s*;6%{92XcI4UtLZn=$p` z-M)qKc_lm>3=TKmIpUOQsASD1>hjHk?PytpDRZJ=g7?PoxjVA&zD#=c((=NM-iA}g z6ZaNDl>r?_yeYTVEl8GbyIt)6I-={8@PP@n zW=3uYCb@+$ZgbneG4_Vomiw$e{#8Gnr?e(L(tmO;THb5xp05ul+4Nd2TV25H#dzrQ zLestTPnQ+9ZO%Ww`2fGb-D|=gwzceaOg3Apz2DD#7`9nt2E!Y+ymPt5cA8IDCuhD5 zw!3<7mhil_Cu&bF33x9hG<&M4-ae)o>hEq%H+EKdASe3w+Qh43B|40AIcjnqrTw}f zeeeGU_la+MJdSFrOKjZBAj0s~^4$#`_xmsMKC*0=H)V>dKUH8-rqsrnFpqEV_1}UI zY+C!140UA<>tk;}Y+%p$SiQdM=8A2{Bl+euoG9M8Z*TE|UzTFh0>9afGXFl$6ui*J zdGp8{xeb0UDK;-WPcm!Hoc#6bh3a+t7kx5+kiGTDZ2R=L%MP>N-*UqGlu-@SAH}?s z&a;=kH|n_PotrDPkLPOfq*c2ceHCU0+~s)2{AOc=&z%k3f8!s;-Ry|udeU6R?NYM- ztyxHz%DgAXlo|OG#7iYu9Aytb3(e&W{goGb)jhD-f6HCF%NMn8c`9&!X#6GgZriMd z*HaS2W4$?NM(pMFKF1VPt9mb!+oHR{kVF32yP`i&Jq|3Nq-ZT$FLr~;s49J{sbH!v z*Vgq1W~m9U{+_z(@1+$!t8T71IH~yH9?ma6^PHx4%;FYxbeD zKI0NNbLJ{dugKo6<-c@8^t|)F{XEyZ&9T9ILEeedtGd3Tlii!*cz4-sKWnLxpLez3 zh@IPn=Ps#9*{&}wx$0S%3YA$aQ$vpn{7YNVWy3HhHR)k{(9N@Rx7)Q`VAxTXHz8+% zq``_9t$DM;gV$O<@YI<%D^*J4l+zcX)0YwzW;`*xfBVLMzS~NMu5D(#Wd(FFjeR@L+9gNa+&!Q1$=xd-bPIyJ?^_E_jQqI6Zk|=Uw{pfpRUTG5)_21G zXE!lFw8^knn0M=Vz~9~#PnbJ0j2*u5t}gE`n(Hu8SMK(`N8hiu|5^II{m*1|)#+l4 zrH9vfH{AE1GM!;JtERSJ;H*jV?MML&DqQsCQpO`)|ASiGPU- zy}RnfdJ*A_Z@-_Z6inE(M!>U2S}-~*`PNN$?aUL7oEZyVL><}2F!{rtgHOum%G;FZ z2It+}b@b=;@UtaH=N{+E5&hzKfsL{J^x}JpcbN~|Z0Pb*_^7k)@3YoKPPs7Q8Ld)Z z&RFX(Nonk}xLESSV=~X`RvGRKF%NgIIQsstMaav)OQ+RuZ|7oL7%O6bpYQY~d@Ak75j8+Yo?W#AJJl`!~$aSF2gi+Z+EMvisN6ZhWTzTMq&gG1E z1GmDa%XOF5@#k!9xGc7vxBq5RGHaz>rB@wiOT$Hne8=@?b)&cM*m(<_UAFE(s&-vu~v=gg#yn61SQax#!1-pyXqzQ`@o_FtlHr3h9 zp;d()TXOR&JrsH9mDMiVlcf^Mb$l^fg_!6Z`Al z%DO#C3HjTRBM{N}Lcgedms+`4LuGELIcF8`3#HI&GhFf&HS(=~yZgUmI?ecN4V(8K z_TGkT;{C^8SUa5ip0QUr>Twj)CzcJC+s|4m-o0fXd-14i-?R$*&tQ!i)7csu^%dlTsG)xSmQ?UZGwE-`sOtxZ_a6*uL1_Zc?} zYs=aTMZ2E!p1oz)9k7?-hScVyrH7yY$$l=_SfB7p;k0BVyUhAir}k*RO>Mt=VTrP4 z)Tw&yZ7+YOX4_fFe2CYXWn-3iW|eZLW_6jh?zIZ{{Xe{}Pk6D^!Pda7BfRy`((vq` zSGMF^loU=>e)2s|+I*RmhQo^2<|<~VFY50T+IZ|cOAtTjiHroFYdd~)$&}|zTlAlS zP3u!cwqHu}iTJA0=Le@=dOUUC!i8Sx1GX8*x-I_Bo4UH*Nm#NjjBEGKAA`)uRUeGHQDt^cHOP8_gY>hL4P=w8WghLJZj&0 zX$|vJUD2>-qOMslvQGuhEEJG-pUI$UKL4f60oKaNhl>x!$mPDb%_;A_dOpI6$5*UA z$u!!nETvMZTBdffZ^BuZ70$t7OKg=UtbF~|?!fX6mG#%ZN+hUk_`bhv@4DGu#T_nI z#dQ)FpRU*$eWx_~`{mWz`5T?;1MUcBiKP))UH^IOkmRtAI&$ zVy`&**}f_K2rQl>!lw7vu7bf%>Tk%SCvTpuI{QXy!DR+hO z+x3CJ1UYuFUtaFtF0$rsvB%x2^E)Lf6YIlYmdq5X_&6ck{P(ZT<@x#_S@v73VyV)S z(y6MxuRrya)W<9HW^r22{5?0~$+X&&!E2(8($ps}YJ4;`>UrcuPwAO|*SGZEHj;eu zpYtcb#@8}wvo+#}85kH;OI#yLQW8s2t&)pU6H8JVj0}v-bPddOjSWH!OstH}tPD)F z4GgRd3faQc*yl;&*CU0?pzznG)4RQrFfmD%Y{la7C5hzJu4bQRe9 zkomvk9_f&`Uk_~B)cN;dbeLHtU#*<#mj3@ntsXbiecRI`!#>>zXP7oswdCv(8!NUo ztbI59Cx%OG`I;g5-)Z;D2;I|M^WHLUSd(+n@WCOuynhUjZ`hS=dugD1+KcZtb3$aK z!~E4UXV%@-XIT7L7w$MyvSV5*>v@oH z+vQ^cf_mqf+0}kbjJ2?opQfpO&p>u}YUII>e%fWw{}{J!nEkzpHh z_#0AF4PryfMCRFDZm+dZT45gPrJU`1{otlG2{%9?kuvY!p8nZ&J3BrHUw#-pO;iTt zI!Uka)@2VbUf6$sr_c0B$E+@wZqzwlzm5ICCZnmxd;j`Se1_Bd6_uv)lIcDTSzby9tc4|6MJPJa=BNe5^u~`@Ko5`{_-8 z5~~?>v?ufA%$GlKRz878@3T+%`As_i!M+gKRdvZAsZyWmFq75MFsnPtDYt@W`&_@e zY0baQppel_`f1Pg;TgZdlszxL`-WfMq*D*p#~`IC$NsL^q3*!)R|>~iG`aU@>^e6s z^=m&U?s^{lGvPF^Z?K73X%-uOdebj(9L&1BY?5Zj??ImNa+hV+^J$!If52iJ|J+bJ}YTDAY_Au0GXw=yH04%;k-5+T_3(Y*R1)>ULg2<`LgB>I@*R{yBgMU`5s=ZXj`}0DAr5rwU2vZ zWY~F7>Rrm*FMav(GyaZy3udWpj0zLm%-N8-)S`FFT-K0TeaasjEa!RLoVo1q2A$RE zW{exwh%75VTjaRjOxAsUXWVk{^kttaSWlm-0!f9K{X5Ft(7EjJi{`}^8Nb7Q*%KqL z{k!?SWZKk4TlUo`FkAiWF6ej}@hd~t@R)aG#d46Ve<^_cx-u)__aBpeKbdAAy=%gZml3}*wPoK48ogcVyNr3G&g;K9{0?mwo?WufT%uF!))j5r z{q&y!bKXA&=ab!a_e%t(*_#A~tvtK5-bl9nDgSJS6t1bB3r?E~MXg)yb$-j|4VS08 zbscYvNwMPlf8yvZ39CaTB4PJ4i!*k)Iel-`bAK9f`D4Vt4V(_oDt7+rFLSN35(v0r>#8TjPy?(;<-?atWsyA2^ zf;sfxe|&&=proM8ax*{h9dl zR>-&1Ss66L--ga_hQzQ#jvGHJ)y9J^jV| zO9vlIU3QH4z5IMA-!+d6IqSB4EidvL`QPte;LBj-suq{AY4WF_IYNsa)+KFh3K zEc5e}jmw<3-SbyYHEx{jb6jx2~Y!r$x$OTQM5a;rJzHq?T~F3sS+5dr z$wakG|IPIx^nfF0t-NIDlY!Q^W*zbJh z;ft+COJCYuEj_JKt2+L+#SB0KL8%xr^Ejod0Aa-c7l5?EK7i^T7`A6xqM|vvOnW9@{P1 zTn{H-Zkw?#=@_V((AfhkCQ_tgO@BJ{wmf_I$?1ED-T%CQH%u9fdVLv;I)yKA#d;~e z2z}xne+85rX55-z$QEXv%OlztlVru0+>-x#RVMpvK4sw-lP|YrsF?7dtzB5Hb-$g- zYO*hbQHL*sk=FzJa}yVYPZX~Xo$cKH<>li`m8+vZ@0xbp_CkpS8?VZ>pUjsXml)T+ z+TqkYC3Ld<+gXR#i?CHY9am$JJkrN_LP1JXj{n{NNfoF3T>kegx1WCLMAYT4{_49{ zz0t6#Wjpb0uT;_s^P6?`RqoQx?h~9vKln~z^t9f%{ITklFHJ9hMgQAi%P>=A{pV1H zX-ABoEh>_GWV3eSg<2^++j}L#OL+b3^YZ^O>;To8j!U?EWy05-UAq79)BUmBRjMDK z@yj`H+ncwG7gRen@GSOScwNA!?CkZ2FSh#R?TTkHy(_oU16;!<)Yn%}dKq*nBHgMh zWb1L6f5tp*A7@Qz;Wxc2HmyE~-(i{HX9+;+Vt;vJ1_Dqow#Xp!^>rl zJuFXsbUAg-MAl8~f~oLlP-(G-YwgQhebY0SCBI7g^YGB8nZ680ey3Cz-do5-oH)?8 zZYzt@(av0pNsYV0!mh0LJw9=h%lgyLMj*za=5Q!u$x?VJucBa_z-*7 zgPD9sKlQ(5JYk@uaVmS+;}^+aHib={#2)GR9<{Po9~eR zEty&CCSItuxWsYBYxivXhEJ>Z{!E|u<+oB)VP^5o<`wg@tor_iw=8$eOL)D&{#57b zMavGq3>H;7`uIRC-wAVB?^!Pol=e+LsU2@5>k+@=(S}`Lge%VVJDm18{@A%!Zpx%( zmwnw6f1duvkfP+fb7e!Vvd^nH?+2B=EzdHOHEi!q-Piv+xcc+^$u>#9k}o@cnPyj? zvd?U4?aXHvHpdqKFn;)~e=g&Mg*i+UCK{AMPWoKzxAEvpP4)Nwce6z@JZ44o9VIF=l@2&SGPEIpR4BT{hhl`%lcG_ zLp7+0^&sVP;S$@2BDq%Y9xpq5WA?gPRWr=m#U4K0cWLVZ(V2S>EuCGsw`-a&gVB|S zKRG`)K3iBMH*eilj~8~idb6{v6zVZLJ7v8`T+V!rhJ!}EQ%dP|g} z*J(x`J;!t+K#DW>yCmi3X>eaOCt9%%Lsq@88lCp zVa7&K`{&ZL3%nZl&1BtA1;n}U5M2Iwx>4%a+l_M`oy>GT(ZBD4Y4pPQvPCxSA9PM^ zj=gL(H@u@#-#c>0Q6FZt8*IG`m13S7bjY%uv$(){*Zkt*+KTvnjo;=dKm8$ko2j$* z>J#RVMf>|c5odle{0==IAf;(7`(^&J#~z8+ z4t1V=cT^^a?YnosxzzUOC!2x<{-b6Lr5f4-oR7DepL()<`;0t}$tAu&3#_c1?`l8u zWnIRsT>az3?Yp;GzwcIenQ!+_U{RIki|gN|{wM71uW|B{k~ffTeQH0OAw`3Cx?Hnz zw(s$YY*vDQjpzM~@lvkM)9;<4%lP+MT>Tv;9rjlrN-UT9`YNq>$+g@nw)W0Lmr^Sy z)fdO@4Hg8Ic1|&?U48y|K98&;U+4kz%f;8vsB{2y z_P$#&VNCaGLiRE2SoV6V+7yZAB|3X9mncm8|K_Y>bEe7`J`E#T_gjH-M;}IM^4ZS` z_~>!d(;}{C&bOy0=PaAn3+hcT`g!@IOt_bnyypw!vkNDG+`;s4@v_JM&bmQD=gvIs z)t$|dV$?h3YV)+&eagLSot`c$xz}eAy?49p`acV*a~iLzN9n8jPF=QmLsMcpV~R;F z=LSxP`@W7}()e!KiS0O6V(>9%$F`z^Hw@)VvYu-kk$kv7`GW<6#1qf!O?HclCUkVM zTPpD^d!4rPZlgx*d&g}I7GCNbnA_*dZQ}fD*7+o_p!}Fw->yY@yI<})c2#B<(~e7g z?_TTgW^+*6%;}&O9Jj63rdFlJF<*K6-+rUQvwW8yZn<(sqE%eF{Y0XL6<_L{=Dy%3 z0tqYzN^Q>;2C^_-U3anVlV9n*mEqUtbALEj;8OX%eqXfT{lD@G?`xbEA7OLK_H||7 zzInm zt2gR$t#+Irrt+rplEJr0yZ)=a{&Tn5bk5bg+7pUy^DLLM4D^zkKC|x-SDP<`(F{-z zDEt1yx6#%g)NNvQ7A`Bepnmz)OiSIvi-Kj;&0?>t3@KU0@6DFpwN^npbn7|6{GaCz zGEb8)`?u+?ZR7zlReMmwN7Q?uU6t0&0Uo}>D7a2Zr4I5 zyFY#!%VMQ}xjLb0<>cr5@*2wHq$qXzeqdp_o4;U?#GrN zJ~MIB{kla8^6r+*8$K`pyD@OW(iQ)1f4uYC?QFf$1m1~XcAh%;=2k6Zfu+HG&T^P(}yCx-sD3o`Y) z-Eqn|WM{yI$!`<(^*rYMrGIqq)`ZWW>VFxo4VZn}>%*6}p1Y|RUg}KHTzwttwoMIg zPPdJ7f~S}V?hNvhuD^bt**3m7>Yu>9UuzDDDhT_8pYiGv7hL=GlU1EzTG{{aJFkZ> zO@HL6Td?9~*UuNZnw;sEOb_2;pUsfM*xNFXQEgV=VfMKk+l>+q@IQI+@2$u6Ame0_ zBYFZe*hOZpn~{2itufx(uspDyoi{7_1w+hFz8BvcY%Ys_K4=^yG%v-9Lp#EZ@dVSY zkTU&6>9hJ4*FPzA(VO&WYWA<$|L9mFnp+>BiCeMaA6@ofsfVm0%2tzFL^0m`a* zamQ1+LJv4x7Tt6yi2YE$%<|8xE{dOW)yb(#uCx9<_+lsT{`arTLuQ}a*YiTxAxnKZGds`A_de3vUvIJ~ zcxR|=QEc6>Xbu zWPUOSB<)iAz0uL@&|+WqMAq|a43SRC3q=ovTrPYgD^{RfxqH99@b!`}-wx+;FBe_$ z_#e-YtZ951p$k}_q)rJhESY>j?fm85rW~PgGtcFD0dsQY0@o|sr)xW`+r7H>J!`== zT~65xlF~EyC&(A`OGx|bH;HtfU)-SGFbmdid&<(1rfl>)aS@5_CU^ zEL!b2ug|OG>cQf9w^FP&>;oq=zr5cfEuJfU+-7jeOzzxk%JpCAS?%hKf0NgL`L(%s zA^*c7@d;A?cMqLkYVpSwl*~4BI!wFpY}3sZ|K`865cq3r{W;y+_3*m5qw&3)>mrU` z58Ywd{LSKC789Fg7c<}#WBz6 z%j?!+=_=b5p~?%_Gk7=D6hAzDGU(Eej-8*QPbLXB=q!AANxASzrStOqgAZk93dLPq zJ!5s@HGb{=A+B{<-n)!$0CEo z>eiaeXGL4AWjMYwcH-qpGVA5dwEqMbywkfe=X1g~`={ThhCR-aFxz`bIA82Tu-N((XAOE-P(EPxbXBVzIOmchrIyvZ)$>jv&NY51E2AxF{ zFTB0m{ftGk&B)#B@ZMR47lMqFo1EX|bGgsVUCP3dzv#|7!}sB}Gry(&T%H}Z;=AdC zKVKrs9j+g?t%9}pv7&njkq^T+|?QUIr#jE-vx3T=u z3(LLxeqNt;UMNZX(t~IJHI&jtb52QzM}+=&uKsMkKH=Y|>0GN{?T_DJ`QBNnif_Y} z5+B>ioa+|*Z`IYZUmiYREANl#iS%8|`q(sTxASfPb>JIAieRpmHUGu?tR+9rD$JiU z>DaE8$2+s9?cVqG>}t_{lMmf@<$ua}izvnm&n}cqQD?IFB5m=haOR~M z1>2wPl1}R@`DFEf$|3Dvx$Jr0uk0*5v*+pFSqBrAw()YOiMY&d}_8d^7+0 z%D(b*Gc5hhRDUt{iu5yozkfh*pWC8)5t_YgjPELh6@k*w3Z*q%Z(SrGTvq(wJ^6Ft z%uPxUmn>Sl?N3hVPv(M#m)~xzF1;s~Qs^GC-ulUh40f9afatKH)FY`R)v5cc8PU4!B!v#%G+wGIE)uiHM0@B3mS z-HpB3U)DwDMc0l^Xh))S4YiEtGgvn*FN!Fs=s$CixlTwrkET3&VQ!+r7!dG zeUrKK>#vjSljvOp1Kp~Ir(b9_^K^`1Z9^!sMc zy`eShR)`T-;WQ@U--YL*m#EZieAyL!a`hK5$=>%I* zkzLz|82fXX*BMS2nK7O)d-dh3`#&Zj+rU$YIAaye?}gY+^OL*0?rp4Zx8|J_?_}LY z&#eAmn_r&3*=T;qW6{v7|Bn@Zw&U5f;dkwx%;TIFQ>C-Cg=f7}pB%PD(NdjJ|Le;f zEl^m06Lpw&F?3;&&7r-&1=c?{w7n?%_nPiHiz^?0m=`R0`HRV4``6b+tJ;?PuqQ3; z|7!YFwd+sN!Yd^@2eg^w^;fL&&sdfFUF6`?12^L49#vEcKE&zoy5-XRw~QyuE?vB> zQ?I=$U8U-CeR1N`z;(wziR}3KKJ4l7*=G5=o6YN2Z+8nfb#co-H+$KK*o5s53yx{( z+}%)7<#hS)k|jSyk65zaxU_n6#m0SKWg;>`W1fn=E!$Lf7~RrqnJgSDxhD7FmXoh` z@Xx-tJvOnj?{Q7;l{ruU8%wrNmArO!n*7@pe@^{TaLjc5y5vyh*YhetQHPfve|(}| zD5f}h^^}(2(ycDq3ftHftW`I4`tn@VSF8FQA08dKtZ(;~4`=raKl$C-nw)-X_Wq~S z4U47cX=}(-H+B5u@Tj<3_diqd(5BMve*YI+TUut#J96>7s>J5nU+1077*DuqeXM_@ zwR!1|m+w1e)@|KZGimWJ0fRdUOXI|@&TI`Wwf8Rhw)c-JFoW$6)n5fN+TZXLV`d(T$u(ZI`~Zb9wClb~7`>=1IN`neV&pWY&nXDo5dqvija)w=dp6AuO5DUp6rYAYUe zn|VTO4%39*s#~9W{$SrQofce;E0o}BiD5^Hpf zUv=$z+gkiOUHQ|_cd^R85^q=h$-euj%UM_}_RbgE2lM2-Zpbz0qzE^x*>Zm?gVGM! zc)t|m3BBf1`&(b^_kH_9{X_iGwHhl{{hrpe#d?+!Mnic_ca?F}dHWiAQLkdo_X{Q3Tm;O*&c1<*<{NQ zx#D;Gzk5~x=AH3;@@#$i*`ky7ANS`CV7Qd1Ubk}Npl5e=S%J21uuGhkvT1S1iFZ}iTUH#U(|07SSF+_UD zv4w3kt}jzsdRr_srpxHM!vg=#=TrM<{(rKW?Obl;-Y}EpdA*Ozx!m5{19n;}JeA?NjxvQ+qjP`WEbN}UBgcH+_skM|W9BtI>FkVYnxS)Cmi^wUv&OF)Tmv*P^T)Q^^8duDWC&v5VT2>vJVd;PD?p^(h*^N^x zbC?p|hQD4bx_Ig2f=$nVamPdlg#M4c{B^s|{w-Ig{EwI*uljS#dd+(U1vJqJC9#y<5z4muh+gT5^5TAb??>!qgs!Szn<@Br6xr!n#u+;JUILH z?CXkirW^^@dhw@e=U02P*z(g{PJy9+w`#@Q`5Nm#tAFd(y&r`ymu?T5T+5nJ>u`F} zsbbeJ=RN<_RjfFpHz)kN_vO;!!L&PQseCJKAYL^Ez$&v0qRx|uWFpEEuV%x5~`azVg= z(c;bB{n1m`t8coJe|yoV;t-XY(Xan)*|6$gyV~vZn|`fIEqvCBrL zRhp{4i-(xn%z80TbZxiRdPkE=W0onKxu0MCubj8hFu!VVc0UYg6I#!_{|Qs51yRF0QWWRsJNu%w(R(d#5cQmwnoJQ6`eL z$0KNJ7T4-m&sBHJ>aN~zb}z(k+vTaQN;RvNzh3SjadlNwCGXcxne9uLJ({MXC${Ba z@6KSiv-OLXE_YhRG4Vn($MPfG@+G`V+4p^U7MXZP{+Y|EyE%txf>X)MGcs;_{wkW7 z2~Oo)And5NKxX&xYjZnqx%GI0x*Nxf!ya$pUH?b?#qr6(p6~QEq^h^gsV>*ie7;#{ z{+j5Tnw5_&zcagX)-7>PEW7^u-@MYleA@rm=1iS(N~2#SS>AYX zuZ{DXYUd?eHDWy*tFmvHrS#6(ky9S@D&uIXFoW}!l_@;yTE6YN=+D6RtE4U8%k#4S zqp4Y|SN_;`h}UyfnetofG_kmSzuv0MJ-?}DW8%S{MG<~Es;6$m)tM|)N*6o3w%Q=> zV5zqgyM=6J{~{w@%dMAwwubU@Kc1GwRm=S_Y`&iqYg~1F(gTkf|9h*?*@>vk_hn#O z(zT)Ll|_e~w~v~V47ZPi1zV~^!}2({!XJx2Zu-5g^|IRP$97lS@3rpRa_sb@%BN+? z^4hXnuZG-P`eKb;T}^CyNWaaKOFyLZb0Z8EYdkwPkHv0EOV-xKQ$)|Lu3rWU|Ffy5 z9sD_Tgc8k9ZNA%i(0Io#wxW57pMuw`SWP?h%62xx50`L%skZ6nf~qXkiz)aD+I<(kjAtFOx@ zBqX#v>Q2$wrOy|5#_Au@kIj})$lf)HIi1%f+%r>U^>#16_0lo+FIJso7b@;|d=gQh zrr`7H)jt1I3A0lw9J~${&JT*p(mp5t^txxq^PMqCm)%0v-be%a+CC~<>mPg1`}c)5 z?S;wDuI#@x`}~^ymCqFPukBlu9TT{L&u8TwWWT3-?)a&Eg7w?8{K$VAtE!T6vvZ$b5BytvV7Zj)&bRi8 z=ek;ivZ}(A7semap8l-Mp)0`d-@D>X-ag6;d!p`W=vFZ_#ha+F-Rs)(F-Yv4VRn3c zaNHxs-p21u9NR_Dt(~8{D^TaO{<3O@dl$o^|4E1b^g3|0m(y>>f~=pb=5b{PpVT-~ zDJLOSVDnNTSKQodr5r=9mL#*Kut5GJx#E}Ijm#hKX-*Zh^fro>egBMqffo0~1M!V} zUc8%f+Q+;jHC3tb)ANiqix2guEMZ^wNSDFxRl21{=62;d zj9jY>{w?+EZJAeJ&dXfF`)Ez-sZ;5$_jWIynerrWmA`yB$Hp5hA!?;}U+hhG>@(bu z;kT>0)iJt%8ffx5WX0BPpEi5Gu3a^Inn#Szoy2Q6i1Sdf}{wD3`Y_?IS| zMZZ=yRr1!qU)iHK*Rr8`n?1XXREmyd2K$T64{VrsiCtV?cz3H&=F`_F`BXGH8?^XV zJB2T=WSD+)(L;sS7ry^sl|k%rBqr_2c)+7pfkoR{U7k zb>hCG+U?o<^XH|XUU2`Y_LSMm_xXN3TmQku@#2iv;1F!S$eLWwrLyI0!Fnaa-$U zt_E$UzLfI5$2GQB*>^{TEt^pMzOT~o{p-ef@6dHQD`y(6TCP;|?yjLrebz2tBNz60 zbx~eYuP2orT5_&?x~6YG)BM+RrkP*wN_k1W?+N#qJLf%{-8!eI|8Hv6Pn#N({LKF6 z8V<+(Mbd8s_S)pF)by=q37efa+2pxm)x4Hja$e6oC+{=po6_>$@ynw(GndFST$Dfh zTItq?1UJEq1D8LhlpbujYd#~7@AN4*Y4hB@%d%T43q)pa236g*Qw0w?KUH6LTe(+r zk+@gk<=B&U8z)ZvxMasmvj@M`&dvSS@_fdn68}9(f#%;1H(dAZs62E%rEJILlF1Qa zX0sypE@M9bQ%c~;>+Ovf=dhG9=w$xMzHrjxSkSsR`_$FuZd_WpRHcUZ#Mfo#=5Mpv zsdn%3|6Q(^?NtgKCnT;o(PUWsTFTeFJvDXnA@6DH9d@WbxO}GW^D|+-mn$1Mx?c1y zyRH0|V{4F<*|up-H@AN5mR`^BMr4wU=K9Ak=RXoH?)b}bf?tR4q3_MTwl^|8rlqPL zxvP2qebjC{gP(p|KA+mLbWsueh4-0#k>Kf#|x3lb}8)_?f)=7#R+O+j~8XusOl{7>OTamR`4QQ7_$YmOVGKJ>fL zy6m>H?TfuRl0}U#*Iw#<->3fkp6uhb2jkr;uaxL$=GpH4^f>SF>!VMs8P^Mad-7t{ zFKZw3w$#Y?nm6uzkWNT#SbjoOU#fm)zWOh2g?E>pMEX`v+1^%TcF*KpiorYvh7+Bq z%Zd*)Y<_ub&9PhRJ43ak%D#py`g*PF;(_T*JWj5APWz;rs@PQ6$XZ)lDlO|RZ<_;yo?A6P`~uEyP|bV zus&bWll_NeS&QfU+)@&qma4ig!LOFt>CRWjUk@3~*MDBLV`)e}rlDu%_CKam6m~!0X3KkT zp`WMOS%3DFlJv(fDVn`Y?(lR@@n&F9x$=eS)m)|}KH;4^k16N`AF|EcxVlRtN;E;l zTE+eCEZ%8T*+TBD-o0%OZ`*$VO`#m zP>%UdO|Q>+aZA}x&WnzzR6AwJ=oT1Uk@&ajvTtH!SdD9_e~tieUZFzk;#+Rh=F4qg zP_X*tHUqC4y#?D^W*u7Qd~^O3Uj~LH7JEee?PedH&7P|)R^Q7K+G}sH{Qb5vx3^sJ z)28}a-F;Dd;M)Pe1)M$RlU~FgFuR_SLT&6k*ch&4136$asF~nX=|hp+N6F z)6=3Cf7ltGH+sJ*nQcUt&ezH_Oq4#k@)m=N2}EwdnUunxq=(^e?BBAzWlb)%gm_^ z7pAgQR8+~mXkM1gd13L#u%251(Uxpawk%*OaLG5i%P}qW>-=^Wvu^hm|82T+!Y(Aw zT&W!$C}(_^uTk(PfAhAwxO020-%gsd#halb{r1TjHEa=iR?`x+H0t}pj|BgSuIjuyL5m94XJ%%x?%aJ2RPWB_TcUr-h;e~7 z=MB#u=mNT=)7j=+EvuYX573&zs77wb^eu+ZP&{E?|h_+VTOVU5;hk6+fe3Pt|ZuUgl@B64v3lB&pCYxb*sYY9 zZLTg{z;2;5Z~2?DDY^&r&R@JMHQDU!*~-c@uKoKb<~1)i>J8EF-ln(ekL;rRypKNg zEuPON_9epWsh-PugS%pX*XypW_!T&F%cs)I3%`WlQfDa1ms_&hSzY+CVZw};6~7d_ zE=zuYdAqTgdDpB}2l9Q)5B&qRR~O6>*A>3X)tJ<}_|^sf-5WE$e_O(}D?Lr6`p^A! zV!~6`xvI(*e6q?|yYfcche@mUsl58b-_Ew)cDvnd;bu_2E47`uuHtxejfBOb-ah9> z8J7v~^yjbIcl*$-yYFt@{jsRY!a>I?I@h&2TUUSKuO@|T(dFxmrB-j&_EDQ&@rk?i zD_2uRMS!h&9@B*Rzx}PO7G>7mzsXg!tL2dX489}$j<&8+3m;y3T=?YvfyQOYFQZv2 zLc9*kzh3DP?R!*^%dz&SoZgyM_D4&M_uQ80+H%E5zf|@;PpBWe6XUw7ANzupZQ6s> zPd%=TyH|Vonf~b;b2X#0(t3(a%KEsBS<^IR=CoYa+7R}z(EaPgxz3;DZQlGz)!g|p zJlpr^|Avo$ZTp@Z_vO5 zMOBwiX4Rn*g*wmYK5v*+u6$YIcIq7CUilNovi|aal;apf?ZiWFE&ITInU`Th-Nh{* z=k17(jXynI_9Nf5C*^Y9-`{l|U-98<+4jYIw=R1Wzo35E-?!DExwUOC&0eh!&!1_h zV=raR{$#Vj?-gFxw<>h{>K<@u?9?iHc;KzMm{=X?dz@u~@QkUR=Jl6q1s?44@O-T7P?ab5WY_nj>lNnnym&L)JpW$R z`jx9zE9rJ?`RA|IjDOYnJO05b728fVCHWsJC*)7cZ`rqY*`sgMW=RVBuwNB_EhZm- zD=PTo@jolBbA8f&@I<6iKtk!obl0zq+aAikj>^`myHu5ZG~=>k>a5u2&;RdT?R{(S zc)l}FLPEeRk2yRoauGm49iO=UuSS@ z6#h<446QBD=Ki)|?Xpk7UH>>{JUaYv(oyDjp~ZQW#6KoyY+bZy7g-#R%-Dty?ItXVL?aD>F$%oYfVM>cWr+( zXQ~EoR(G8F@zO{6zIuO7mvF22?9>X*|Cdt4#Gs(+YwzaSubO+cRwYmH>6M0a`wG82 zV{}|A^6g+%$oVswD>eUbthmKfV-oqC_rl3Ouj12-x?Vlc_B(AZ%X8P;b^EoDI}2ag zER1bdzdVyWWI@81TJ)KB-Yt=^6)M|oy?otrrKn!!Bljy;^M`!ZKPgpmb<=|%g}H~V<%FNO zYkqonQ~#$@jA_f-)z_|uoHGz@346TdYDkUsvS0BJx+TBO%GWs2wC$nn+1zgJQ@-qz z*QJ#05boH0DpV*5!hc9ixC_<-ZO23qF}&-uPzv ztD=<|H?9Uxy!P;Es)IxGqB#q8vD{z2bXm=+9p{1_T#e49HysM(+qGrsvPWGh^LK-q zi)#;S|6f_aY;d9^nk8dVmCe0PD;DjtHr!U_sj}iaL)4DMMA!cHpO@(D5nf?_m(6y^ ze2I8b&%=#R_D&JCm$Ur-PGWcQewI%=UPgSAUGT>`PLN%F#k>E!t**&T`O~i*eVWd( zNkl^Q%g&$%?GK-?xjx+=6Krd!c{%WQ)@lERlb5sdzN(&-Rphc`Rc^#l=dv7!_r3R~ z{GY!dj=5sp9J&2dj_rLmQ<2wV?)=G{3YNe0n53vJ#K5pJkWKN7vT z?q$@!;D4fD%%^&m7g6|C`F5 zmMhy(n$hp+dHsNqF87l+eRdKlPouI=tFK&M&b-rco_c9!>h9YgAN-n~{_CD9XksB+ zZ0h}0UZrh&U%$`w7U^2-eO+Jc{Iq-g@ft_wR$ehT(#<=6eVU`MgVx(c-<9T`%M23Z zOX+P1nHtK;5IdVaSK+uNXT{kPo!Bp)mK&clRye14M}AK-sr!7Q^Z&1f>*MyO{xS%x zT#)@CEX>+Y?DdcC`RkeIt+&+Ozx8YHZkH#~%e1FUJ#p}VBEQ!3>3nlp&s_y~&$Tl} z{V9CV(cCumOUQ!Xv#w|C;@!3OaH0RCm7rmyNjZ_z85mA1nYv#2+{*)Mmp3*9i?45K z50>!93)Se4|FA3d1W;huDS-VbOJE-%U(b&tA0oR+iSYFRvxa&lVLI&$UlE@r%>3 zKxF66waYH4xpQ(p6J4M#^?%mVkMea)+k~I+X!f#9Vp-bkGpqe>cF>(4Q`TwOa{J41 ztmw9#Ct>)uP%<)f$=YcS&uqF`CNsQ<-F)Zk+Wdbu9m((Ac^-y_&J$0adqA({Etj=G zZ-q54`r5%#%x-&R$o3pw=#KS$DGMmX4pI2XRGu~ zT&=O&<%#aDUz6G!;DaP7r#t)J0HF8PEPbvGkrCFOTPP`fA45I z;?JORi(y^*3&$7Yf8pdV*QxSl zvb+he=Io2zV5vW$yx?{8{U3{z+*u3XzW#;iGx>$1OAYN!IZOea{t|8{{sOO#I5p&@iRt{WXsl3=h67u@mI-{n%x( zE{tWf`)V7}E3fLcKHY8jHg&E0l4T0{-|z1VH-0@mF#Jj``@A>*mY4Z{J+f!wVSX#- zJ?piLyb}D?4q5K?Wr)hsvSU=qj=Ut>_2+Eij^r;d4Ew*O&RW62bvpjFT+VO#UF>>% z6Sw<)w)r`UBVA?T^<0Z1|MvZRHgn-caed>ztMpZS*H>-WyL8#2z=U1*kN3IG^-{Q} z<~LDAouTW6YtWsNa-s7xH{~#0@bx;h|Efm@&xg$_^UQ?SH!K#ql<;m@V@d7W_<+DS zE8p9{NOvhMJGiIKA)Np9JJ%o5AGU8}z04ne{I}sD@fv@YDaldm=hq}WeA;=|{N9EH zx4@nVqe+*;L%&wZUMT!MW8`Mh+;8d;Vd`aweb*q;1a zGB;rF4$g*lKfXs-`q+$_!ei~8KYu8&?%tNlY>gMQ|KH|6u=zv_{f|a`OI;S8(mwuxjx-KE`Bz9hh@j?|M&RzX8fFXsPb$0<$cL>R>bk; zaKA}8^mA>u)}KFdX(GM}YC-b-GXHNq&A1a6a33@V)EyY?~Z3ry{EOO=Id?N&r*2>u6=?( zyc5)g{;f|v`0??i*!?9J<{d8LNV;3yIiJBJU1h;mj&D}||K@2vtZbaGe)a}Sf`065 zcG-Wr3NOlC^p8q2+z2xME1=MknsQyH>f$HeI{7W2#U;PK+4+BY?)v%Fqf&Xb);ZPX zYYO7-nm#fR{*ZjP<+!k9{l{l#)nyhb+4C(6+VgUS@#YPy65oBe`7GRC+H`wftYhe3 z9Z_5H8*&FU+qz!s7{s0rDS6>~db>j7nm((V$G%UKtF;tBeWpE9IlN!vw(;y-kR8*t zRed(Yo8~#yGYdH`lSV7C(O7-emce z*W=vB1%IVN*C}xeYd){K_?qdK{lmZ_4d0`hMXtrt@4mS#|F*sN`{Os;W<9pg+ts(y zV~Rr+i*@nbAA1?J3fH&(-7p0_sFbz(<|#FX--^Q5OAHDQq&qZ4t_!Nrs}r0#>BhmT z>bzLlBZ;ANIqi;aUs8P_;*{o`-#<=Q{*)CxVE?pMIjMBQ*-t?*6L zvhe1+hQ9gVa+oeKTkguUHniofxc0qy^|ALwlUeF)YAv@uXPL~varlo#pSo1adGGza z^JVWvZP@v5az`A~o~zw#zmgs6s_#_(iRxYaNcU6imHh?L2dDj7&tm%bvj5@q_U)JU z{S|o-IG^8kV`;_myY`9sk64R*1@s#YeWXsjZhwCAq@}CZEaCsPX^OKMjwq%+?DhDf z@<246|Bc6=B~CLXT;^^uHlL6a9}s^`o9B(phaiEEKclW^?K!lQw|h_Do{t@M{txSI zMdFtltX{C{op&eCmTc+3$~LE!>pyn=+m_(>cl#2nj)QRrH9*OBZlLYr>bY$HZ!!Iy zH(%N~mBqjLC1ZhwS=~$)xwfk3yjQF})xPF#J)4sx+`!`Tah3VUFDeK2>rWB)e&Ssp z!EiOw{Mn{dX=V$(MSf1fw;z4H6PsB)S*rNvrry?K>AJX((E5-wXMavuQRn5N7jo57 zt7_eIgLNBL?c1?p)j$1CJ|`jn{crQ9?8lHcK7S%_EP0}^K_O@Y+x5lWBaYA<0zC>|41afD z%oc39l2}lUQD>2 zuO(l1Vkz(J@732b{c|sQWGv?AcFJ|$ds#)~GB!t!o33ZCtPP(_gd}3BJ_0eUH{)dqTWrN+x&Tz@AZ@ZLA}p2a?Bo_?`l2e zx&D5L-JO!XLG8k4wmNpsXHY5fy>tI!XqtCsa=~hkG=td;UnYu}-);1`KW&;w@Beub zrW3*$`X2wVo}I`aXx=!JmviFbTW%$3cQ4P>c>b&0Wbda9pN;eXeST4?J&!&1(dCCl z#j($Wm%W~%b)}ni5`zY3%;{hEm_NvB=Kcx2kUM#04$}qxpgTJxb}YLtUm{e=`haC3 z!;3!V;$N4qUR!Kf{3`zKy?TxKedS=}+N`xth1yU$_17@j7I#va-@>k}t!r)fRthw@mj}+4v*A z%#oi@;nuVR^SQQWzb}`6V*P^owfNNeQW<+Rm7X23dwl(UU`w`fWl1$?;fcea%2&dE zIeWJzZFy<6w8>d7_XexbfjgC!Ds>a%_Av#-Iu$4e_qx~z4d+cu{_S=(IX$fqRB_q80-WN9=`n9;qkhU zd>Yd1d9OA$C_Om8X;-zZ<%W0v%SFU`e@;IjZ6|k0@JE?@^*?Ui?o;~LR2SY^@OnZ{ zR4?3Aicdtiv z6U#=gjDNpor+?eAVx~{b<%tJ&^esPew{%-iy`ri)m$$naZ8B zXztsee`5u+?|)RBU1Q->BYQiWDKc!!>2{YLrv%?Woi^(fd$f3H&AY47%h)eOPqOKI z{_``7)~}H7SuK%@`uiU<9_Sb4Q@`=DA=c)q!!*wR(l>rjW?j3ywUD>6a{0+4ru%fv z(n>dSHk@Cgvq$u+9pjt#wbgf64mdw9pZT>>{otP+-@__?zS`a>&sHPlej!-!-xT?; z*S22?vAGu&{qOF!*2|CH+s{j~nZsq@xA@hct0l3MGJm}J5a#$-qU6<+XFH}Ydp((p zg{jgbKwbTxsqeSq+>ki+ywTX{^4fpNm66IM31l8N!ne;?g%x$5~|j$uJU z-d}yue^ZxSe#&vi;i**VozS&gMf0~@3;DCLH1O!1((YyLLG^)(_1kL8l7Dr)oTW1N z)c#tl0EOuM<+AR~JKc8p>HIT2JDFdN!AX0S{8Ufv&CAlYX9oQ_G-IF2l`l=}_~!cb z9KUD2z~AKEpHy~P!6P5SE=F7uVh}8O?E7nLMqdl#eshVJGmXT4*c^H;Yjdk@`~JI8 z!TW?JTDi>sQuf?l@=9MvZI-ss4MW|1vWvGpW8HS%`PC)$h5w}k7FI2JIco`1qpxF3 zwaLWB2KhZ^d90SPP7e=E(?4Hk#>g?jhtGcP!%LUJqmB$Kj?}lS{@BN@;Q5Y`K>{>O zqigzfzDSB@frHWcz2Rs1S<)7rabZi@#6i0NYNZ7h9frRi*DlN zQwolh%uX@-&83h4noD>x$Cgcduk3r{u4n9m-AbPKleVu|^-$Brj(^Fm6L*yx^_8wv z=uO=AP&7B!bZYt5t6_E7f6qSk+%4%5w_D!-{|bx9o!qw)pX=Y&{ZilVeNc^o!Lo#{ zs5jv156)SytohyN9?+S2=}XJYZzoF37(JLYGiz90+_Uiz@?cPzz_MWSjES=g9^Tni z9s1NnVXFIwzL1h>tM0uz`sC-eTV3MO*;-<2<4tO-zT5nD;E;KEyl{@owi)LF<~=&j z6y7SmZmmqtF1=;0CwZSpDR!zb@LBJ=C-%a8smdS!%efPIbC@Qm1PT39HW2qs;Av3# zko=N)x=phGL)Xu*rga@Y$&o*+Yhn15!;M=0d(>~ah3vVuZN;NopixLBi^=+rm0f=C zonQ5I8q3nigG#aoRhWV<-=o364J__=M0$=_oo)+yYx8B``tX~{qMc>Bjw zE4>sr9qRu1*+2979(i!iFSgF3PId2I&J#HmA-Yfb$h9rkLTs*XTk&W%s4BWJzqwog zv8|Qqg0(T18=wE&RFivU>YDeAH|9>4K4I!4T)Cq2Lq_ zIB$xm!?tI*nbI5 znY}*R$Gk^V>4&ks%_|n8A7*o(?Apy##rojL-esyqxm*6E3O9%>5%S?bup@lBq`FR-=DuZ?iq1N<{M)1Ax)=%TO_kXp?APZn4-0J0 z4vh%=bdyDab)|{m-izx3Dof?GCZ{j6;b$K@mCr)(AP4N+UwxMy|!(+e{j zdvrhCo>6)Fuxn^(d||n#;*0Ad^Ik+bE9UN46|$Xg{h#zZvCmZg?GoUez`Hmt?d|2& zs~(@X;JOnMw_HgxYTF)>fbhM^!VMxR!VM~yj|JTEH4Le_`REICTUn0(>hA@M*R8l6 z7jgB&>qV=63GeCK!{oTXsi>Z7oz@Qjrg^hwE0;>=Ysv5a@AT^Q@9gt0Puya0I5L0b zlwbF}N+MSqn;Faq^*GvH?d$#IR>sfMY7CxBCSGv0s8NlxoS0C>aO*a|N4H!~PcYWuRkv%&xHUD1CA;sT+*1!5ubot_p9;4X6;1HYm zJeft4+SbqcW4d6m@r3M_45sy_v9|HEPd}TT*9>Z*t6cPW!R??cRKHPBf1Hho_HF4;}3cX6^3f-igUrU8`yXKcND!@@%hWgc~!xomV7g~ru;B@w6DLU zC`X}uMbWo;%HLQk4^Myi+wkN)7KX2?j1m`S&XzkJ!miPNuIBuI!-h`YoNp2>3M+Ij zdSvXq%db#j#>g?F&AXrd?{B%=Q`IIgcvK4%?_%*y@ILqDdTDd)Kl=yu2V@y`sZ6=O zOM8Dr%-Vpdms#wXewbF-1kR6%kN)SI5%DQ+%_{ws_9y4*&*oaHY5!_p=8Zq8`7_sb zWCp6IuUPyqzE))-i$hAl_p$>!58S;KQnYG{o1FPwwV7x3F>y>-$LD)!BKy7a%i_KE z37#Fh69eWyDx9-MHbr^!;kmBUudLUZb9=*z6|4S*=6^E*K>I_~rbhu!c9~}_ zll^mU|LzL?U%`%j-eIyqCx6XO=VR*o_%Pt#AKN86WzNt4C{i8#b(>m1*7@)hX2Iun zv7%b0Ae+?~7%vvQoLFJQ!&Cjge!>p_hmSH9vXioXes7D(Ws@?t-m|&D@9x6k?f^*PffuCJ(iR4E%=`|9KtPKE`K zSc-ZDj(a|_-uGQ%rlQ9GD+MnnF1xK7e3M0i;Z}$do4cIb&FkM56&k;pacY^vqeE&8 zzm3!GeJXf(=hw|AMt-f@4W*59eSW?IZJyru`~9N2J2ju!H?LLkJUi7Yr0@Gvi$mVa zR!`#kGb?c8c8QHH>oVo%oLnTL^l61zn_bGTT%(D;3^mIpK57muXS~Y2>(x_%6R&63xR-vj{=Z00(4KGAs^w~1HwHDV znD^*-VMwM~?DUWYaR#;wM}&4Qd;9yAhD_6G=3R^WrSl?xrwTL3XC}X3D0t7l>`Q=7 z9@B)?ZyCQbB-A@_T0Obc77H4{=@6RDVDsiTXUM(Y^5mUxln>Z~wZ+{9#__lcRDMOocbsoldjA$#Ovbmc*%}SNA*I z;lAW}>&EB4!n^NV|HgAWJllfa@N`N{#bdw-EK>1psQqq_}4$Xe#-oQ zcIedNN%y_lN{XiWzrMEL;Mb$6YnQE_%oV~^>5-wor>3<3b?l4J?$6#Q^BlOh?2_e& zdG9t&z2Gs<+rp$l7yg)i3$7e%=qgj|-R_F8)!E zcl?mbw@bU`YKXP!>_gN4TJ$y4{;EH}&`#!YEsNO^-7Uo~N!pRC>Uw0I|KHi0Iq|B$ z8RHA}&=!xEx*c_95BZgE|W zhQC|2z8JfteSc+~_eZ_Z^&W@9twq!G=g)j|G~dWeZ{yE>X9WV@-+fo*`0lgj5y747 zKD!3LovgFYYpQ?yr#mY?JNUC6S-w~8j-UF{`BA%t+~1zTueOQP!9F86L+@01^<#5hucjr+gG1@{?~Gron6*HVO`s44R`m=Io12S@Ais0bLJf8NaFqE zvM%-3@^Jq9mpxu=da}hYZ4;-1d}i>?N{;SdKR*62PkcC4;n6pfw0Pz()5UimUM|bQ zzOU@{m*yAu9&k)-|HB!;C#`c^&*+ZV!NR@S-y@=S$A>(5$I|t4llOv*EOmwz&#S-N z$L@{pj5+=5S3=QTv#GVZH`-3dm@&R!&ei&@vS|Oi;%z#dH8=OR)S1+B%yX!Fa&rl& zrLy4s&q5vZTR-m^U7UCCY4n6u_7#7ZeR`M{rC|Y@)f2tH%=*}zh&Sswr+i+Ter)pI zFjikn$F~}qAFCNaqXF+HFY?~9@{RGqZN4BM>1P(daFN-&IsSD2D&hURy_qXn8`^vL zx3z9+-Sgmh;HSBFnD70(@8@^#ubrNaiH*5?p5ZkcMv#q-tNxrip7-j1iE42n$Lqfq zGZ(qD-*x+|_=siGle{yXN9$*JKAE+nUT;zTg=ZH|I)Wl=H{-V^v#C=S-nslg_Q{tI zJ6vVHi+22sV{i0y+;-6#oXloFo?`jp;-U`qJui37eb_TIf9)pbm;cvgUOe|=_iNjp zeHAzRK$CnfDf|2b>$Yv(_UGQAoxJrXH$&=9mOhobGQGE+Az*4t+(XSz$K_s ziir3oh*UF$%niA}TCpSh@HJ=ssC`S9Gu@Jqnt4J_lT)I}fqPJB#c)hx!veHzxy>QddF!cj%xztx%?U>HY{$}xv=AvC)HW#CU zH+rm2UzX25<-zgQBDOyc^nEY=3EC#9HQ~g8EiSSmpZC87RsY+6Y317JY<~-#S%-!H&QRLs zzbK@Cebolri=V*bjt8d8TFE|bQn+=By>X9JiOIt32!Y?>J?c__r@M9Wb1jOS394fk zW~)8hnEgH8;dGOw%O|U1h?wyf0wS^626pF+XPxx z!t*7lzHeFf_wVt0W|Z&D=VmSyo^kKyy#E=m*NW~d|N3h!Xp2pU9pj_--<>k#Vokn< z*ZxSEEWbRQV{LrMysFz~-UiGJtu0lC&9h_;S?}zB9klz`%YQdm66C+Ei`0{4{>Q*# zxmM*J|GCqJ%`X`Rmi;P}SUYd#;uHSA_2R+p6E-pkL+|Gf|Sv(9k( z75jVB{dWb*ebeRAkhQCMasJQx`1>XN0=$c)>yq!JPTRe%kx}1YZ+^;+ZSl9yHrt(0 zW2nyNi*29OvsAS;qE3l9ze$2a!*X45&3^tzE8qK1bG(23dKftU%6g`#U%!6t<&3$tZH0~MwTAo8gV4^3rgolfMJGIL~ljbBJT^_)+j#Z2tZ8`_dOE*k_en z$K|fp+@I&1`*cRbIpOf=vmfQppPZQ1qW`lh;o&!zuHJdqKTJ5(wBVI`08`ZUT`L}a z*EKa(V=0~YH#0gW@LpK(KZc6$P6?+&Rk_rV}7;Zx0_;G@r6fz*JS4&-7gy1{pxp`w8bjJ31k8s(lOJV^e^FwacuK))ri)2qW7f!5@VmuzO~%{Jw|vPjKu1 z6(2IbAF}-W?bBOhc9|V(YnQ3lFg@vEnD6FT>iBR^Tijm18yhNqwI|QJE^%h}M}DD- z!@@r|zdmWd?bL&*duN($*MB{&g|Fatv39_78Bx2}*Bwq(h$vZ0MBTgpRSTTG& z(s16}^Tv7B?U#bCsWaRP`DA$Hr+Pxt|B`-*!%KDcliFZ$Z+%BQ)eqUzdr_@Ail>@8>XU$lv-U3||B zyZNtAap&E8x4r#*-T(4(&|;gnXFjv-jw{(yA@kDGC~iv8g_UfN>TJE%JbJK7bRX}M zoYiZ8Ow!u#Uu1jF{Q~>ezi%U3r?0Quz5jUDO3nWr3a6PmHf`OoYTb(ELXgA7rFr*u zy>of0qnYcIVfn&m>eclOTXyDXt@nF3tlNTQk?Ee<-z>>Rmp`69-_mis+X*$do zZfLUn>U}#)J4~`hiOIt0qVle2?&HD&{eLvJTnk~yVg7V0!J+O7^Pck0_sXqoC(mX* zU0S>IC$mV%Rr@<((S?m#`@M^9FgFSd+6uBBZ=9jE@!OyBTNTzTn2QpuWp~^?zf4O( z>d23yKUlU^^vx;zAoD-(9|LIIe`jvRaet<_ie1hVFQ2gg;J<3l)%)R0i_WdA%gOrM z_;}Ij$6{SiLnUO|r**%G-(p$PCDQ)*_^gGS$^;ge$(svaWoL{xD}8C*`CBh8CFn>e(XK7_t#AtN}h7sTh4o&%ZZM;`9GA^^>-=n4SCgt+0$MYday6Jvwq^^F5csb zK4EGMpu-~`RPrSSFdb|=)962Ih`RR_@u_7d&(RuH)>Be(7mS>dV9}8Uk3r@`?4>mO#W3p z;n1pIQlfhA-o-s^4>WZ)I=H z25kfWQF~`mC9nDF7ta~u-F7TDZZx-XPZK#)Yqa9trtg|-R#gW*sJi_1+mYEfub=PA za;O$M(N+7l=%?MzDQcM)au@X}T(1t0zMQ^U$3DfEX~Ea0%!N)~wTIkG5F~+?KQ!8c% z3m&olKd%$}+)@9%W`OPHah&63q>%P&<- zb4Yasc{_%!><({L)60l&Cp-Tqe{hlGdhtg2cGSHWpM#IwJ9ei2)6J$m-&QJxFweD< zF{NxP`2{ll?pvZ2x})&&v$dCZe?RM?HBKOMsBruKHv);*g3G2fphgR)c^8T9|=%|3$_X{p7 zY?!IMO!Jg%-kG^?j(^%$r?DgDXjY(YY(h7G`)kpchtm>4rvNSdbh5#z${??Q`U_6B z>v5h}#54G=&f-(bkethqv1E-@%D$HtLL&cb=PY(y8)aHl>weF@*W23c(X7bwqnJ$>n;`wc2-If7s(Ky&C`d zw8B}paV>q(!2+z_!7_Z)G*ZePM>y#KY~fC1wTTMyqapowwnk z{ek*hVt(2Vcl1}in5l3%_)qmpg|A(DI(K6JZ%^U<^m@HusP;BS^B0pYEc*Jka{l)T z>%FfBty%faI+OjM>Z0vE^DciVxL~Z&bm8mVj2}OD9TlxFvRTJy_x$tWYnNiao_puL zY2I}0`C(uGKl8sX{&F4**Ux>+J%u>5yiJ6n^i?0$&*D3FzbjRMce-4^@_)8VrE<@M zG&#Tj(A&55Tg%}KFS;B5+|}#{C7YWm%zNsBe}32_^W5a36wn zf8_Qflh~X+D{FF0+9MU_zxC7djperz{mK7gE^|`lcb?h%-{@Y>eZ2gdtLSm9s*N2r zE9Y1D=QuCfl@Oel8(-}bm8~_6QJ!MSw5~)2Zxqtte(*=jVBdmO|6DKgGA_7kTzrkc z-_K=lrj`%$GN$7U`*dPN+@%i%+5GIU_>{5i&X@1c&(8L@P*InfyOQs6;9R~UpN8x0 z8W(sr#XRWrTmE@t^Ybr%=ALJnowbbjrNDC$_opSIeom6J4;)WoI=kx6HsfXWFK%1^ z*UL2(b_kL)&y zsZx>SU;gu~`~g+_Dyaf@|5lf;exjU8i~^$P*y|E@DORLEH;uUZ_k1n`Xp--ZtibG^ z=Q4dK5?5LACCU^?#J=BF{dw*^#yyO;v<|J7+jPw_-16d_t1Hb;*<{@1e)(tLZ~1!> zHl;1nr{}6Ys+F*Cdcfh@_a?4V@1ko^S@S-YPfV-ywf1Oym@|3u{-iHQPnXSDSG=io zf7{O8zvV%LIXhmT*!ZZZMWcE5{nPv+H`crm6aBt)&Ri+EQrEj>^PPUUC3w!w_!Hj# zcJ6$KOaJB>vpk(Ir8ieFe@~c|$8YfdW9RGVf@f}=J^55^(5)MfW$v2i+dUL87ppa$ zvEM#Z_r(3b3q$0uY_kWId%RM%Zw|zqmtGVosi4#x@ZRA_rL}v~ykmFPZj8}?_U6{c zjUPT;&7a+8E}Oe!TTwwy2j7&t=M)Y~FWSj-WyNF8xw|jik=+<^&dlBX{ie6Gt+rI9 z`Q84|cfDZ|1K<0nE?=FM^>ZsWAD2*C_&d1Taa&wK-0|dHQ(EJVWxeW`l!B_fg*$B6 zd<kFUbPHAR2FTyYiaK?(_4BL$iMteaPJeO-ACd7S-%%#|VdtV%zvS+}e*Mbx zaL3cF-xht>kX@sw|DI*$jqlfumMy)-@t_4XupRU6{D#@~4tkXvi-j^8AGp2d{_JDk zlp0w#$(zY~%d5GCrz>`DHhS}n-(X^2AA9N9Bma2bm}uz=Gvx9-ee^26;ia97-q(&A zy*NShX~pT^6ILnA*^zhg%Y>I{8+2Cxj}i-$S?&~|{N7r{|IvE46`v|EzZICUvV?Ds z`+R$&tJBTyE#KnJ5K>t14ps_I?s zy_%)r*@deeXUt^Xf>y){toL~M>=%3E0=I?FI^3s%{baxBGxMBxe}BC=w6Sx|U-|D* z(*#}8F2{TezMS~Q?Uo9|EsXBn(i*|Pt)NNVsT=zDO>sJk%dXB;OrgXdzuiG4U)0nEB zmk!@lp9(R=n!ZgakY{`&{M`CT=WXr{#UA(GH+=1s-N3JSpcOp73O4RqFCL=XHw|gGU>eUt4`; ziYLRDou>}&_@pplQmks*QzySWYxXS;&US3R)AY6Yb(WTKxu^eWuVm03B&`~&1Ho^4 ztmb+@ns#YU_wIF(*W!)|_x|%%KC1aS%UV^4A=avx!Mgl@@_os#4m$&+oo)ABw+0=~ zx9X4G0gWTB;#s9xHTgQHUoE<7v0}5vg_U;?t>>F}`Lo&6U0I(R1)gYkY%ymo{lCgP zec6!*Wsk*+_GV8z?RdMs|3l#AjZ1a}T6`2|J9Ig_v2Q_BfJUxtSmnHz$2RD^wtd|m zS`({yLe|DwA>Hv#(5&yi2`w+bd1M}Px6HUbcUQ@>k1yZ42{VMY@bBNk(qVo5F4Kde zSmTYyZMSwmv;WC;D8Grv$z}cB5aZkqkb&H0SAC>G%a=dNH2qmr@b0nx_YgN}=bbC! zl6Ki%YOa}-c)KpQ>a)UTTZXd>UkgRPyY@;=#_4#&j^A0w=ggVU^tMxWi$9mu)s<#< zWu~P%hTP3|;AXZt`p$2WeSfr;`#EO2lwG!$+D=uj;gXDfbT5Kw!Ng12jazP-D5i4U z4}JUdeeRp1{rmZ(o`fA#Fj}8m=(>~NeUr}W{wQ(3`O6M%XR_^lwU#}6ZN=i}N){__ zi8@z%&s;rm!babBJt_>hVt)Bf*mBFvFjd1{ShIWg{_WDIe@bpJKHwqqHDc4UUC+aX zrlqnb`+hxQBlAA|VBGPqxq-O=ncT4?!_AOCokm8)%drJ4^2Eo8n zLVpYQ#5Dwdo}Ur;V}FD4Op~M^0lS?Rq)Hmp+_<3K*y%XWdBRc9GS$~BeXd`&w>P;f zm+2F*Gjz7M{p>fw>ZXhrmQCtstY^EE5^i$XXUR^JcM%K)^?W(}7SrU{tk$)^Rni<0 zW|kQlV;R`8V0Yj4Qmc!vA5ZI=QY)A^>7|)xF`w4{>&rG>o>YBF<>IqVD?5t)53CMJ z6LOq)6dsepRfNl&dHM)N=n0DTP|4Lxc20#m^SN43$6DFt-r8LH~%ij9)SWK(bu={ z?YIPXTZZhtf6gJxSZij9mm4fm%UjvJZ7#n|>MmcSBViZP9teMo`}phViRy~lUpt&T zj;bv%;!I(xINl_~%wr_zFI7F&F}i=!NBu7rPuH*A_UQ7eKdKKR7CkC(0xb`^+?y0R zbJf~!KfFSpZ+sBH*lNDcny9*Xf7OzgXBc_z{6HyND;%^1kO{dxAljt2j)8Hu5Pk3SN9HE-WHPeSQSS84QcyIeZX13R{2VNYt>Y5WcHG~@)mnEC|*Dvt*|Gn1d zRppX(3ez>E_`aNUwA>SXxAg!k*iv8j>VIcFcl0Kphe9HI!2}sS}5uQ|#BgSX4$JOhQ_;Kwk zQNe}MA9@cg7W^6W@Xju4U-Pci)UIu}y7XE1Tof;{)wmNA5NfU-XPK+@*WM*;mYxqyUTFiSrjIWRP%JNd8Oa!D0Jztu~~bn8lJ%|K((+^NF`I{T8u%Sq~j~r>e{E zz5m<9%}bYjl>g|p^-741ipo>=KmAQRzdr9+uq^u6rc1qAm$vQrw0_00*k8N%v(#=_ zuuAbAbBo#ZIiy|w`=IvkJp8^>%6YL zm8Dg`BxAwtKV`S&ii_3Ca+S8#-;%Imm~QGR(Q!jQUvbZk(#g{eUae6)Wo%TMy87jT zpoyUM=HSD$uG?;NP%GQ(HbL`%#^sHrtS7bxN&U;)leE_K>GTF24<{GH!Y5mrEKB=m z1UD1aB*4GgIU1EjTPdu5bKa)>q z?hTd$>hIOqyfXStd6r2;+|lS-uE=S+_MPFqtU22riuyB%dUj;auGlEaI(<#V+rt}m zv=?vHSvN;x-Ze&NX8V~hx|*7rwIBP2Uw-yjZBZP{3E!gPl9Kf)w`}*G<5qqrrM9-_ z=Dw&m_OlsSJVX<}UMUo?oi@YA#X@XGLjljJ28Q#0B#!#3AN$?7MQ@db*g`|k%WCnZ z$$39l{Pf?V@-SxF)M=mt;>@Ng`)h%=n!gRZ6A*Y+IJ;VFNtXbpZJUAe29K7z8t2RW zH&(NSUw1fX>{Itjw0bdrpDzOsXHB8NvM99v*t6r}?H%Wy zRJ=d3%S|f$PQOq&%Le@xb}qB0b5*aNUgT*xKXobN-t1|&ul;&1)pao6zKa;EbM-kvdAaygyP#1ou?Na@V@(h&u&3?d2w^A2`j#HhuYn{ z+#kbocf*0dRcCtF`W<3&Trg)}UBq!Eg?Fb~r%mMznYitidA{$pv+^0mdv1h3y7xzP z;mw`8jkD{+bY@NN%;9&Cx@;=^`GrlwAGuD)7tPrcFU*%Nc`5#}?`lwV<*XI|j4SVR zMn*)0n%^&xj+&O4+pV=O^r5w<)!auf)VV=psoN8ZUz!I-ZaOYsdUmP!q4)P*6qlPH zpIw%-RBtvzib5_A@6nIDuIUO`&d{)*x~U+2&-ty_LjK8T7+wGVFEKInum9O?(^6+% z-n3#-RLkl~WljsX739^1?n#Q>+xJJ{>h(a#frVwE=Y5ie8D7VN=SOaR4lDCb_7R&K zA8~cVg_66v+Ba{BO^sisbY1;@&!(#D5AI~P&&|JoFgiAeDPiyVwxgHrWj^m&KFNrE zXE#?>qGRY`A^E=4$jpUXm;GUy5U=)8>d&;tuL>9I=T#gmxx4nt#Fe`rN7kBv&H?Lu z^)r^ab+VJl&ZS$IDgDUp($!|&ay6`u`-!;co}bkgRrB`#jIQ3y+3=N-^|RE!J};x| z@5>uhb4^9>gO8aE^7efy6S`M*=JmMHx=AM%*lYj3!E)f%@+RHV`{7R(s>}-#$;$xU zi12b(beKR?mex9fEoLj%$4zzT>OS`G2*@=l!VKB-mq_VLeci|LtH|j5>H^o>i@LHy z-~JHMHeR8mYbt8ap<=zW_Uh|1znfwc&)<@-o_IEBm4#Bp$5U&gO>CcdyfK`>JXw1F zzxc$92aDevJ?)hqWox`Vx8k_2!=~dqa>VCIvWA`xKiX?}5PTpfzr(gD(OoX#^5qqK zl9He4@~^gfUZ($cris$0wb!nh|KGVX?Uumew<(deTR^8+-fAoTbN9em`?eF)g?9Zk zpDN>3oUv|a*e0J%oDSQ9ygDoEFCV+W__0gu?CNTV3rqCxY8QB^Tl((_G}e{0UK}|s z-|R|Q-^GaI$sFk_zwfNFT3?WNcW>co-MN+w$A4;j%-q?X!?jC?8#H%Jy71@s zy2@Wk2?yoo&QX3h`L%e6N_xA==~v6I-TLHdc$Ka7Qp)`3DX+VpnW}6nzVB$A=g3(a z&%FkGh^^{f`>MZ_%%;~FPxKebS@+wl!2gnS=&>rj)3>s;{QQ3H)Lktem8I?Pyp-=p zz^3!MC+;rZex+W2;-*_7y9B=cc~`%c(_tFu?ubjjJ~cW#tgcruy%l3-dhhuXsq?4v z|0{i%(zu~r%jQB<^uJhZ=L24*vDUw){tpOw|Nii`opl*6I;!F)zWUMp(6n0?XWYHY z!_V}qPoMayo6Qd%d73ik{(`E$g#z>XtS$=k&-;93Sq1CXl_@-rY*>40@7H$}f^Uox z*`M0+mhpsJ-{*sR_Dd(MC||#BriI46f`4ui3ZqIgPF9U0e zz^eMZ)hqX8gJugQyAGZ*0tG#R7A>` zywPd7wS)DocEg&KbjB3Wq1Ox_cb{p!+c8fkc-B0RCp*@5YxTK45xj87ve~oWf7kVD4uvzHxKXzkc(50N^*U|)kb1B@;h|AyotF5Vg zzgyRF)u0`q&S3StVxw=FjR_N^+H+ zGMDLO#Af>*RocCIf#`WqLp`@!D=%!KdXH|&*E;{*dC%R2WZ$j_9W|)e&)~Y(F8Itk z)(N>eOcQdCynfyP$N7QXB45>HAx#_5%0RDkKF+7v-k)V$Wp(mrezxyV)~MY5->?17 zH1*5&J!)=s>+}ERhg>y_a&unvygCEANXB;s-`U_}LSLtStMr;L%Q2%g=%II>YUut= zf0PB&SDLj6RqX;D@OXIL!Ag0?cP6o~^`baFt^RsgJ8a$Rm2(oUq_j=Ji7VHo(zZa_ z;8uxEiEr-T==2c#vJ*>rIeV{P44v%0*pmNyaa)rw!=@ftmKmugLBAMIWd610@PGU< zP?I~~XT=VswWgxsodr{0{(2wU|NH9aXJTjTKZ#ym7;*Bu+u1G6v3KTk)@UAG*J{c- z`}K46O&j@RU;plRnzQt8edi)E7Ths|K;^quHEZN{o|+Ow_~ z6fcs$u)I%O<#@G3V$go@0so1?r=_CV;I|a`6vP!M#T_fOR|}q3WqFZ*%hhS~7ipC< zt~67~SO4JsG09$gM~dt0mkB!m*Mf>V^YxF7T6Z2`T=w|pX+D|1H4o>s{TBY)VAf{W za=N}jxwg~c#@Q(!8kIV~+C;nr-F4$KdwGv*@x2c}H>`Jj^El|zoDW;}gBG-w+C0xc zuyEP!6!~qdPaW!ui7WtZq@KZd?A3nIuF!e2Pp7Up!}{o5+02?>n;y&y22Io*njc~M z)nLz!`kMOu_0TI9lpt3wWE5vuc{#2A_4Sg~6>Co~WlsO!)yW^P2(Fv{nY}UCXZz{8 zHw<-eE$^75`TbqJta)SA`qYCTAIn_-IqlH)UDq~y*f!bS5Pa(0$@h)CqcU@+*B2Yjyj_P&9>c6!D6ja(;y~~gD!{y zE)gbG!Tt~eL`kmd5nBOk^3=3Srt{V&6CHY0;XBx-0%%JNdEB z^w*;{aUb@{XRqncGK)LE0CGOK%o!H;=HJ7c}1^bPKK zgiQjau8bumk|*3JmF&va;H!^k@%_RKY8FnoTWQ{PV*8n`LF+X(h+-+e~J@8~1)@9{pYQ{kx^?x@4}Z;`Ax+Wr{KnuWPYi!f`hBdQ;rIB?j`7 zCoP-FcKf8)qLLpEBkV51E{CZ<`0~#xpUA&u`F2-KLofXFt+KheH+#9l|Bxr&XPf2! zynf`Bsq8Z5Qz}*0b;@gBzgDlZh!^{jvWoB3?S5X1D#!J^|AhP6O^CSnUf^$tP;#xW z*P)db*2lRTd>J;in6(uZ-ele3Y4=s@inX_w@?I&=`&HRTGpv4^e&|#={i2YZv3=<^k`0!(d2{T}`fp_uH9>=_CRO}i8Ye#< zJ8`?V!DkC+jv44y5AfB1Nm_xs^Yj0eWp55p!B3E?x zT)WWS8>-Q{5eADjW(iw|hWLv%ed6fCQCK z67F7+KU|fcZ<}-qdK^M=w@t1RFir6|#QEeT`LBi>}{J1fRmXm-U3P_`W$h zs=fU)toKjVdHK(Iv(S>Wshb1;+FveI_*K2=lddNB`!}I~u3EeX4ScprHym8H`r+gi z2eewPL}S;z{po!?W}D7J-f5SvE}NCua<5UtOBXEV1ox)D*4DQyogSiJ0F_v*OXNFk?I+bW7xy9$T}o#CTnF zOyP>xFW26>FUuP|xyerZM6iax^1ZCHrD5W65&Q3zB-CEqRuIK#X*v00iP56TA~U;+ zO5Ebgr~FQ~v=r~%`{U#F>c1C3eZHO2Pb@B9WpMbhQ>1-=I>^46-<`L7^Hg4F-B|2< zd}Dj=iWr5Zz4dQTx8Kgv+P~sOlZmXvwb!qjYWXk4Cs#(~qzmdov{ z3;uav&YU@i1M+1m+B+@WoBWv67#I7R#|W))pBZkk{@?l9pBvU`*$VsDd298S2J8&> zlC}q13-YEE=URqkv-*^m!R*j}7@dqb-D)1Jmzmo0xiO11uGa{{!H=9cQkJ9p+< zu6ym)&Tyotq@wS6Z%Cn=hLzJrLB4YwN2OVdI8G#HnuT8W4|JSX;<8u%Yntr;a?o~) z&B-TB6{atFQ2IktlXLm+3TbV|mEVqTW?D4yLfe6B{O|XhR{UZG4Md!7zkYR^yzVO< zfiEGKMK^9bwD{4{XW}uN7Wv-~&jnrGQ_i5eOnZ~O^b(ahynD?fK{biP{M9mN*4@4B zbNr%mqWP(EO}^`2%5Gh;j>=v(pYtBm1CGmAHyT~y)~ZUqc&Bam?@x)LEul;AJESre zG`u|ZCF4PZBIqns(4AjiS7mSL*zetY`o8Vq<^?tzwtP6bPcserZ zR>gYo9egYb+!;&02u)yJ_V|VA#P))xvyC!S*Dnv-yLf5ZF^%>c+eKF~-F3TqN=24q zf8pZ1(){Tk?oKvq>nhkOYH_-x_16O-O-_9y*%ncq-&_jP9ZS;7=e=6#vUjX(y}i;acf$@R~D*#;W4;aB4gK~ zbmve5qy3Bb98b@BJ2_ihecH!WWO*v$vgqcXi1nw|8k$E{f0(n}?CW!fU0I(RD=Mcq z*PVZ3^>1ILo7bVqKJ1CCr_~rD9h4WoJ)m&8@JEKM*8Rzsj@|$I-2pT!-791Fq`0d_ z-FcFV_C9fe*gH2*=7I(}SMO(k9d|b0@Pp!I(a#re2ML{9lHInI-;D7@!LF=N8GG*7 z^(XihdKd2SS=KW9SG7XulDE%3`7bC^Jf#`AXH&wmE=GRMJ5d(f|26JgXSz{6R#fZg zW4DB5FHf;L?Y$Tzbu+<=N818ynwAM~zq;>n!4%QP{SI%egD(B)l4q|0E$lz}agq3n zBF8q{tEr2_S$~$QUyPTJ+4_jro^J){_OqS#On;-?)Vq`}FWK=?6O>vvayqnKUbgAH z;xF~fg(?UArp!KZzjm!p%;v-Gk4tkMZT|e|Z~E=)dY-MwH~f;92m7hRuU@?8;^zAo zoxlCr-TCW8P0M2$i!#G}G(&&xnPT=WbF#9w?F|-%!3njLi#q z58f`mPpTK&^}|yIQku0Yf1j&-KP`R0bdTwe z>qU*<;@qNzaZ7U8<=DQ;t{QTX&bGPq{gDzZVoEz`_wfca> zWl=Nt|GKKa>NEQeK`twGbJA~$y~=%qp@_S3r>W7#@4vUJC;dHZ%wDYXJntbu9oZNlb?s#f0=e%^0GB!Jdq%!DaZcq^s>hu zfo~7Y?YJ|iXQdx#T&OhVd7z({-z3!U4`fk*Ymm=+U55=`@UF2OvE-*)K#11^THXd4`g2Rr_cE%dPBR* z?(2ouj=OCu_KMdme%s(G@iwZ$a?vAQfjdkOm|pX%8@l`pGWzLz%}_&5he2jpYl1Ax z>NUkGmv)!4MlJi3ZTg$j=SntzTydV()LAn>Es_j4!;rGfDSe8(@|^I0raRpV*%U2! zrYbk=V|!uO_@+IfK5$X(QN^3}Cv!jgY?0xweK<>VhS8Twhy83P!Z>Qr)%D6pO>Sr6v>*fuyoFoOEa%=YS`&8%volrS?u?|u1n?#}a$ z-Ga+x@9PzRJ7QP8cj8M>%>CHnI$?@>-M{_z?@Ecq6>vwl!#IOn#6TSeoI%=N-uMRo#zbwl=@>FZ5irjfMj$)WN!%>iFOK6;?R zyZQ5jlk3+Os~j+JneVjZyXUszL-VJLZL)7T5kLRi>?ZRCKU5pcTz1LcbV`32zszLT zcfB76lvc#BKloxD*(C3w_GdPyyuJJxH{pGp53&QcMD%ZeCu-*vo?^@-aOkWy}`UQ>M!@ZsB#XW>I9D{i*0`pYk>N z7S*%t&lh_#ub9rfsI@`I%2~f9!af}2n4I@$s_7hY=j_$|=#J7#zAg8%=H zilpr~5`GlRc=4wI?}ACp2DLTphwe_|4G%Ys{(b241)EQ$4*LS*{5+RmT;CmEam0!> zRGyo`Ju`fRTvvYN^A*xv*A;%~?Rj`yXji^tLFd`!5$!DZr~2%;W5ousS@FBPz~AE6GNlRX3-}jk_kmJGjr%lfk7PK?Sv{dCgEp}cS%YU)|ndfmsi=sW3PDan-KX>TKvP=o7d2A1a z1>QR`h&vPr3dFLUXbp&o*_YtgzjgV(q?IX$dkyQhz3;0O3i>7LkjHMI|L*fu+o{V{ zXa8?1|HrO)j0AOD~2 z#N$x#>dS)+MuVk);)CvqzMo??OLxEc+2zkp{@J%MYHon2+^(jh3}H+=PW@swxT0A% zV=JHI)#FSD0&hvm4;8J@$z0)WoAN2=`TeWhj`-XN`X}@~-h@U}7q)H`t2WQ-?A`UaA6*s?KT()Ir;J#ofE^Y?# z&gZ}QUvqMpIk}~IE-RS4Ty5aZPF85#gWuC(ClS_I+F!@ly7;im+a* zxefD#txqk*-f3-8-OwGuu){+syguSSQ;Keo^K>t@7_FJ_?nkmqO?~zD>2YBbLqV7S z_pFX&sN5~~`141~Cv&IB0V4+XHO5Dc1M;|@D2Xxn6|IZ9&z~UKHAAtk&NpbDt5?7a z#+J#9>Rw+sJ=3fuz`SwP(Z1N%(0nc3< zT>t!;DiFi|K=QSZXrIQ*`LGd+!+daob_u2MkI_?!CW1X~}1O z&t(O|CbNRysZQwGsXNK*R5^FAAUDHxm(u@h-bt`XFAU7&-G6`UZL0v?6I=E9rd;p7 zoZ%9tUbN}COWUlsHK9figEGG4={NqW^I1~5c|U6?CpW|OEBX8l&P(-oJ}Noc;;nl7 z<+G}n7vIeMd7k0dn&d0G2lZs;?z(B^cD8-)+Zyjbf22(2&x(7e`e9e;vi@UJ8P+8- zKA5vBnrVml0V5uh>DNB|j?F9gk#4PdsqVVsPU76fd7by>JrR``<*%>1&-w8%Z-$+z zw3fN^hSOG;Uozh0K47ZDFeliY)uFtS_y6)E{N_{g8YicGwrBsar?hX^;vY+&^7Van zdG?*x(`>)w%YwR<(;059HJp#2`B^LENFJ^tZv}Vtb{;5mC)UsqNYPv3DOfBtLBCvn4w^8ftfFsMj ztdkyv@cO@hc*AmfL_J%Gtqwy>(C*S*>WViy)bh8baG85<`+WVHv%l2IHx-hrX0Iup zA^c}COY7WghLhZi9%@hGoSt-Ic4VB>jrhsJ)#vg}?*&9KSg3wWFKK6t>|Pj>sd>MN z&urUD7nN+rIa~S?GZ-$F^p|$1Om3CU@0cIod~n&NvLj9Ak8Bg~>KQNNOkjL4Nn=^$ zt|oJa$nJy7Za#W`eaDSuSDT(3Y7VeTnS1yh^MNYQ?blz<`kpdz_3i#n`Md_6_^@}X zA8yY$cZBJHBFOcBOLtW>=p4TJcd6;0@a^)O1Rdl?;MES|8e5Z+~W%i(w^@& z48C);$;fUJcf0EB`#nBC*q6pJeM&0jli4V{!FdPI1;tyEGHeBLtnC@i%fD|{^v=z+ zo6#at#xx^$?ozjJiCO<`#HMZix-X8&JmkaO`iMUtm|kxTDBZwN(DLrr7ZaX2vrg<^ z;g$Wv^_r*J{=-!=y;_|(9#cW_wV*q3Ld=i8QWq;@PA=Pq9p;h&(6YWH%_#kRLM7+JJ)eXQ z{mIl%&}T?5uudr7^V|94+X~_Q6w?X6lp0Q6^IVm}5tr3l${{ts|88GI(klxu)8p0* z>jd7ircC_tP{~t#+lNQ+E`lA4^K7aeYnJFrEF|UEr$xJw}C9&QDLyy1(&UpyAQlJwJr6 zs*5qWe9c<$vpPVf^7K>Yjtv*BSOjZL#28#kz1GzhGBoX8Q|vS2)ZCa_r;z;-3<6g- z-`!ltvf#Ycu^pUc3+rF?uT2)Xc>J-7!y>EYt#^_-{++QpzN2E-jNM*BSIxy3TsRlZ zJ@}bn-S%^aZnux}uiwn)=(6~+GQ%R34;zF$yz<#2Gv_vRoa&9G>R>s*Hc^G~hto`49O|c{6PXvR2>VzkU5?8F*c8+nXb;`MUj;|$LOzLu2 zeWnDg5-4Gp4PbE-+%bv`(L8TCu%JynfM>Bu0lR36Gv{ zXRKKFOg+p-`)1$TYme0zbtW@9OevYTjv?!p{zneYJBhYAN~?H44xGH;0`rNmmdbx2zht?sch7*F9ZdHpMC{MC(d}OyX>$u?SLZQIY4GaoJCmlY2 zUr?djrY!H!(tGW&@}gIXj1Er(c78j`$nkE~ug1`KQ(}Jfef`m~qAG$xU}xI*HS&xv zR^R%_vipMl*W=d?D=m8UD2~B}?~+rVp-X+()aJ@v5tmwZuO%xjdX>cJ@I>P+&ph^m z+Sc})dUX|ZzeaSd`1OOM;Y6rh7}JGQd`jAD+Z|m#CV&)rw!dYlvwFz9!bZ1l-`Oe| z7ZH9j1{b}_SG*XMg)7oQDW%Wf2B6Agg74$H#jlcuM9QL`t1kjYV^M72bp{K| zzH%M~)0cU#Z@7r)i!r$LO^&G$=xRUqk8RELlHaed?d;Tu(_!e4IvABj+iP)VuOvBp>^ zF!uu=Lm;zYqf_e7_J0n0Mfdy=HWdSz;`Zl{8N>O#-=(2to4DXrF>Z!MZXz$a7z7(Y zNkq22qoX6s#k#mZJG^XjDWB4&iz%`V9cy~ltSi}^phN~()b_PLH1g(`WG^OW1_lPz z64!{5l*E!$tK_28#FA77BLgEdT>~>+V}lR_6DwmgD+3d40|P4q0|7-7H53iG`6-!c xmAEw|*7VF`U|`UI+fb63n_66wm|K8Fk152G33AImGB7YOc)I$ztaD0e0sxYR@a6yj diff --git a/src/solstone_linux/icons/hicolor/64x64/apps/solstone-observer.png b/src/solstone_linux/icons/hicolor/64x64/apps/solstone-observer.png deleted file mode 100644 index b60165f389edaa3add3bb600b4ee5d48db5771a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4133 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 - solstone - - - - - - - diff --git a/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg b/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg deleted file mode 100644 index 9c4088a..0000000 --- a/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +++ /dev/null @@ -1,17 +0,0 @@ - - solstone — error - - - - - - - - - - - - diff --git a/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg b/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg deleted file mode 100644 index 6980c0d..0000000 --- a/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +++ /dev/null @@ -1,17 +0,0 @@ - - solstone — paused - - - - - - - - - - - - diff --git a/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg b/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg deleted file mode 100644 index f1dedf9..0000000 --- a/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +++ /dev/null @@ -1,7 +0,0 @@ - - solstone — observing - - - - - diff --git a/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg b/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg deleted file mode 100644 index f043bec..0000000 --- a/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +++ /dev/null @@ -1,16 +0,0 @@ - - solstone — syncing - - - - - - - - - - - diff --git a/src/solstone_linux/install_guard.py b/src/solstone_linux/install_guard.py deleted file mode 100644 index 9a23422..0000000 --- a/src/solstone_linux/install_guard.py +++ /dev/null @@ -1,210 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -"""Install ownership guard for pipx-managed service installs.""" - -from __future__ import annotations - -import sys -from enum import Enum -from pathlib import Path - -MARKER_REL = Path(".config/solstone-linux/.install-source") -PIPX_BIN_REL = Path(".local/bin/solstone-linux") - - -def marker_path() -> Path: - return Path.home() / MARKER_REL - - -def pipx_bin_path() -> Path: - return Path.home() / PIPX_BIN_REL - - -class State(str, Enum): - ABSENT = "ABSENT" - OWNED = "OWNED" - CROSS_REPO = "CROSS_REPO" - PARTIAL_OWNED = "PARTIAL_OWNED" - UNKNOWN = "UNKNOWN" - - -def _parse_marker() -> Path | None: - try: - raw = marker_path().read_text(encoding="utf-8") - except OSError: - return None - - stripped = raw.strip() - if not stripped: - return None - - lines = stripped.splitlines() - if len(lines) != 1: - return None - - candidate = Path(lines[0].strip()) - if not candidate.is_absolute(): - return None - - return candidate.resolve() - - -def check(curdir: Path) -> tuple[State, Path | None]: - resolved_curdir = curdir.resolve() - marker = marker_path() - pipx_bin_present = pipx_bin_path().exists() - - if not marker.exists(): - if not pipx_bin_present: - return (State.ABSENT, None) - return (State.UNKNOWN, None) - - owner = _parse_marker() - if owner is None: - return (State.UNKNOWN, None) - if owner != resolved_curdir: - return (State.CROSS_REPO, owner) - if pipx_bin_present: - return (State.OWNED, owner) - return (State.PARTIAL_OWNED, owner) - - -def write_marker(curdir: Path) -> None: - path = marker_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"{curdir.resolve()}\n", encoding="utf-8") - - -def remove_marker() -> None: - marker_path().unlink(missing_ok=True) - - -def _unknown_reason() -> str: - if marker_path().exists(): - return ".install-source marker is malformed" - return "no .install-source marker — likely pre-hygiene install" - - -def _print_cross_repo_error(curdir: Path, owner: Path | None, uninstall: bool) -> None: - lines = [ - "error: cross-repo contamination detected", - f"current repo: {curdir.resolve()}", - f"installed from: {owner}", - "", - "To recover, run from the installed repo:", - " make uninstall-service", - "Or manually:", - ] - if uninstall: - lines.extend( - [ - " systemctl --user stop solstone-linux.service", - " systemctl --user disable solstone-linux.service", - " rm -f ~/.config/systemd/user/solstone-linux.service", - ] - ) - lines.extend( - [ - " pipx uninstall solstone-linux", - " rm ~/.config/solstone-linux/.install-source", - ] - ) - print("\n".join(lines), file=sys.stderr) - - -def _print_unknown_error(uninstall: bool) -> None: - lines = [ - f"error: installed: unknown ({_unknown_reason()})", - "", - "To recover:", - ] - if uninstall: - lines.extend( - [ - " systemctl --user stop solstone-linux.service", - " systemctl --user disable solstone-linux.service", - " rm -f ~/.config/systemd/user/solstone-linux.service", - ] - ) - lines.extend( - [ - " pipx uninstall solstone-linux", - " rm -f ~/.config/solstone-linux/.install-source", - "Then re-run make install-service.", - ] - ) - print("\n".join(lines), file=sys.stderr) - - -def _preinstall(curdir: Path) -> int: - state, owner = check(curdir) - if state is State.ABSENT: - print("mode: fresh install") - return 0 - if state is State.OWNED: - print("mode: upgrade") - return 10 - if state is State.PARTIAL_OWNED: - print( - "warning: .install-source marker present but pipx binary missing — reinstalling" - ) - print("mode: upgrade") - return 10 - if state is State.CROSS_REPO: - print("mode: aborted — cross-repo contamination") - _print_cross_repo_error(curdir, owner, uninstall=False) - return 2 - - print("mode: aborted — unknown install state") - _print_unknown_error(uninstall=False) - return 2 - - -def _preuninstall(curdir: Path) -> int: - state, owner = check(curdir) - if state is State.ABSENT: - print("no artifacts to remove") - return 0 - if state in {State.OWNED, State.PARTIAL_OWNED}: - return 10 - if state is State.CROSS_REPO: - print("mode: aborted — cross-repo contamination") - _print_cross_repo_error(curdir, owner, uninstall=True) - return 2 - - print("mode: aborted — unknown install state") - _print_unknown_error(uninstall=True) - return 2 - - -def main() -> int: - if len(sys.argv) < 2: - print( - "usage: install_guard [curdir]", - file=sys.stderr, - ) - return 2 - - command = sys.argv[1] - if command == "remove": - remove_marker() - return 0 - - if command in {"preinstall", "preuninstall", "write"}: - if len(sys.argv) != 3: - print(f"usage: install_guard {command} ", file=sys.stderr) - return 2 - curdir = Path(sys.argv[2]) - if command == "preinstall": - return _preinstall(curdir) - if command == "preuninstall": - return _preuninstall(curdir) - write_marker(curdir) - return 0 - - print(f"unknown command: {command}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/solstone_linux/monitor_positions.py b/src/solstone_linux/monitor_positions.py deleted file mode 100644 index 91e370c..0000000 --- a/src/solstone_linux/monitor_positions.py +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Monitor position assignment based on geometry. - -Extracted from solstone's observe/utils.py — the assign_monitor_positions() -function only. Also remains in solstone core (used by server-side naming). -""" - -from __future__ import annotations - - -def assign_monitor_positions(monitors: list[dict]) -> list[dict]: - """ - Assign position labels to monitors based on relative positions. - - Uses pairwise comparison to determine positions. Vertical labels (top/bottom) - are only assigned when monitors actually overlap horizontally, avoiding - phantom relationships from offset monitors. - - Parameters - ---------- - monitors : list[dict] - List of monitor dicts, each with keys: - - id: Monitor identifier (e.g., "DP-3", "HDMI-1") - - box: [x1, y1, x2, y2] coordinates - - Returns - ------- - list[dict] - Same monitors with "position" key added to each: - - "center": No monitors on both sides - - "left"/"right": Horizontal position - - "top"/"bottom": Vertical position (only with horizontal overlap) - - "left-top", "right-bottom", etc.: Corner positions - """ - if not monitors: - return [] - - if len(monitors) == 1: - monitors[0]["position"] = "center" - return monitors - - # Tolerance for center classification - epsilon = 1 - - for m in monitors: - x1, y1, x2, y2 = m["box"] - center_x = (x1 + x2) / 2 - center_y = (y1 + y2) / 2 - - has_left = False - has_right = False - has_above = False - has_below = False - - for other in monitors: - if other is m: - continue - - ox1, oy1, ox2, oy2 = other["box"] - other_center_x = (ox1 + ox2) / 2 - other_center_y = (oy1 + oy2) / 2 - - # Horizontal relationship (always check) - if other_center_x < center_x - epsilon: - has_left = True - elif other_center_x > center_x + epsilon: - has_right = True - - # Vertical relationship only if horizontal overlap exists - # Overlap means ranges intersect (not just touch) - h_overlap = (x1 < ox2) and (x2 > ox1) - if h_overlap: - if other_center_y < center_y - epsilon: - has_above = True - elif other_center_y > center_y + epsilon: - has_below = True - - # Determine horizontal label - if has_left and has_right: - h_pos = "center" - elif has_left: - h_pos = "right" - elif has_right: - h_pos = "left" - else: - h_pos = "center" - - # Determine vertical label (only if monitors above/below with overlap) - if has_above and has_below: - v_pos = "middle" - elif has_above: - v_pos = "bottom" - elif has_below: - v_pos = "top" - else: - v_pos = None - - # Combine positions - if v_pos is None: - position = h_pos - elif h_pos == "center": - position = v_pos - else: - position = f"{h_pos}-{v_pos}" - - m["position"] = position - - return monitors diff --git a/src/solstone_linux/observer.py b/src/solstone_linux/observer.py deleted file mode 100644 index 7dfc45f..0000000 --- a/src/solstone_linux/observer.py +++ /dev/null @@ -1,834 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -""" -Standalone Linux desktop observer — screen + audio capture. - -Continuously captures audio and manages screencast recording based on activity. -Creates 5-minute segments in a local cache directory. The sync service handles -all uploads — the observer only writes locally. - -Key architectural change from monorepo version: -- Capture writes completed segments to local cache only -- No ObserverClient usage in boundary handling — no network calls in capture loop -- Sync service picks up completed segments and uploads asynchronously - -State machine: - SCREENCAST: Screen is active, recording video - IDLE: Screen is inactive -""" - -import asyncio -import datetime -import logging -import os -import platform -import signal -import socket -import time -from pathlib import Path - -import numpy as np -from dbus_fast.aio import MessageBus -from dbus_fast.constants import BusType, NameFlag, RequestNameReply - -from . import __version__ -from .activity import ( - is_power_save_active, - is_screen_locked, - probe_activity_services, -) -from .audio_mute import is_sink_muted -from .audio_recorder import AudioRecorder -from .capture_stats import compute_capture_stats -from .chat_bridge import run_chat_bridge -from .config import Config -from .recovery import recover_incomplete_segments, write_segment_metadata -from .screencast import Screencaster, SilentStream, StreamInfo, X11Screencaster -from .sync import SyncService -from .upload import STREAM_TYPE, UploadClient - -logger = logging.getLogger(__name__) - - -def _create_screencaster(config) -> "Screencaster | X11Screencaster": - """Return the appropriate screencaster for the current desktop session. - - Selection order: - 1. XDG_SESSION_TYPE=x11 → X11Screencaster - 2. WAYLAND_DISPLAY set → Screencaster (portal/PipeWire) - 3. Only DISPLAY set → X11Screencaster (no Wayland available) - 4. Fallback → Screencaster (portal/PipeWire) - """ - session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() - if session_type == "x11": - logger.info("X11 session detected — using X11 screencaster") - return X11Screencaster() - if os.environ.get("WAYLAND_DISPLAY") or session_type == "wayland": - logger.info("Wayland session detected — using portal/PipeWire screencaster") - return Screencaster(config.restore_token_path) - if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): - logger.info("No Wayland display found — falling back to X11 screencaster") - return X11Screencaster() - logger.info("Using portal/PipeWire screencaster (default)") - return Screencaster(config.restore_token_path) - - -# Host identification -HOST = socket.gethostname() -PLATFORM = platform.system().lower() - -# Constants -RMS_THRESHOLD = 0.01 -MIN_HITS_FOR_SAVE = 3 -CHUNK_DURATION = 5 # seconds -CAPTURE_STATS_REFRESH_INTERVAL = 60 # seconds - -# Capture modes -MODE_IDLE = "idle" -MODE_SCREENCAST = "screencast" - - -def _get_timestamp_parts(timestamp: float | None = None) -> tuple[str, str]: - """Get date and time parts from timestamp.""" - if timestamp is None: - timestamp = time.time() - dt = datetime.datetime.fromtimestamp(timestamp) - return dt.strftime("%Y%m%d"), dt.strftime("%H%M%S") - - -class Observer: - """Unified audio and screencast observer with local cache + sync.""" - - def __init__(self, config: Config): - self.config = config - self.interval = config.segment_interval - self.audio_recorder = AudioRecorder() - self.screencaster = _create_screencaster(config) - self.bus: MessageBus | None = None - self.running = True - self.stream = config.stream - - self._client: UploadClient | None = None - self._sync: SyncService | None = None - - # State tracking - self.start_at = time.time() - self.start_at_mono = time.monotonic() - self._start_mono = time.monotonic() - self.threshold_hits = 0 - self.accumulated_audio_buffer = np.array([], dtype=np.float32).reshape(0, 2) - self.capture_stats = {"captures_today": 0, "total_size_mb": 0} - self._last_capture_stats_refresh = -CAPTURE_STATS_REFRESH_INTERVAL - - # Mode tracking - self.current_mode = MODE_IDLE - - # Segment directory (HHMMSS.incomplete/) - self.segment_dir: Path | None = None - - # Multi-file screencast tracking - self.current_streams: list[StreamInfo] = [] - - # Activity status cache (updated each loop) - self.cached_is_active = False - self.cached_screen_locked = False - self.cached_is_muted = False - self.cached_power_save = False - - # Mute state at segment start (determines save format) - self.segment_is_muted = False - - # Pause state - self._paused = False - self._pause_until = 0.0 - - # D-Bus service interface - self._dbus_service = None - self._tray = None - - async def setup(self) -> bool: - """Initialize audio devices, DBus connection, and sync service.""" - # Connect to DBus and acquire the singleton service name before any - # capture, registration, export, or recovery side effects. - self.bus = await MessageBus(bus_type=BusType.SESSION).connect() - logger.info("DBus connection established") - - from .dbus_service import BUS_NAME, OBJECT_PATH, ObserverService - - reply = await self.bus.request_name(BUS_NAME, NameFlag.DO_NOT_QUEUE) - if reply not in ( - RequestNameReply.PRIMARY_OWNER, - RequestNameReply.ALREADY_OWNER, - ): - logger.error( - "Another solstone-linux observer is already running (owns %s). " - "Check: systemctl --user status solstone-linux", - BUS_NAME, - ) - return False - - # Screen capture must not wait on audio; the recorder thread re-detects - # devices still initializing within REDETECT_INTERVAL. - self.audio_recorder.detect() - self.audio_recorder.start_recording() - logger.info("Audio recording started") - - # Probe which activity signals are available (logging only) - await probe_activity_services(self.bus) - - # Verify capture backend is available (exit if not) - if not await self.screencaster.connect(): - logger.error("Screencast capture backend not available") - return False - logger.info("Screencast capture backend connected") - - # Initialize upload client and sync service - self._client = UploadClient(self.config) - if self.config.server_url: - self._client.ensure_registered(self.config) - self.stream = self.config.stream - self._sync = SyncService(self.config, self._client) - - self._dbus_service = ObserverService(self) - self.bus.export(OBJECT_PATH, self._dbus_service) - self._sync._dbus_service = self._dbus_service - logger.info("D-Bus service exported as %s", BUS_NAME) - - # Initialize system tray (graceful: skip if no StatusNotifierWatcher) - try: - from .tray import TrayApp - - tray = TrayApp(self, self.bus) - started = await tray.start() - if started: - self._tray = tray - logger.info("System tray active") - else: - logger.info("System tray unavailable (no StatusNotifierWatcher)") - except Exception as e: - logger.info("System tray disabled: %s", e) - - logger.info("Sync service initialized") - - return True - - async def check_activity_status(self) -> str: - """Check system activity status and determine capture mode.""" - screen_locked = await is_screen_locked(self.bus) - power_save = await is_power_save_active(self.bus) - sink_muted = await is_sink_muted() - - # Cache values for status events - self.cached_screen_locked = screen_locked - self.cached_is_muted = sink_muted - self.cached_power_save = power_save - - # Determine screen activity - screen_idle = screen_locked or power_save - screen_active = not screen_idle - - # Determine mode - if screen_active: - mode = MODE_SCREENCAST - else: - mode = MODE_IDLE - - # Cache legacy is_active for audio threshold logic - has_audio_activity = self.threshold_hits >= MIN_HITS_FOR_SAVE - self.cached_is_active = screen_active or has_audio_activity - - return mode - - async def _refresh_capture_stats(self) -> None: - today = datetime.datetime.now().strftime("%Y%m%d") - try: - self.capture_stats = await asyncio.to_thread( - compute_capture_stats, - self.config.captures_dir, - today, - ) - except Exception: - logger.warning("Capture stats refresh failed", exc_info=True) - - def compute_rms(self, audio_buffer: np.ndarray) -> float: - """Compute per-channel RMS and return maximum (stereo: mic=left, sys=right).""" - if audio_buffer.size == 0: - return 0.0 - rms_left = float(np.sqrt(np.mean(audio_buffer[:, 0] ** 2))) - rms_right = float(np.sqrt(np.mean(audio_buffer[:, 1] ** 2))) - return max(rms_left, rms_right) - - def _save_audio_segment(self, segment_dir: Path, is_muted: bool) -> list[str]: - """Save accumulated audio buffer to segment directory.""" - if self.accumulated_audio_buffer.size == 0: - logger.warning("No audio buffer to save") - return [] - - if is_muted: - # Split mode: save mic and sys as separate mono files - mic_data = self.accumulated_audio_buffer[:, 0] - sys_data = self.accumulated_audio_buffer[:, 1] - - mic_bytes = self.audio_recorder.create_mono_flac_bytes(mic_data) - sys_bytes = self.audio_recorder.create_mono_flac_bytes(sys_data) - - (segment_dir / "mic_audio.flac").write_bytes(mic_bytes) - (segment_dir / "sys_audio.flac").write_bytes(sys_bytes) - - logger.info(f"Saved split audio (muted): {segment_dir}") - return ["mic_audio.flac", "sys_audio.flac"] - else: - # Normal mode: save combined stereo file - flac_bytes = self.audio_recorder.create_flac_bytes( - self.accumulated_audio_buffer - ) - (segment_dir / "audio.flac").write_bytes(flac_bytes) - - logger.info(f"Saved audio to {segment_dir}/audio.flac") - return ["audio.flac"] - - def _start_segment(self) -> Path: - """Start a new segment with .incomplete directory.""" - self.start_at = time.time() - self.start_at_mono = time.monotonic() - - date_part, time_part = _get_timestamp_parts(self.start_at) - captures_dir = self.config.captures_dir - - # Create YYYYMMDD/stream/HHMMSS.incomplete/ - segment_dir = captures_dir / date_part / self.stream / f"{time_part}.incomplete" - segment_dir.mkdir(parents=True, exist_ok=True) - self.segment_dir = segment_dir - - # Write metadata for recovery - write_segment_metadata(segment_dir, self.start_at) - - return segment_dir - - def _finalize_segment(self) -> str | None: - """Rename .incomplete to HHMMSS_DDD/ and return segment key.""" - if not self.segment_dir or not self.segment_dir.exists(): - return None - - # Remove .metadata before finalizing - meta_path = self.segment_dir / ".metadata" - if meta_path.exists(): - try: - meta_path.unlink() - except OSError: - pass - - # Check if there are any actual files - contents = [f for f in self.segment_dir.iterdir() if f.is_file()] - if not contents: - # Empty segment, remove it - try: - os.rmdir(str(self.segment_dir)) - except OSError: - pass - return None - - _, time_part = _get_timestamp_parts(self.start_at) - duration = max(1, min(int(time.time() - self.start_at), self.interval)) - segment_key = f"{time_part}_{duration}" - final_dir = self.segment_dir.parent / segment_key - - try: - os.rename(str(self.segment_dir), str(final_dir)) - logger.info(f"Segment finalized: {segment_key}") - return segment_key - except OSError as e: - logger.error(f"Failed to finalize segment: {e}") - return None - - async def handle_boundary(self, new_mode: str): - """Handle window boundary rollover. - - Closes the current segment, writes audio, finalizes to local cache, - and triggers sync. No network calls in the capture loop. - """ - # Stop screencast first (closes file handles) - if self.current_mode == MODE_SCREENCAST: - logger.info("Stopping previous screencast") - healthy, silent = await self.screencaster.stop() - for s in silent: - self._emit_stream_silent(s) - self.current_streams = [] - - # Save audio if we have enough threshold hits - did_save_audio = self.threshold_hits >= MIN_HITS_FOR_SAVE - if did_save_audio and self.segment_dir: - audio_files = self._save_audio_segment( - self.segment_dir, self.segment_is_muted - ) - if audio_files: - logger.info( - f"Saved {len(audio_files)} audio file(s) ({self.threshold_hits} hits)" - ) - else: - logger.debug( - f"Skipping audio save (only {self.threshold_hits}/{MIN_HITS_FOR_SAVE} hits)" - ) - - # Reset audio state - self.accumulated_audio_buffer = np.array([], dtype=np.float32).reshape(0, 2) - self.threshold_hits = 0 - - # Finalize segment (rename .incomplete -> HHMMSS_DDD/) - segment_key = self._finalize_segment() - self.segment_dir = None - - # Trigger sync to upload the completed segment - if segment_key and self._sync: - self._sync.trigger() - - # Update segment mute state for new segment - self.segment_is_muted = self.cached_is_muted - - # Update mode - old_mode = self.current_mode - self.current_mode = new_mode - - # Start new capture based on mode - if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: - await self.initialize_screencast() - else: - self._start_segment() - - logger.info(f"Mode transition: {old_mode} -> {new_mode}") - - async def initialize_screencast(self) -> bool: - """Start a new screencast recording. - - Creates a segment directory and starts GStreamer recording to it. - """ - segment_dir = self._start_segment() - - try: - streams = await self.screencaster.start( - str(segment_dir), - framerate=self.config.capture_framerate, - draw_cursor=self.config.draw_cursor, - ) - except RuntimeError as e: - logger.error(f"Failed to start screencast: {e}") - raise - - if not streams: - logger.error("No streams returned from screencast start") - raise RuntimeError("No streams available") - - self.current_streams = streams - - logger.info(f"Started screencast with {len(streams)} stream(s)") - for stream in streams: - logger.info(f" {stream.position} ({stream.connector}): {stream.file_path}") - - return True - - def _emit_stream_silent(self, silent: SilentStream) -> None: - if self._client is None: - return - segment_dir_basename = self.segment_dir.name if self.segment_dir else "" - duration_seconds = int(time.time() - self.start_at) if self.start_at else 0 - fields = { - "connector": silent.connector, - "position": silent.position, - "node_id": silent.node_id, - "file_bytes": silent.file_bytes, - "segment_dir": segment_dir_basename, - "duration_seconds": duration_seconds, - "host": HOST, - "platform": PLATFORM, - } - self._client.enqueue_stream_silent(fields) - - def emit_status(self): - """Emit observe.status event with current state (fire-and-forget).""" - if not self._client: - return - - elapsed = int(time.monotonic() - self.start_at_mono) - - # Screencast info - if self.current_mode == MODE_SCREENCAST and self.current_streams: - streams_info = [ - { - "position": stream.position, - "connector": stream.connector, - "file": stream.file_path, - } - for stream in self.current_streams - ] - screencast_info = { - "recording": True, - "streams": streams_info, - "window_elapsed_seconds": elapsed, - } - else: - screencast_info = {"recording": False} - - # Audio info - audio_info = { - "threshold_hits": self.threshold_hits, - "will_save": self.threshold_hits >= MIN_HITS_FOR_SAVE, - "available": self.audio_recorder.audio_available, - } - - # Activity info - activity_info = { - "active": self.cached_is_active, - "screen_locked": self.cached_screen_locked, - "sink_muted": self.cached_is_muted, - "power_save": self.cached_power_save, - } - - status_fields = { - "mode": self.current_mode, - "screencast": screencast_info, - "audio": audio_info, - "activity": activity_info, - "host": HOST, - "platform": PLATFORM, - "paused": self._paused, - } - if self._client.is_registered and self.stream: - status_fields.update( - { - "name": self.stream, - "stream_type": STREAM_TYPE, - "version": __version__, - "uptime": elapsed, - } - ) - if self._sync is not None: - status_fields.update(self._sync.health_beacon_fields()) - - self._client.enqueue_status(status_fields) - - def _refresh_tray(self): - """Refresh the SNI tray UI. Safe when tray is unavailable; disables on failure.""" - if self._tray is None: - return - try: - self._tray.update() - except Exception: - logger.warning("Tray update failed, disabling tray", exc_info=True) - self._tray = None - - def pause(self, duration_seconds: int): - """Pause capture. duration_seconds=0 means indefinite.""" - self._paused = True - if duration_seconds > 0: - self._pause_until = time.monotonic() + duration_seconds - else: - self._pause_until = 0.0 - if self._dbus_service: - self._dbus_service.StatusChanged("paused") - logger.info("Paused for %ss", duration_seconds) - self._refresh_tray() - - def resume(self): - """Resume capture from pause.""" - self._paused = False - self._pause_until = 0.0 - if self._dbus_service: - self._dbus_service.StatusChanged( - "recording" if self.current_mode == MODE_SCREENCAST else "idle" - ) - logger.info("Resumed") - self._refresh_tray() - - async def main_loop(self): - """Run the main observer loop with background sync.""" - logger.info(f"Starting observer loop (interval={self.interval}s)") - - # Start sync service as background task - bridge_stop_event = asyncio.Event() - bridge_task = None - sync_task = None - if self._sync: - sync_task = asyncio.create_task(self._sync.run()) - if self.config.chat_bridge_enabled: - bridge_task = asyncio.create_task( - run_chat_bridge(self.config, bridge_stop_event) - ) - - # Determine initial mode (default to screencast if check fails) - try: - new_mode = await self.check_activity_status() - except Exception as e: - logger.warning( - "Initial activity check failed: %s — defaulting to screencast", e - ) - new_mode = MODE_SCREENCAST - self.segment_is_muted = self.cached_is_muted - self.current_mode = new_mode - - if self.config.start_paused: - self.pause(0) - logger.info("Starting in paused mode (start_paused=true)") - - try: - # Start initial capture based on mode (skipped when starting paused) - if not self._paused: - if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: - await self.initialize_screencast() - else: - self._start_segment() - - logger.info(f"Initial mode: {self.current_mode}") - - while self.running: - await asyncio.sleep(CHUNK_DURATION) - - now = time.monotonic() - if ( - now - self._last_capture_stats_refresh - >= CAPTURE_STATS_REFRESH_INTERVAL - ): - self._last_capture_stats_refresh = now - await self._refresh_capture_stats() - - # Check auto-resume from timed pause - if ( - self._paused - and self._pause_until > 0 - and time.monotonic() >= self._pause_until - ): - self._paused = False - self._pause_until = 0.0 - if self._dbus_service: - self._dbus_service.StatusChanged( - "recording" - if self.current_mode == MODE_SCREENCAST - else "idle" - ) - logger.info("Auto-resumed from timed pause") - self._refresh_tray() - - # Handle paused state - if self._paused: - if self.segment_dir: - if self.current_mode == MODE_SCREENCAST: - healthy, silent = await self.screencaster.stop() - for s in silent: - self._emit_stream_silent(s) - self.current_streams = [] - if self.threshold_hits >= MIN_HITS_FOR_SAVE: - self._save_audio_segment( - self.segment_dir, self.segment_is_muted - ) - self.accumulated_audio_buffer = np.array( - [], dtype=np.float32 - ).reshape(0, 2) - self.threshold_hits = 0 - segment_key = self._finalize_segment() - self.segment_dir = None - if segment_key and self._sync: - self._sync.trigger() - self.audio_recorder.get_buffers() - self.emit_status() - self._refresh_tray() - continue - - # Resume: start new segment if needed (segment_dir is None after pause) - if self.segment_dir is None: - try: - new_mode = await self.check_activity_status() - except Exception: - new_mode = self.current_mode - self.segment_is_muted = self.cached_is_muted - self.current_mode = new_mode - if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: - try: - await self.initialize_screencast() - except RuntimeError: - self._start_segment() - else: - self._start_segment() - self.emit_status() - continue - - # Check activity status and determine new mode - try: - new_mode = await self.check_activity_status() - except Exception as e: - logger.warning( - "Activity check failed: %s — keeping current mode", e - ) - new_mode = self.current_mode - - # Check for GStreamer failure mid-recording - if ( - self.current_mode == MODE_SCREENCAST - and not self.screencaster.is_healthy() - ): - logger.warning("Screencast recording failed, stopping gracefully") - healthy, silent = await self.screencaster.stop() - for s in silent: - self._emit_stream_silent(s) - self.current_streams = [] - self.current_mode = MODE_IDLE - - # Detect mode change - mode_changed = new_mode != self.current_mode - if mode_changed: - logger.info(f"Mode changing: {self.current_mode} -> {new_mode}") - - # Only trigger segment boundary on screencast transitions - screencast_transition = mode_changed and ( - self.current_mode == MODE_SCREENCAST or new_mode == MODE_SCREENCAST - ) - - # Detect mute state transition - mute_transition = self.cached_is_muted != self.segment_is_muted - if mute_transition: - logger.info( - f"Mute state changed: " - f"{'muted' if self.segment_is_muted else 'unmuted'} -> " - f"{'muted' if self.cached_is_muted else 'unmuted'}" - ) - - # Capture audio buffer for this chunk - audio_chunk = self.audio_recorder.get_buffers() - - if audio_chunk.size > 0: - self.accumulated_audio_buffer = np.vstack( - (self.accumulated_audio_buffer, audio_chunk) - ) - rms = self.compute_rms(audio_chunk) - if rms > RMS_THRESHOLD: - self.threshold_hits += 1 - logger.debug( - f"RMS {rms:.4f} > threshold (hit {self.threshold_hits})" - ) - else: - logger.debug(f"RMS {rms:.4f} below threshold") - else: - logger.debug("No audio data in chunk") - - # Check for window boundary (monotonic to avoid DST/clock jumps) - elapsed = time.monotonic() - self.start_at_mono - is_boundary = ( - (elapsed >= self.interval) - or screencast_transition - or mute_transition - ) - - if is_boundary: - logger.info( - f"Boundary: elapsed={elapsed:.1f}s screencast_change={screencast_transition} " - f"mute_change={mute_transition} " - f"hits={self.threshold_hits}/{MIN_HITS_FOR_SAVE}" - ) - await self.handle_boundary(new_mode) - if mode_changed and self._dbus_service: - status = "recording" if new_mode == MODE_SCREENCAST else "idle" - self._dbus_service.StatusChanged(status) - self._refresh_tray() - - # Emit status event - self.emit_status() - self._refresh_tray() - finally: - # Cleanup on exit - logger.info("Observer loop stopped, cleaning up...") - await self.shutdown() - if sync_task: - if self._sync: - self._sync.stop() - sync_task.cancel() - try: - await sync_task - except asyncio.CancelledError: - pass - bridge_stop_event.set() - if bridge_task: - bridge_task.cancel() - try: - await bridge_task - except (asyncio.CancelledError, Exception): - pass - - async def shutdown(self): - """Clean shutdown of observer.""" - # Stop screencast first (closes file handles) - if self.current_mode == MODE_SCREENCAST: - logger.info("Stopping screencast for shutdown") - healthy, silent = await self.screencaster.stop() - for s in silent: - self._emit_stream_silent(s) - await asyncio.sleep(0.5) - - # Save final audio if threshold met - if self.threshold_hits >= MIN_HITS_FOR_SAVE and self.segment_dir: - audio_files = self._save_audio_segment( - self.segment_dir, self.segment_is_muted - ) - if audio_files: - logger.info(f"Saved final audio: {len(audio_files)} file(s)") - - # Finalize segment locally - segment_key = self._finalize_segment() - self.segment_dir = None - - if segment_key: - logger.info(f"Finalized segment locally: {segment_key} (shutdown)") - - # Stop audio recorder - self.audio_recorder.stop_recording() - logger.info("Audio recording stopped") - - if self._client: - self._client.stop() - self._client = None - logger.info("Client stopped") - - -async def async_run(config: Config) -> int: - """Async entry point for the observer.""" - from .session_env import check_session_ready - - # Pre-flight: check session prerequisites - not_ready = check_session_ready() - if not_ready: - logger.warning("Session not ready: %s", not_ready) - return 75 # EXIT_TEMPFAIL - - observer = Observer(config) - - loop = asyncio.get_running_loop() - - def signal_handler(): - logger.info("Received shutdown signal") - observer.running = False - - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, signal_handler) - - if not await observer.setup(): - logger.error("Observer setup failed") - return 1 - - recovered = recover_incomplete_segments( - config.captures_dir, config.segment_interval - ) - if recovered: - logger.info("Recovered %d incomplete segment(s)", recovered) - - try: - await observer.main_loop() - except RuntimeError as e: - logger.error(f"Observer runtime error: {e}") - return 1 - except Exception as e: - logger.error(f"Observer error: {e}", exc_info=True) - return 1 - - if observer.audio_recorder.fatal_error: - logger.error("Fatal audio error: %s", observer.audio_recorder.fatal_error) - return 1 - - return 0 diff --git a/src/solstone_linux/recovery.py b/src/solstone_linux/recovery.py deleted file mode 100644 index adb7607..0000000 --- a/src/solstone_linux/recovery.py +++ /dev/null @@ -1,206 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Crash recovery for orphaned .incomplete segment directories. - -Modeled on solstone-macos's IncompleteSegmentRecovery.swift. -Runs on startup before the capture loop begins. - -Improvement over tmux baseline: reads .metadata JSON file for accurate -start timestamp instead of relying on brittle filesystem timestamps. -""" - -from __future__ import annotations - -import json -import logging -import os -import time -from pathlib import Path - -import soundfile as sf - -from .config import DEFAULT_SEGMENT_INTERVAL - -logger = logging.getLogger(__name__) - -# Segments newer than this are assumed to be actively recording -MINIMUM_AGE_SECONDS = 120 # 2 minutes - -METADATA_FILENAME = ".metadata" - - -def write_segment_metadata(segment_dir: Path, start_timestamp: float) -> None: - """Write metadata file inside a segment directory. - - Called when creating a new .incomplete segment so recovery can - use the actual start timestamp instead of filesystem timestamps. - """ - meta_path = segment_dir / METADATA_FILENAME - try: - data = {"start_timestamp": start_timestamp} - with open(meta_path, "w", encoding="utf-8") as f: - json.dump(data, f) - f.write("\n") - except OSError as e: - logger.warning(f"Failed to write segment metadata: {e}") - - -def _read_segment_metadata(segment_dir: Path) -> dict | None: - """Read metadata file from a segment directory.""" - meta_path = segment_dir / METADATA_FILENAME - if not meta_path.exists(): - return None - try: - with open(meta_path, encoding="utf-8") as f: - return json.load(f) - except (json.JSONDecodeError, OSError): - return None - - -def recover_incomplete_segments( - captures_dir: Path, window_ceiling: int = DEFAULT_SEGMENT_INTERVAL -) -> int: - """Scan captures dir for orphaned .incomplete directories and finalize them. - - For each .incomplete directory older than 2 minutes: - - Read .metadata for start timestamp if available, else fall back to - filesystem timestamps (mtime - ctime) - - Rename to HHMMSS_DDD/ format - - If recovery fails, rename to HHMMSS.failed/ to prevent infinite retry - - Returns the number of successfully recovered segments. - """ - if not captures_dir.exists(): - return 0 - - recovered = 0 - now = time.time() - - for day_dir in sorted(captures_dir.iterdir()): - if not day_dir.is_dir(): - continue - - for stream_dir in sorted(day_dir.iterdir()): - if not stream_dir.is_dir(): - continue - - for segment_dir in sorted(stream_dir.iterdir()): - if not segment_dir.is_dir(): - continue - - dir_name = segment_dir.name - if not dir_name.endswith(".incomplete"): - continue - - # Check age - try: - dir_stat = segment_dir.stat() - age = now - dir_stat.st_mtime - if age < MINIMUM_AGE_SECONDS: - logger.debug(f"Skipping recent incomplete: {dir_name}") - continue - except OSError: - continue - - logger.info(f"Recovering incomplete segment: {dir_name}") - if _recover_segment(segment_dir, window_ceiling): - recovered += 1 - - if recovered: - logger.info(f"Recovered {recovered} incomplete segment(s)") - return recovered - - -def _readable_media_duration(files: list[Path]) -> float | None: - """Return the max duration across readable FLAC files, if any.""" - durations = [] - for path in files: - if path.suffix.lower() != ".flac" or not path.is_file(): - continue - try: - info = sf.info(str(path)) - if info.samplerate > 0: - durations.append(info.frames / info.samplerate) - except Exception: - continue - if not durations: - return None - return max(durations) - - -def _recover_segment(segment_dir: Path, window_ceiling: int) -> bool: - """Recover a single incomplete segment directory. - - Returns True on success. - """ - dir_name = segment_dir.name - time_prefix = dir_name.removesuffix(".incomplete") - - # Try .metadata first for accurate duration - metadata = _read_segment_metadata(segment_dir) - if metadata and "start_timestamp" in metadata: - start_ts = metadata["start_timestamp"] - duration = max(1, min(int(time.time() - start_ts), window_ceiling)) - else: - # Fall back to filesystem timestamps - try: - st = segment_dir.stat() - duration = max(1, min(int(st.st_mtime - st.st_ctime), window_ceiling)) - except OSError: - return _mark_failed(segment_dir) - - # Check there are actual files inside (ignore .metadata) - try: - contents = [f for f in segment_dir.iterdir() if f.name != METADATA_FILENAME] - if not contents: - logger.warning(f"Empty incomplete segment: {dir_name}") - return _mark_failed(segment_dir) - except OSError: - return _mark_failed(segment_dir) - - readable_duration = _readable_media_duration(contents) - if readable_duration is not None: - duration = max(1, min(duration, int(readable_duration))) - - # Build final segment key with duration - segment_key = f"{time_prefix}_{duration}" - final_dir = segment_dir.parent / segment_key - - # Remove .metadata before finalizing (not a capture artifact) - meta_path = segment_dir / METADATA_FILENAME - if meta_path.exists(): - try: - meta_path.unlink() - except OSError: - pass - - try: - os.rename(str(segment_dir), str(final_dir)) - logger.info(f"Recovered: {dir_name} -> {segment_key}") - return True - except OSError as e: - logger.warning(f"Failed to rename {dir_name}: {e}") - return _mark_failed(segment_dir) - - -def _mark_failed(segment_dir: Path) -> bool: - """Rename from .incomplete to .failed to prevent infinite retry.""" - dir_name = segment_dir.name - if not dir_name.endswith(".incomplete"): - return False - - failed_name = dir_name.removesuffix(".incomplete") + ".failed" - failed_dir = segment_dir.parent / failed_name - - try: - os.rename(str(segment_dir), str(failed_dir)) - try: - os.utime(str(failed_dir), None) - except OSError as e: - logger.warning(f"Failed to stamp quarantine time for {failed_name}: {e}") - logger.warning(f"Marked as failed: {dir_name} -> {failed_name}") - except OSError as e: - logger.error(f"Failed to mark as failed: {e}") - - return False diff --git a/src/solstone_linux/screencast.py b/src/solstone_linux/screencast.py deleted file mode 100644 index 3b7eafc..0000000 --- a/src/solstone_linux/screencast.py +++ /dev/null @@ -1,968 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -""" -Portal-based multi-monitor screencast recording. - -Uses xdg-desktop-portal ScreenCast API with PipeWire + GStreamer to record -each monitor as a separate file. This replaces the old GNOME Shell D-Bus approach. - -Extracted from solstone's observe/linux/screencast.py. - -Changes from monorepo version: -- Replaces `from think.utils import get_journal` with config-based restore token path -- Replaces `from observe.gnome.activity import get_monitor_geometries` with local activity module - -Runtime deps: - - xdg-desktop-portal with org.freedesktop.portal.ScreenCast - - Portal backend: xdg-desktop-portal-gnome (or -kde, -wlr, etc.) - - PipeWire running - - GStreamer with PipeWire plugin: gst-launch-1.0 pipewiresrc -""" - -import asyncio -import logging -import os -import shutil -import signal -import subprocess -import threading -import uuid -from dataclasses import dataclass -from pathlib import Path - -from dbus_fast import Variant -from dbus_fast.aio import MessageBus -from dbus_fast.constants import BusType -from dbus_fast.errors import ( - DBusError, - InvalidIntrospectionError, - InvalidMemberNameError, -) - -logger = logging.getLogger(__name__) - -# Portal D-Bus constants -PORTAL_BUS = "org.freedesktop.portal.Desktop" -PORTAL_PATH = "/org/freedesktop/portal/desktop" -SC_IFACE = "org.freedesktop.portal.ScreenCast" -REQ_IFACE = "org.freedesktop.portal.Request" -SESSION_IFACE = "org.freedesktop.portal.Session" - -MIN_HEALTHY_WEBM_BYTES = 2048 -STDERR_DRAIN_LINE_CAP = 500 -STDERR_DRAIN_JOIN_TIMEOUT = 2.0 -PORTAL_CALL_TIMEOUT = 30 -PORTAL_INTERACTIVE_TIMEOUT = 600 - - -@dataclass -class StreamInfo: - """Information about a single monitor's recording stream.""" - - node_id: int - position: str - connector: str - x: int - y: int - width: int - height: int - file_path: str # Final path in segment directory - - @property - def filename(self) -> str: - """Return just the filename for event payloads.""" - return os.path.basename(self.file_path) - - -@dataclass -class SilentStream: - node_id: int - connector: str - position: str - file_path: Path - file_bytes: int - - -def _load_restore_token(token_path: Path) -> str | None: - """Load restore token from disk.""" - try: - data = token_path.read_text(encoding="utf-8").strip() - return data or None - except (FileNotFoundError, OSError): - return None - - -def _save_restore_token(token: str, token_path: Path) -> None: - """Save restore token to disk.""" - try: - token_path.parent.mkdir(parents=True, exist_ok=True) - token_path.write_text(token.strip() + "\n", encoding="utf-8") - logger.debug(f"Saved restore token to {token_path}") - except OSError as e: - logger.warning(f"Failed to save restore token: {e}") - - -def _make_request_handle(bus: MessageBus, token: str) -> str: - """Compute expected Request object path for a handle_token.""" - sender = bus.unique_name.lstrip(":").replace(".", "_") - return f"/org/freedesktop/portal/desktop/request/{sender}/{token}" - - -def _prepare_request_handler( - bus: MessageBus, handle: str -) -> tuple[asyncio.Future, object]: - """Set up signal handler for Request::Response before calling portal method.""" - loop = asyncio.get_running_loop() - fut: asyncio.Future = loop.create_future() - - def _message_handler(msg): - if ( - msg.message_type.name == "SIGNAL" - and msg.path == handle - and msg.interface == REQ_IFACE - and msg.member == "Response" - ): - response = msg.body[0] - results = msg.body[1] if len(msg.body) > 1 else {} - if not fut.done(): - fut.set_result((int(response), results)) - - bus.add_message_handler(_message_handler) - return fut, _message_handler - - -def _variant_or_value(val): - """Extract value from Variant if needed.""" - if isinstance(val, Variant): - return val.value - return val - - -def _match_streams_to_monitors(streams: list[dict], monitors: list[dict]) -> list[dict]: - """ - Match portal stream geometries to monitor info. - - Portal streams have position (x, y) and size (width, height). - Monitors (from GDK or KScreen) have connector IDs and box coordinates. - - Returns streams augmented with connector and position labels. - """ - matched = [] - used_position_connectors = set() - - # Detect if all streams lack meaningful position data (KDE portal reports (0,0) for all) - all_zero_position = True - for stream in streams: - props = stream.get("props", {}) - pos = _variant_or_value(props.get("position", (0, 0))) - if isinstance(pos, (tuple, list)) and len(pos) >= 2: - if int(pos[0]) != 0 or int(pos[1]) != 0: - all_zero_position = False - break - - for stream in streams: - props = stream.get("props", {}) - - # Extract stream geometry from portal properties - stream_pos = _variant_or_value(props.get("position", (0, 0))) - stream_size = _variant_or_value(props.get("size", (0, 0))) - - if isinstance(stream_pos, (tuple, list)) and len(stream_pos) >= 2: - sx, sy = int(stream_pos[0]), int(stream_pos[1]) - else: - sx, sy = 0, 0 - - if isinstance(stream_size, (tuple, list)) and len(stream_size) >= 2: - sw, sh = int(stream_size[0]), int(stream_size[1]) - else: - sw, sh = 0, 0 - - # Find matching monitor by geometry - best_match = None - best_overlap = 0 - - if not all_zero_position: - for monitor in monitors: - if monitor["id"] in used_position_connectors: - continue - - mx1, my1, mx2, my2 = monitor["box"] - mw, mh = mx2 - mx1, my2 - my1 - - # Check if geometries match (within tolerance for scaling) - if abs(sx - mx1) < 10 and abs(sy - my1) < 10: - overlap = min(sw, mw) * min(sh, mh) - if overlap > best_overlap: - best_overlap = overlap - best_match = monitor - - if best_match: - used_position_connectors.add(best_match["id"]) - stream["connector"] = best_match["id"] - stream["position_label"] = best_match.get("position", "unknown") - stream["x"] = best_match["box"][0] - stream["y"] = best_match["box"][1] - stream["width"] = best_match["box"][2] - best_match["box"][0] - stream["height"] = best_match["box"][3] - best_match["box"][1] - else: - # Fallback: use stream index as identifier - stream["connector"] = f"monitor-{stream['idx']}" - stream["position_label"] = "unknown" - stream["x"] = sx - stream["y"] = sy - stream["width"] = sw - stream["height"] = sh - - matched.append(stream) - - unmatched_streams = [ - stream - for stream in matched - if str(stream.get("connector", "")).startswith("monitor-") - ] - matched_connectors = { - stream["connector"] - for stream in matched - if not str(stream.get("connector", "")).startswith("monitor-") - } - unmatched_monitors = [ - monitor for monitor in monitors if monitor["id"] not in matched_connectors - ] - - for stream in unmatched_streams: - if not unmatched_monitors: - break - - best_match = None - sw, sh = stream["width"], stream["height"] - for monitor in unmatched_monitors: - mx1, my1, mx2, my2 = monitor["box"] - mw, mh = mx2 - mx1, my2 - my1 - if abs(sw - mw) <= 2 and abs(sh - mh) <= 2: - best_match = monitor - break - - if best_match: - stream["connector"] = best_match["id"] - stream["position_label"] = best_match.get("position", "unknown") - stream["x"] = best_match["box"][0] - stream["y"] = best_match["box"][1] - stream["width"] = best_match["box"][2] - best_match["box"][0] - stream["height"] = best_match["box"][3] - best_match["box"][1] - unmatched_monitors.remove(best_match) - - return matched - - -class _StderrDrain: - """Continuously drain a subprocess stderr pipe. - - A chatty GStreamer pipeline can fill the OS pipe buffer (~64 KB); once - full, gst blocks on write(2) and stops producing frames while the - process stays alive — so is_healthy() would stay green while capture - silently stalls. Draining on a daemon thread (mirroring audio_recorder's - thread lifecycle) keeps the pipe empty. The thread ends on EOF when the - process exits; stop() reaps it via join(). - """ - - def __init__(self, stderr, tag: str): - self._stderr = stderr - self._tag = tag - self._thread = threading.Thread(target=self._run, daemon=True) - - def start(self) -> None: - self._thread.start() - - def _run(self) -> None: - try: - for raw in iter(self._stderr.readline, b""): - line = raw.decode("utf-8", errors="replace").rstrip("\r\n") - if not line: - continue - if len(line) > STDERR_DRAIN_LINE_CAP: - line = line[:STDERR_DRAIN_LINE_CAP] + "…" - logger.debug("%s stderr: %s", self._tag, line) - except (ValueError, OSError): - # stderr closed underneath us (e.g. during stop()); done. - pass - - def join(self, timeout: float = STDERR_DRAIN_JOIN_TIMEOUT) -> None: - self._thread.join(timeout=timeout) - - -class Screencaster: - """Portal-based multi-monitor screencast manager.""" - - def __init__(self, restore_token_path: Path): - self.bus: MessageBus | None = None - self.session_handle: str | None = None - self.pw_fd: int | None = None - self.gst_process: subprocess.Popen | None = None - self.streams: list[StreamInfo] = [] - self._started = False - self._stderr_drain: _StderrDrain | None = None - self._restore_token_path = restore_token_path - - def _close_pw_fd(self) -> None: - """Close the PipeWire fd exactly once, if held.""" - if self.pw_fd is not None: - try: - os.close(self.pw_fd) - except OSError: - pass - self.pw_fd = None - - async def connect(self) -> bool: - """ - Establish D-Bus connection and verify portal availability. - - Returns: - True if portal is available, False otherwise. - """ - if self.bus is not None: - return True - - try: - self.bus = await MessageBus( - bus_type=BusType.SESSION, - negotiate_unix_fd=True, - ).connect() - - # Verify portal interface exists - root_intro = await asyncio.wait_for( - self.bus.introspect(PORTAL_BUS, PORTAL_PATH), - PORTAL_CALL_TIMEOUT, - ) - root_obj = self.bus.get_proxy_object(PORTAL_BUS, PORTAL_PATH, root_intro) - root_obj.get_interface(SC_IFACE) - return True - - except Exception as e: - logger.error(f"Portal not available: {e}") - self.bus = None - return False - - async def start( - self, - output_dir: str, - framerate: int = 1, - draw_cursor: bool = True, - ) -> list[StreamInfo]: - """ - Start screencast recording for all monitors. - - Files are written directly to output_dir with final names (position_connector_screen.webm). - The output_dir is typically a segment directory that will be renamed on completion. - - Args: - output_dir: Directory for output files (e.g., YYYYMMDD/stream/HHMMSS.incomplete/) - framerate: Frames per second (default: 1) - draw_cursor: Whether to draw mouse cursor (default: True) - - Returns: - List of StreamInfo for each monitor being recorded. - - Raises: - RuntimeError: If recording fails to start. - """ - if not await self.connect(): - raise RuntimeError("Portal not available") - - # Get monitor info from GDK for connector IDs - from .activity import get_monitor_geometries - - try: - monitors = get_monitor_geometries() - except Exception as e: - logger.warning(f"Failed to get monitor geometries: {e}") - monitors = [] - - # Fall back to KScreen on KDE when GDK is unavailable - if not monitors and self.bus: - from .activity import get_monitor_geometries_kscreen - - try: - monitors = await get_monitor_geometries_kscreen(self.bus) - except Exception as e: - logger.warning(f"KScreen monitor fallback failed: {e}") - monitors = [] - - # Get portal interface - try: - root_intro = await asyncio.wait_for( - self.bus.introspect(PORTAL_BUS, PORTAL_PATH), - PORTAL_CALL_TIMEOUT, - ) - except asyncio.TimeoutError: - await self._close_session() - raise RuntimeError("Portal introspect timed out") - root_obj = self.bus.get_proxy_object(PORTAL_BUS, PORTAL_PATH, root_intro) - screencast = root_obj.get_interface(SC_IFACE) - - # 1) CreateSession - create_token = "h_" + uuid.uuid4().hex - create_handle = _make_request_handle(self.bus, create_token) - create_fut, create_handler = _prepare_request_handler(self.bus, create_handle) - - create_opts = { - "handle_token": Variant("s", create_token), - "session_handle_token": Variant("s", "s_" + uuid.uuid4().hex), - } - - try: - await asyncio.wait_for( - screencast.call_create_session(create_opts), - PORTAL_CALL_TIMEOUT, - ) - resp, results = await asyncio.wait_for(create_fut, PORTAL_CALL_TIMEOUT) - except asyncio.TimeoutError: - await self._close_session() - raise RuntimeError("CreateSession timed out") - finally: - self.bus.remove_message_handler(create_handler) - if resp != 0: - raise RuntimeError(f"CreateSession failed with code {resp}") - - self.session_handle = str(_variant_or_value(results.get("session_handle"))) - if not self.session_handle: - raise RuntimeError("CreateSession returned no session_handle") - - logger.debug(f"Portal session: {self.session_handle}") - - # 2) SelectSources - restore_token = _load_restore_token(self._restore_token_path) - if restore_token: - logger.debug("Using saved restore token") - - # Portal cursor-mode bitmask: 1=HIDDEN, 2=EMBEDDED (4=METADATA unused). - cursor_mode = 2 if draw_cursor else 1 - - select_token = "h_" + uuid.uuid4().hex - select_handle = _make_request_handle(self.bus, select_token) - select_fut, select_handler = _prepare_request_handler(self.bus, select_handle) - - select_opts = { - "handle_token": Variant("s", select_token), - "types": Variant("u", 1), # 1 = MONITOR - "multiple": Variant("b", True), - "cursor_mode": Variant("u", cursor_mode), - "persist_mode": Variant("u", 2), # Persist until revoked - } - if restore_token: - select_opts["restore_token"] = Variant("s", restore_token) - - response_timeout = ( - PORTAL_INTERACTIVE_TIMEOUT if not restore_token else PORTAL_CALL_TIMEOUT - ) - try: - await asyncio.wait_for( - screencast.call_select_sources(self.session_handle, select_opts), - PORTAL_CALL_TIMEOUT, - ) - resp, _ = await asyncio.wait_for(select_fut, response_timeout) - except asyncio.TimeoutError: - await self._close_session() - raise RuntimeError("SelectSources timed out") - finally: - self.bus.remove_message_handler(select_handler) - if resp != 0: - await self._close_session() - raise RuntimeError(f"SelectSources failed with code {resp}") - - # 3) Start - start_token = "h_" + uuid.uuid4().hex - start_handle = _make_request_handle(self.bus, start_token) - start_fut, start_handler = _prepare_request_handler(self.bus, start_handle) - - start_opts = {"handle_token": Variant("s", start_token)} - response_timeout = ( - PORTAL_INTERACTIVE_TIMEOUT if not restore_token else PORTAL_CALL_TIMEOUT - ) - try: - await asyncio.wait_for( - screencast.call_start(self.session_handle, "", start_opts), - PORTAL_CALL_TIMEOUT, - ) - resp, results = await asyncio.wait_for(start_fut, response_timeout) - except asyncio.TimeoutError: - await self._close_session() - raise RuntimeError("Start timed out") - finally: - self.bus.remove_message_handler(start_handler) - if resp != 0: - await self._close_session() - raise RuntimeError(f"Start failed with code {resp}") - - portal_streams = _variant_or_value(results.get("streams")) or [] - if not portal_streams: - await self._close_session() - raise RuntimeError("Start returned no streams") - - # Save new restore token if provided - new_token = _variant_or_value(results.get("restore_token")) - if isinstance(new_token, str) and new_token.strip(): - _save_restore_token(new_token, self._restore_token_path) - - # Parse streams - stream_info = [] - for idx, stream in enumerate(portal_streams): - try: - node_id = int(stream[0]) - props = stream[1] if len(stream) > 1 else {} - stream_info.append({"idx": idx, "node_id": node_id, "props": props}) - except Exception as e: - logger.warning(f"Could not parse stream {idx}: {e}") - - if not stream_info: - await self._close_session() - raise RuntimeError("No valid streams found") - - # Match streams to monitors - stream_info = _match_streams_to_monitors(stream_info, monitors) - - logger.info(f"Portal returned {len(stream_info)} stream(s)") - - # 4) OpenPipeWireRemote - try: - fd_obj = await asyncio.wait_for( - screencast.call_open_pipe_wire_remote(self.session_handle, {}), - PORTAL_CALL_TIMEOUT, - ) - except asyncio.TimeoutError: - await self._close_session() - raise RuntimeError("OpenPipeWireRemote timed out") - if hasattr(fd_obj, "take"): - self.pw_fd = fd_obj.take() - else: - self.pw_fd = int(fd_obj) - - # 5) Build GStreamer pipeline - self.streams = [] - pipeline_parts = [] - - for info in stream_info: - node_id = info["node_id"] - position = info["position_label"] - connector = info["connector"] - - # Final file path: position_connector_screen.webm - file_path = os.path.join(output_dir, f"{position}_{connector}_screen.webm") - - stream_obj = StreamInfo( - node_id=node_id, - position=position, - connector=connector, - x=info["x"], - y=info["y"], - width=info["width"], - height=info["height"], - file_path=file_path, - ) - self.streams.append(stream_obj) - - # GStreamer branch for this stream - branch = [ - "pipewiresrc", - f"fd={self.pw_fd}", - f"path={node_id}", - "!", - "videorate", - "!", - f"video/x-raw,framerate={framerate}/1", - "!", - "videoconvert", - "!", - "vp8enc", - "end-usage=cq", - "cq-level=4", - "max-quantizer=15", - "keyframe-max-dist=30", - "static-threshold=100", - "!", - "webmmux", - "!", - "filesink", - f"location={file_path}", - ] - pipeline_parts.append(branch) - - logger.info(f" Stream {node_id}: {position} ({connector}) -> {file_path}") - - cmd = ["gst-launch-1.0", "-e"] - for branch in pipeline_parts: - cmd.extend(branch) - - try: - self.gst_process = subprocess.Popen( - cmd, - pass_fds=(self.pw_fd,), - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except FileNotFoundError: - self._close_pw_fd() - await self._close_session() - raise RuntimeError("gst-launch-1.0 not found") - except Exception as e: - self._close_pw_fd() - await self._close_session() - raise RuntimeError(f"Failed to start GStreamer: {e}") - - # Brief delay to check for immediate failure - await asyncio.sleep(0.2) - if self.gst_process.poll() is not None: - stderr = ( - self.gst_process.stderr.read().decode("utf-8", errors="replace") - if self.gst_process.stderr - else "" - ) - self._close_pw_fd() - await self._close_session() - raise RuntimeError(f"GStreamer exited immediately: {stderr[:200]}") - - if self.gst_process.stderr is not None: - self._stderr_drain = _StderrDrain(self.gst_process.stderr, "gst") - self._stderr_drain.start() - - self._started = True - return self.streams - - async def stop(self) -> tuple[list[StreamInfo], list[SilentStream]]: - """ - Stop screencast recording gracefully. - - Returns: - Healthy streams and silent streams that were dropped. - """ - streams = self.streams.copy() - - # Stop GStreamer with SIGINT for clean EOS - if self.gst_process and self.gst_process.poll() is None: - try: - self.gst_process.send_signal(signal.SIGINT) - try: - await asyncio.wait_for( - asyncio.to_thread(self.gst_process.wait), - timeout=5.0, - ) - except asyncio.TimeoutError: - logger.warning("GStreamer did not exit cleanly, killing") - self.gst_process.kill() - self.gst_process.wait() - except Exception as e: - logger.warning(f"Error stopping GStreamer: {e}") - - self.gst_process = None - - if self._stderr_drain is not None: - self._stderr_drain.join() - self._stderr_drain = None - - healthy: list[StreamInfo] = [] - silent: list[SilentStream] = [] - for stream in streams: - file_path = Path(stream.file_path) - try: - file_bytes = file_path.stat().st_size - except FileNotFoundError: - file_bytes = 0 - except OSError as exc: - logger.warning("could not stat %s: %s", file_path, exc) - file_bytes = 0 - - if file_bytes >= MIN_HEALTHY_WEBM_BYTES: - healthy.append(stream) - continue - - silent.append( - SilentStream( - node_id=stream.node_id, - connector=stream.connector, - position=stream.position, - file_path=file_path, - file_bytes=file_bytes, - ) - ) - logger.warning( - "silent stream dropped: connector=%s position=%s file_bytes=%d path=%s", - stream.connector, - stream.position, - file_bytes, - file_path, - ) - try: - file_path.unlink(missing_ok=True) - except OSError as exc: - logger.warning("could not unlink silent stream %s: %s", file_path, exc) - - # Close PipeWire fd - self._close_pw_fd() - - # Close portal session - await self._close_session() - - self.streams = [] - self._started = False - - return healthy, silent - - async def _close_session(self): - """Close the portal session.""" - if self.session_handle and self.bus: - try: - session_intro = await asyncio.wait_for( - self.bus.introspect(PORTAL_BUS, self.session_handle), - PORTAL_CALL_TIMEOUT, - ) - session_obj = self.bus.get_proxy_object( - PORTAL_BUS, self.session_handle, session_intro - ) - session_iface = session_obj.get_interface(SESSION_IFACE) - await asyncio.wait_for( - session_iface.call_close(), - PORTAL_CALL_TIMEOUT, - ) - except ( - asyncio.TimeoutError, - DBusError, - InvalidMemberNameError, - InvalidIntrospectionError, - OSError, - ) as exc: - logger.warning( - "_close_session failed: service=%s path=%s: %s: %s", - PORTAL_BUS, - self.session_handle, - type(exc).__name__, - exc, - ) - self.session_handle = None - - def is_healthy(self) -> bool: - """Check if recording is still running.""" - if not self._started: - return False - if self.gst_process is None: - return False - return self.gst_process.poll() is None - - -class X11Screencaster: - """X11 screen capture using GStreamer ximagesrc. - - Mirrors the Screencaster interface so the observer can use either - backend interchangeably. Each connected monitor becomes one independent - GStreamer branch writing a VP8/WebM file at the configured framerate. - """ - - def __init__(self): - self.gst_process: subprocess.Popen | None = None - self.streams: list[StreamInfo] = [] - self._started = False - self._stderr_drain: _StderrDrain | None = None - - async def connect(self) -> bool: - """Verify the X11 display and GStreamer are available.""" - if not os.environ.get("DISPLAY"): - logger.error("X11 capture: DISPLAY not set") - return False - if shutil.which("gst-launch-1.0") is None: - logger.error("X11 capture: gst-launch-1.0 not found") - return False - return True - - async def start( - self, - output_dir: str, - framerate: int = 1, - draw_cursor: bool = True, - ) -> list[StreamInfo]: - """Start X11 screencast recording for all monitors. - - Files are written to output_dir with names position_connector_screen.webm, - identical to the Wayland backend. - - Raises: - RuntimeError: If no monitors are found or GStreamer fails to start. - """ - display = os.environ.get("DISPLAY", ":0") - - from .activity import get_monitor_geometries, get_monitor_geometries_x11 - - monitors = get_monitor_geometries_x11() - if not monitors: - try: - monitors = get_monitor_geometries() - except Exception as e: - logger.warning("GDK monitor fallback failed: %s", e) - - if not monitors: - raise RuntimeError("No monitors found for X11 capture") - - show_pointer = "true" if draw_cursor else "false" - self.streams = [] - pipeline_parts = [] - - for idx, monitor in enumerate(monitors): - x1, y1, x2, y2 = monitor["box"] - w, h = x2 - x1, y2 - y1 - position = monitor.get("position", "center") - connector = monitor["id"] - - file_path = os.path.join(output_dir, f"{position}_{connector}_screen.webm") - - stream_obj = StreamInfo( - node_id=idx, - position=position, - connector=connector, - x=x1, - y=y1, - width=w, - height=h, - file_path=file_path, - ) - self.streams.append(stream_obj) - - # ximagesrc endx/endy are inclusive pixel indices - endx = x1 + w - 1 - endy = y1 + h - 1 - - branch = [ - "ximagesrc", - f"display-name={display}", - f"startx={x1}", - f"starty={y1}", - f"endx={endx}", - f"endy={endy}", - "use-damage=false", - f"show-pointer={show_pointer}", - "!", - "videorate", - "!", - f"video/x-raw,framerate={framerate}/1", - "!", - "videoconvert", - "!", - "vp8enc", - "end-usage=cq", - "cq-level=4", - "max-quantizer=15", - "keyframe-max-dist=30", - "static-threshold=100", - "!", - "webmmux", - "!", - "filesink", - f"location={file_path}", - ] - pipeline_parts.append(branch) - - logger.info( - " X11 stream %d: %s (%s) -> %s", idx, position, connector, file_path - ) - - cmd = ["gst-launch-1.0", "-e"] - for branch in pipeline_parts: - cmd.extend(branch) - - try: - self.gst_process = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except FileNotFoundError: - raise RuntimeError("gst-launch-1.0 not found") - except Exception as e: - raise RuntimeError(f"Failed to start GStreamer (X11): {e}") - - await asyncio.sleep(0.2) - if self.gst_process.poll() is not None: - stderr = ( - self.gst_process.stderr.read().decode("utf-8", errors="replace") - if self.gst_process.stderr - else "" - ) - raise RuntimeError(f"GStreamer (X11) exited immediately: {stderr[:200]}") - - if self.gst_process.stderr is not None: - self._stderr_drain = _StderrDrain(self.gst_process.stderr, "gst-x11") - self._stderr_drain.start() - - self._started = True - return self.streams - - async def stop(self) -> tuple[list[StreamInfo], list[SilentStream]]: - """Stop X11 screencast recording gracefully.""" - streams = self.streams.copy() - - if self.gst_process and self.gst_process.poll() is None: - try: - self.gst_process.send_signal(signal.SIGINT) - try: - await asyncio.wait_for( - asyncio.to_thread(self.gst_process.wait), - timeout=5.0, - ) - except asyncio.TimeoutError: - logger.warning("GStreamer (X11) did not exit cleanly, killing") - self.gst_process.kill() - self.gst_process.wait() - except Exception as e: - logger.warning("Error stopping GStreamer (X11): %s", e) - - self.gst_process = None - - if self._stderr_drain is not None: - self._stderr_drain.join() - self._stderr_drain = None - - healthy: list[StreamInfo] = [] - silent: list[SilentStream] = [] - for stream in streams: - file_path = Path(stream.file_path) - try: - file_bytes = file_path.stat().st_size - except FileNotFoundError: - file_bytes = 0 - except OSError as exc: - logger.warning("could not stat %s: %s", file_path, exc) - file_bytes = 0 - - if file_bytes >= MIN_HEALTHY_WEBM_BYTES: - healthy.append(stream) - continue - - silent.append( - SilentStream( - node_id=stream.node_id, - connector=stream.connector, - position=stream.position, - file_path=file_path, - file_bytes=file_bytes, - ) - ) - logger.warning( - "silent stream dropped: connector=%s position=%s file_bytes=%d path=%s", - stream.connector, - stream.position, - file_bytes, - file_path, - ) - try: - file_path.unlink(missing_ok=True) - except OSError as exc: - logger.warning("could not unlink silent stream %s: %s", file_path, exc) - - self.streams = [] - self._started = False - return healthy, silent - - def is_healthy(self) -> bool: - """Check if recording is still running.""" - if not self._started: - return False - if self.gst_process is None: - return False - return self.gst_process.poll() is None diff --git a/src/solstone_linux/session_env.py b/src/solstone_linux/session_env.py deleted file mode 100644 index f82bfbc..0000000 --- a/src/solstone_linux/session_env.py +++ /dev/null @@ -1,92 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Desktop session environment checks and recovery. - -Extracted from solstone's observe/linux/observer.py (lines 598-666). - -_recover_session_env() is kept as fallback for manual CLI launch. -For systemd service launch, PassEnvironment= in the unit file is -the primary mechanism. -""" - -import logging -import os -import shutil -import subprocess - -logger = logging.getLogger(__name__) - -# Exit codes -EXIT_TEMPFAIL = 75 # EX_TEMPFAIL: session not ready, retry later - - -def _recover_session_env() -> None: - """Try to recover desktop session env vars from the systemd user manager. - - On GNOME Wayland, gnome-shell pushes DISPLAY, WAYLAND_DISPLAY, and - DBUS_SESSION_BUS_ADDRESS into the systemd user environment on startup. - When the observer is launched from a non-desktop shell, these vars may be missing - from the inherited environment — but systemctl --user show-environment - has them. - """ - needed = {"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS"} - missing = {v for v in needed if not os.environ.get(v)} - if not missing: - return - - # Ensure XDG_RUNTIME_DIR is set (required for systemctl --user to connect) - if not os.environ.get("XDG_RUNTIME_DIR"): - os.environ["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" - - try: - result = subprocess.run( - ["systemctl", "--user", "show-environment"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - return - except (FileNotFoundError, subprocess.TimeoutExpired): - return - - recovered = [] - for line in result.stdout.splitlines(): - key, _, value = line.partition("=") - if key in missing and value: - os.environ[key] = value - recovered.append(f"{key}={value}") - - if recovered: - logger.info("Recovered session env from systemd: %s", ", ".join(recovered)) - - -def check_session_ready() -> str | None: - """Check if the desktop session is ready for observation. - - Returns None if ready, or a description of what's missing. - """ - # Try to recover missing session vars from systemd user manager - _recover_session_env() - - # Display server - if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): - return "no display server (DISPLAY/WAYLAND_DISPLAY not set)" - - # DBus session bus - if not os.environ.get("DBUS_SESSION_BUS_ADDRESS"): - return "no DBus session bus (DBUS_SESSION_BUS_ADDRESS not set)" - - # PulseAudio / PipeWire audio - pactl = shutil.which("pactl") - if pactl: - try: - subprocess.run( - [pactl, "info"], - capture_output=True, - timeout=5, - ).check_returncode() - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return "audio server not responding (pactl info failed)" - return None diff --git a/src/solstone_linux/sni.py b/src/solstone_linux/sni.py deleted file mode 100644 index f725f4f..0000000 --- a/src/solstone_linux/sni.py +++ /dev/null @@ -1,250 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -# ruff: noqa: F722, F821 -"""StatusNotifierItem (SNI) implementation over dbus-fast. - -Implements the org.kde.StatusNotifierItem D-Bus interface for -registering a tray icon with KDE Plasma's system tray or GNOME's -AppIndicator extension. Both speak the same protocol. - -The tray icon, menu, and tooltip are all rendered by the DE's -tray host — this code just exposes the data over D-Bus. -""" - -import logging - -from dbus_fast import PropertyAccess -from dbus_fast.aio import MessageBus -from dbus_fast.service import ( - ServiceInterface, - dbus_property, - method, - signal as dbus_signal, -) - -log = logging.getLogger(__name__) - - -class StatusNotifierItem(ServiceInterface): - """org.kde.StatusNotifierItem D-Bus interface.""" - - def __init__(self, app_id: str = "solstone-observer"): - super().__init__("org.kde.StatusNotifierItem") - self._id = app_id - self._category = "ApplicationStatus" - self._status = "Active" # Passive, Active, NeedsAttention - self._title = "sol" - self._icon_name = "solstone-recording" - self._icon_accessible_desc = "" - self._attention_icon_name = "" - self._attention_accessible_desc = "" - self._overlay_icon_name = "" - self._tooltip_icon = "" - self._tooltip_title = "sol" - self._tooltip_body = "on" - self._icon_theme_path = "" - self._menu_path = "/MenuBar" - self._item_is_menu = True - - # Callbacks - self.on_activate = None - self.on_secondary_activate = None - self.on_scroll = None - - # ── Setters that emit change signals ── - - def set_icon(self, icon_name: str): - if self._icon_name != icon_name: - self._icon_name = icon_name - self.NewIcon() - - def set_icon_accessible_desc(self, desc: str): - if self._icon_accessible_desc != desc: - self._icon_accessible_desc = desc - self.emit_properties_changed({"IconAccessibleDesc": desc}) - - def set_status(self, status: str): - """Set Active, Passive, or NeedsAttention.""" - if self._status != status: - self._status = status - self.NewStatus(status) - - def set_tooltip(self, title: str, body: str, icon: str = ""): - self._tooltip_title = title - self._tooltip_body = body - if icon: - self._tooltip_icon = icon - self.NewToolTip() - - def set_title(self, title: str): - if self._title != title: - self._title = title - self.NewTitle() - - def set_attention_icon(self, icon_name: str): - self._attention_icon_name = icon_name - self.NewAttentionIcon() - - def set_attention_accessible_desc(self, desc: str): - if self._attention_accessible_desc != desc: - self._attention_accessible_desc = desc - self.emit_properties_changed({"AttentionAccessibleDesc": desc}) - - def set_overlay_icon(self, icon_name: str): - self._overlay_icon_name = icon_name - self.NewOverlayIcon() - - # ── D-Bus Properties ── - - @dbus_property(access=PropertyAccess.READ) - def Category(self) -> "s": - return self._category - - @dbus_property(access=PropertyAccess.READ) - def Id(self) -> "s": - return self._id - - @dbus_property(access=PropertyAccess.READ) - def Title(self) -> "s": - return self._title - - @dbus_property(access=PropertyAccess.READ) - def Status(self) -> "s": - return self._status - - @dbus_property(access=PropertyAccess.READ) - def WindowId(self) -> "i": - return 0 - - @dbus_property(access=PropertyAccess.READ) - def IconName(self) -> "s": - return self._icon_name - - @dbus_property(access=PropertyAccess.READ) - def IconAccessibleDesc(self) -> "s": - return self._icon_accessible_desc - - @dbus_property(access=PropertyAccess.READ) - def IconPixmap(self) -> "a(iiay)": - return [] - - @dbus_property(access=PropertyAccess.READ) - def OverlayIconName(self) -> "s": - return self._overlay_icon_name - - @dbus_property(access=PropertyAccess.READ) - def OverlayIconPixmap(self) -> "a(iiay)": - return [] - - @dbus_property(access=PropertyAccess.READ) - def AttentionIconName(self) -> "s": - return self._attention_icon_name - - @dbus_property(access=PropertyAccess.READ) - def AttentionAccessibleDesc(self) -> "s": - return self._attention_accessible_desc - - @dbus_property(access=PropertyAccess.READ) - def AttentionIconPixmap(self) -> "a(iiay)": - return [] - - @dbus_property(access=PropertyAccess.READ) - def AttentionMovieName(self) -> "s": - return "" - - @dbus_property(access=PropertyAccess.READ) - def ToolTip(self) -> "(sa(iiay)ss)": - return [ - self._tooltip_icon, # icon name - [], # icon pixmaps - self._tooltip_title, # title - self._tooltip_body, # body (supports HTML on KDE) - ] - - @dbus_property(access=PropertyAccess.READ) - def IconThemePath(self) -> "s": - return self._icon_theme_path - - @dbus_property(access=PropertyAccess.READ) - def Menu(self) -> "o": - return self._menu_path - - @dbus_property(access=PropertyAccess.READ) - def ItemIsMenu(self) -> "b": - return self._item_is_menu - - # ── D-Bus Methods ── - - @method() - def ContextMenu(self, x: "i", y: "i"): - log.debug(f"ContextMenu at ({x}, {y})") - - @method() - def Activate(self, x: "i", y: "i"): - log.debug(f"Activate at ({x}, {y})") - if self.on_activate: - self.on_activate() - - @method() - def SecondaryActivate(self, x: "i", y: "i"): - log.debug(f"SecondaryActivate at ({x}, {y})") - if self.on_secondary_activate: - self.on_secondary_activate() - - @method() - def Scroll(self, delta: "i", orientation: "s"): - log.debug(f"Scroll delta={delta} orientation={orientation}") - if self.on_scroll: - self.on_scroll(delta, orientation) - - @method() - def ProvideXdgActivationToken(self, token: "s"): - log.debug(f"XDG activation token: {token}") - - # ── D-Bus Signals ── - - @dbus_signal() - def NewTitle(self): - pass - - @dbus_signal() - def NewIcon(self): - pass - - @dbus_signal() - def NewAttentionIcon(self): - pass - - @dbus_signal() - def NewOverlayIcon(self): - pass - - @dbus_signal() - def NewToolTip(self): - pass - - @dbus_signal() - def NewStatus(self, status) -> "s": - return status - - -async def register_with_watcher(bus: MessageBus, bus_name: str): - """Register our SNI with the StatusNotifierWatcher.""" - try: - introspection = await bus.introspect( - "org.kde.StatusNotifierWatcher", - "/StatusNotifierWatcher", - ) - proxy = bus.get_proxy_object( - "org.kde.StatusNotifierWatcher", - "/StatusNotifierWatcher", - introspection, - ) - watcher = proxy.get_interface("org.kde.StatusNotifierWatcher") - await watcher.call_register_status_notifier_item(bus_name) - log.info(f"Registered with StatusNotifierWatcher as {bus_name}") - return True - except Exception as e: - log.warning(f"Failed to register with StatusNotifierWatcher: {e}") - log.warning("Is KDE Plasma running, or the AppIndicator extension enabled?") - return False diff --git a/src/solstone_linux/solstone-linux.service.in b/src/solstone_linux/solstone-linux.service.in deleted file mode 100644 index f2d43e6..0000000 --- a/src/solstone_linux/solstone-linux.service.in +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=sol for Linux -After=graphical-session.target -PartOf=graphical-session.target -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -ExecStart={BINARY} run -PassEnvironment=DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP XAUTHORITY XDG_SESSION_TYPE -Environment=PATH={PATH} -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=graphical-session.target diff --git a/src/solstone_linux/streams.py b/src/solstone_linux/streams.py deleted file mode 100644 index 81c1413..0000000 --- a/src/solstone_linux/streams.py +++ /dev/null @@ -1,87 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Stream identity for observer segments. - -Extracted from solstone's think/streams.py — only the pure naming functions -needed by standalone observers. - -Naming convention (separator is '.'): - Local Linux: {hostname} e.g. "archon" - Observer: {observer_name} e.g. "desktop" -""" - -from __future__ import annotations - -import re - -_STREAM_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") - - -def _strip_hostname(name: str) -> str: - """Strip domain suffix from a hostname, keeping only the first label. - - Dots in stream names are reserved for qualifiers (e.g., '.tmux'). - Hostnames like 'ja1r.local' or '192.168.1.1' must be reduced to a - dot-free base name. - - Examples: 'ja1r.local' -> 'ja1r', '192.168.1.1' -> '192-168-1-1', - 'archon' -> 'archon', 'my.host.example.com' -> 'my' - """ - name = name.strip() - if not name: - return name - parts = name.split(".") - if all(p.isdigit() for p in parts if p): - return "-".join(p for p in parts if p) - return parts[0] - - -def stream_name( - *, - host: str | None = None, - observer: str | None = None, - qualifier: str | None = None, -) -> str: - """Derive canonical stream name from source characteristics. - - Parameters - ---------- - host : str, optional - Local hostname (e.g., "archon"). - observer : str, optional - Observer name (e.g., "desktop"). - qualifier : str, optional - Sub-stream qualifier. Appended with dot separator. - - Returns - ------- - str - Canonical stream name. - - Raises - ------ - ValueError - If no source is provided, or the resulting name is invalid. - """ - if host: - base = _strip_hostname(host) - elif observer: - base = _strip_hostname(observer) - else: - raise ValueError("stream_name requires host or observer") - - name = base.lower().strip() - name = re.sub(r"[\s/\\]+", "-", name) - - if qualifier: - qualifier = qualifier.lower().strip() - qualifier = re.sub(r"[\s/\\]+", "-", qualifier) - name = f"{name}.{qualifier}" - - if not name or ".." in name: - raise ValueError(f"Invalid stream name: {name!r}") - if not _STREAM_NAME_RE.match(name): - raise ValueError(f"Invalid stream name: {name!r}") - - return name diff --git a/src/solstone_linux/sync.py b/src/solstone_linux/sync.py deleted file mode 100644 index 507336b..0000000 --- a/src/solstone_linux/sync.py +++ /dev/null @@ -1,843 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Background sync service for uploading captured segments. - -Modeled on solstone-macos's SyncService.swift. Runs as an asyncio -background task in the same event loop as capture. Walks cache days -newest-to-oldest, queries server for existing segments, uploads missing ones. - -Refinements over tmux baseline: -- Owns long retry/backoff and the circuit breaker; per-upload immediate - retries are bounded and interruptible in UploadClient -- Circuit breaker tuned by error type: auth=immediate, transient=5-10 -- Transient circuit breaker recovers via half-open probe with exponential backoff -- Auth/revoked circuit breaker is permanent (requires restart) -- Synced-days pruning at 90 days to prevent unbounded cache growth -""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import logging -import os -import shutil -import time -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Callable - -from .config import Config -from .sync_health import ( - ErrorType, - SyncFacts, - SyncHealth, - derive_health, - load_facts, - save_facts, -) -from .upload import UploadClient - -logger = logging.getLogger(__name__) - -# Circuit breaker thresholds by error type -CIRCUIT_THRESHOLD_AUTH = 1 # Auth failures open immediately -CIRCUIT_THRESHOLD_TRANSIENT = 5 # Transient failures need 5 consecutive - -# Circuit breaker recovery cooldown -CIRCUIT_COOLDOWN_INITIAL = 30 # seconds before first probe -CIRCUIT_COOLDOWN_FACTOR = 2 # multiply cooldown on each failed probe -CIRCUIT_COOLDOWN_MAX = 300 # cap at 5 minutes - -# Synced days older than this are pruned from the cache -SYNCED_DAYS_MAX_AGE = 90 - -# Quarantined (.failed) segments are dropped once this old (age from quarantine entry) -QUARANTINE_TTL_DAYS = 30 - -# Flush durable contact at most this often during long healthy drains. -CONTACT_FLUSH_INTERVAL = 30 - -SERVER_KEY_FILENAME = ".server_key" - -# Per-file statuses that prove reconcile convergence after name and SHA match. -# "processed" means the journal intentionally consumed the raw byte after -# verified processing and deliberately does not keep that raw file on journal -# disk; it makes the segment eligible for configured local cache cleanup, but -# does not mean the raw byte is still stored. -TERMINAL_HELD_STATUSES = ("present", "processed") - - -def _eligible_files(segment_dir: Path) -> list[Path]: - """Files eligible for upload and reconcile.""" - return [ - f for f in segment_dir.iterdir() if f.is_file() and not f.name.startswith(".") - ] - - -def _sha256_file(path: Path) -> str | None: - digest = hashlib.sha256() - try: - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - digest.update(chunk) - except OSError: - return None - return digest.hexdigest() - - -def _segment_proven_held(segment_dir: Path, entry: dict) -> bool: - """Return True only when every local file has terminal journal proof. - - Each local file must match a listing entry by submitted_name (else name), - carry a status in TERMINAL_HELD_STATUSES, and match its SHA-256 exactly. - All three are required: a terminal status alone is never proof for this byte. - Anything else -- absent entry, unknown status, hash mismatch, unreadable file - -- needs upload and is never deletable. - """ - local_files = _eligible_files(segment_dir) - if not local_files: - return False - - remote_by_name = {} - for remote_file in entry.get("files", []): - name = remote_file.get("submitted_name") or remote_file.get("name") - if name: - remote_by_name[name] = remote_file - - for local_file in local_files: - remote_file = remote_by_name.get(local_file.name) - if remote_file is None: - return False - if remote_file.get("status") not in TERMINAL_HELD_STATUSES: - return False - local_sha = _sha256_file(local_file) - if local_sha is None or local_sha != remote_file.get("sha256"): - return False - - return True - - -def _index_entries(items: list[dict]) -> dict[str, dict]: - entries = {} - for item in items: - key = item.get("key") - if key: - entries[key] = item - original_key = item.get("original_key") - if original_key: - entries[original_key] = item - return entries - - -def _read_server_key(segment_dir: Path) -> str | None: - try: - key = (segment_dir / SERVER_KEY_FILENAME).read_text(encoding="utf-8").strip() - except OSError: - return None - return key or None - - -def _write_server_key(segment_dir: Path, key: str) -> None: - try: - (segment_dir / SERVER_KEY_FILENAME).write_text(f"{key}\n", encoding="utf-8") - except OSError as e: - logger.warning("Failed to write server key marker for %s: %s", segment_dir, e) - - -def _lookup_entry(entries_by_key: dict[str, dict], segment_dir: Path) -> dict | None: - for key in (segment_dir.name, _read_server_key(segment_dir)): - if key and key in entries_by_key: - return entries_by_key[key] - return None - - -class SyncService: - """Background sync service that uploads completed segments to the server.""" - - def __init__( - self, - config: Config, - client: UploadClient, - now: Callable[[], float] = time.time, - ): - self._config = config - self._client = client - self._now = now - self._stale_threshold = config.sync_stale_threshold - self._synced_days: set[str] = set() - self._consecutive_failures = 0 - self._last_error_type: ErrorType | None = None - self._circuit_open = False - self._circuit_open_permanent = False - self._circuit_open_since: float = 0.0 - self._circuit_cooldown: float = CIRCUIT_COOLDOWN_INITIAL - self._last_full_sync: float = 0 - self._running = True - self._trigger = asyncio.Event() - self._dbus_service = None - self._facts: SyncFacts = load_facts(self._config.state_dir) - self._facts.in_progress = False - self._facts.progress = "" - self._last_contact_flush = 0.0 - self._last_emitted_health = "" - self._save_health() - - # Load synced days cache - self._load_synced_days() - - @property - def health(self) -> SyncHealth: - return derive_health(self._facts, self._now(), self._stale_threshold) - - @property - def progress(self) -> str: - return self._facts.progress - - def health_beacon_fields(self) -> dict[str, Any]: - """Diagnostics-only sync fields: counts, epoch seconds, and error class.""" - last_successful_sync = self._facts.last_successful_sync - last_error_class = self._facts.last_error_class - last_error_code = self._facts.last_error_code - - if last_error_class is None: - last_error_reason = None - elif last_error_code is not None: - last_error_reason = f"{last_error_class.value}:{last_error_code}" - else: - last_error_reason = last_error_class.value - - return { - "last_successful_sync": int(last_successful_sync) - if last_successful_sync is not None - else None, - "pending_queue_depth": self._facts.pending_confirmed, - "recent_error_count": min(99, max(0, self._consecutive_failures)), - "last_error_reason": last_error_reason, - } - - def _synced_days_path(self) -> Path: - return self._config.state_dir / "synced_days.json" - - def _load_synced_days(self) -> None: - path = self._synced_days_path() - if not path.exists(): - return - try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - self._synced_days = set(data) if isinstance(data, list) else set() - except (json.JSONDecodeError, OSError): - self._synced_days = set() - - def _save_synced_days(self) -> None: - self._config.state_dir.mkdir(parents=True, exist_ok=True) - path = self._synced_days_path() - tmp = path.with_suffix(f".{os.getpid()}.tmp") - try: - with open(tmp, "w", encoding="utf-8") as f: - json.dump(sorted(self._synced_days), f) - f.write("\n") - os.rename(str(tmp), str(path)) - except OSError as e: - logger.warning(f"Failed to save synced days: {e}") - - def _prune_synced_days(self) -> None: - """Remove synced-days entries older than 90 days.""" - if not self._synced_days: - return - cutoff = (datetime.now() - timedelta(days=SYNCED_DAYS_MAX_AGE)).strftime( - "%Y%m%d" - ) - before = len(self._synced_days) - self._synced_days = {d for d in self._synced_days if d >= cutoff} - pruned = before - len(self._synced_days) - if pruned: - logger.info( - f"Pruned {pruned} synced-days entries older than {SYNCED_DAYS_MAX_AGE} days" - ) - self._save_synced_days() - - def _quarantine_segment(self, segment_dir: Path, reason: str) -> bool: - """Rename a segment directory to .failed so it's never retried.""" - failed_path = segment_dir.with_name(segment_dir.name + ".failed") - try: - segment_dir.rename(failed_path) - try: - os.utime(failed_path, (self._now(), self._now())) - except OSError as e: - logger.warning( - "Failed to stamp quarantine time for %s: %s", failed_path, e - ) - logger.warning( - "Quarantined %s/%s — %s", - segment_dir.parent.parent.name, - segment_dir.name, - reason, - ) - return True - except OSError as e: - logger.error("Failed to quarantine %s: %s", segment_dir, e) - return False - - async def _cleanup_synced_segments(self) -> None: - """Delete synced segments older than cache_retention_days. - - Triple-gated safety: - 1. Day must be in _synced_days (fully synced locally) - 2. Segment must be older than retention threshold (unless retention=0) - 3. Segment must be confirmed present on server (fresh query) - """ - retention = self._config.cache_retention_days - if retention < 0: - return - - captures_dir = self._config.captures_dir - if not captures_dir.exists(): - return - - today = datetime.now().strftime("%Y%m%d") - if retention > 0: - cutoff = (datetime.now() - timedelta(days=retention)).strftime("%Y%m%d") - else: - cutoff = today # 0 means delete immediately — all days qualify - - deleted_total = 0 - - for day_dir in sorted(captures_dir.iterdir()): - if not day_dir.is_dir(): - continue - - day = day_dir.name - - if not self._running: - break - - # Gate 1: day must be in synced_days - if day not in self._synced_days: - continue - - # Gate 2: day must be old enough (unless retention=0) - if retention > 0 and day >= cutoff: - continue - - # Don't clean today's segments - if day == today: - continue - - # Gate 3: fresh server confirmation - query_result = await asyncio.to_thread( - self._client.get_server_segments, day - ) - if query_result.error_type is not None or query_result.segments is None: - self._record_failure(query_result.error_type, query_result.status_code) - logger.warning("Cleanup: skipping day %s — server unreachable", day) - continue - self._record_contact() - - proof_available = not query_result.legacy and not query_result.truncated - entries_by_key = ( - _index_entries(query_result.segments) if proof_available else {} - ) - - deleted_day = 0 - - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - - for seg_dir in sorted(stream_dir.iterdir()): - if not seg_dir.is_dir(): - continue - - name = seg_dir.name - # Never touch incomplete segments - if name.endswith(".incomplete"): - continue - - # Quarantined (.failed) segments are handled by the age-based sweep, never here. - if name.endswith(".failed"): - continue - - entry = _lookup_entry(entries_by_key, seg_dir) - if not ( - proof_available - and entry is not None - and _segment_proven_held(seg_dir, entry) - ): - logger.warning( - "Cleanup: keeping %s/%s — not confirmed on server", - day, - name, - ) - continue - - shutil.rmtree(seg_dir) - logger.info("Cleanup: deleted %s/%s", day, name) - deleted_day += 1 - - # Remove empty stream dir - if stream_dir.is_dir() and not any(stream_dir.iterdir()): - stream_dir.rmdir() - - # Remove empty day dir - if day_dir.is_dir() and not any(day_dir.iterdir()): - day_dir.rmdir() - - if deleted_day: - deleted_total += deleted_day - - if deleted_total: - logger.info("Cleanup: deleted %d segment(s) total", deleted_total) - - def _sweep_expired_quarantine(self) -> None: - """Drop quarantined (.failed) segments past the local TTL. - - Purely local and unconditional: independent of _synced_days, server - reachability, cache_retention_days, and circuit state. Age is measured - from quarantine-entry time (the .failed dir's stamped mtime), not - capture time. Anything younger than the TTL is always kept. - """ - captures_dir = self._config.captures_dir - if not captures_dir.exists(): - return - - cutoff = self._now() - QUARANTINE_TTL_DAYS * 86400 - - for day_dir in sorted(captures_dir.iterdir()): - if not day_dir.is_dir(): - continue - day = day_dir.name - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - for seg_dir in sorted(stream_dir.iterdir()): - if not seg_dir.is_dir() or not seg_dir.name.endswith(".failed"): - continue - try: - mtime = seg_dir.stat().st_mtime - except OSError: - continue - if mtime > cutoff: - continue - age_days = int((self._now() - mtime) // 86400) - try: - shutil.rmtree(seg_dir) - except OSError as e: - logger.error( - "Failed to drop quarantined %s/%s: %s", - day, - seg_dir.name, - e, - ) - continue - logger.warning( - "Dropping quarantined %s/%s — held %dd, past %dd limit; " - "unrecovered quarantined data discarded", - day, - seg_dir.name, - age_days, - QUARANTINE_TTL_DAYS, - ) - - def _save_health(self) -> None: - try: - save_facts(self._config.state_dir, self._facts) - except OSError as e: - logger.warning("Failed to save sync health: %s", e) - - def _emit_health_changed(self) -> None: - health = self.health - emitted = f"{health.state.value}:{self._facts.progress}" - if emitted == self._last_emitted_health: - return - self._last_emitted_health = emitted - if self._dbus_service: - self._dbus_service.SyncProgressChanged(emitted) - - def _set_progress(self, progress: str, in_progress: bool = True) -> None: - self._facts.in_progress = in_progress - self._facts.progress = progress - self._save_health() - self._emit_health_changed() - - def _record_contact(self, force: bool = False) -> None: - self._facts.last_successful_contact = self._now() - now_mono = time.monotonic() - if force or now_mono - self._last_contact_flush >= CONTACT_FLUSH_INTERVAL: - self._last_contact_flush = now_mono - self._save_health() - self._emit_health_changed() - - def _record_failure( - self, error_type: ErrorType | None, status_code: int | None = None - ) -> None: - if error_type is None: - return - - self._last_error_type = error_type - self._facts.last_error_class = error_type - self._facts.last_error_code = status_code - self._facts.pending_confirmed = None - self._save_health() - self._emit_health_changed() - - if error_type == ErrorType.CLIENT: - return - - self._consecutive_failures += 1 - threshold = self._circuit_threshold() - if self._consecutive_failures >= threshold: - self._circuit_open = True - self._circuit_open_permanent = error_type == ErrorType.AUTH - self._circuit_open_since = time.monotonic() - self._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - logger.error( - "Circuit breaker OPEN: %s consecutive %s failures (threshold: %s)", - self._consecutive_failures, - error_type.value, - threshold, - ) - - def _commit_pass_result( - self, - success: bool, - error_type: ErrorType | None = None, - status_code: int | None = None, - ) -> None: - self._facts.in_progress = False - self._facts.progress = "" - if success: - now = self._now() - self._facts.last_successful_sync = now - if self._facts.last_successful_contact is None: - self._facts.last_successful_contact = now - self._facts.last_error_class = None - self._facts.last_error_code = None - self._facts.pending_confirmed = 0 - self._consecutive_failures = 0 - self._last_error_type = None - else: - self._facts.pending_confirmed = None - self._facts.last_error_class = error_type - self._facts.last_error_code = status_code - self._last_contact_flush = time.monotonic() - self._save_health() - self._emit_health_changed() - - def _circuit_threshold(self) -> int: - """Get circuit breaker threshold based on last error type.""" - if self._last_error_type in (ErrorType.AUTH, ErrorType.INCOMPATIBLE): - return CIRCUIT_THRESHOLD_AUTH - if self._last_error_type == ErrorType.CLIENT: - return 0 - return CIRCUIT_THRESHOLD_TRANSIENT - - def trigger(self) -> None: - """Trigger a sync pass (called by observer on segment completion).""" - self._trigger.set() - - def stop(self) -> None: - """Stop the sync service.""" - self._running = False - self._trigger.set() - self._client.request_stop() - - async def run(self) -> None: - """Main sync loop — waits for triggers, then syncs.""" - # Prune on startup - self._prune_synced_days() - - while self._running: - try: - # Wait for trigger or periodic check (60s timeout) - try: - await asyncio.wait_for(self._trigger.wait(), timeout=60) - except asyncio.TimeoutError: - pass - - self._trigger.clear() - - if not self._running: - break - - if self._circuit_open: - if self._circuit_open_permanent: - self._facts.in_progress = False - self._facts.progress = "" - self._save_health() - self._emit_health_changed() - logger.warning( - "Circuit breaker open (permanent) — skipping sync" - ) - continue - - elapsed = time.monotonic() - self._circuit_open_since - if elapsed < self._circuit_cooldown: - remaining = self._circuit_cooldown - elapsed - self._facts.in_progress = False - self._facts.progress = f"{remaining:.0f}s until probe" - self._save_health() - self._emit_health_changed() - logger.warning( - f"Circuit breaker open — {remaining:.0f}s until probe" - ) - continue - - self._set_progress("probing journal...") - logger.info("Circuit breaker half-open — probing server") - today = datetime.now().strftime("%Y%m%d") - probe_result = await asyncio.to_thread( - self._client.get_server_segments, today - ) - if probe_result.error_type is None: - self._record_contact(force=True) - logger.info("Circuit breaker probe succeeded — closing circuit") - self._circuit_open = False - self._circuit_open_permanent = False - self._circuit_open_since = 0.0 - self._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - self._consecutive_failures = 0 - self._last_error_type = None - self._facts.last_error_class = None - self._facts.last_error_code = None - self._set_progress("syncing...") - else: - self._record_failure( - probe_result.error_type, probe_result.status_code - ) - self._circuit_cooldown = min( - self._circuit_cooldown * CIRCUIT_COOLDOWN_FACTOR, - CIRCUIT_COOLDOWN_MAX, - ) - self._circuit_open_since = time.monotonic() - self._facts.in_progress = False - self._facts.progress = ( - f"probe failed, next in {self._circuit_cooldown:.0f}s" - ) - self._save_health() - self._emit_health_changed() - logger.warning( - f"Circuit breaker probe failed — next probe in {self._circuit_cooldown:.0f}s" - ) - continue - - # Force full sync daily - now = self._now() - force_full = (now - self._last_full_sync) > 86400 - - await self._sync(force_full=force_full) - - if force_full: - self._last_full_sync = now - - except Exception as e: - logger.error(f"Sync error: {e}", exc_info=True) - await asyncio.sleep(5) - - async def _sync(self, force_full: bool = False) -> None: - """Walk days newest-to-oldest and upload missing segments.""" - captures_dir = self._config.captures_dir - - today = datetime.now().strftime("%Y%m%d") - - # Collect segments by day - segments_by_day = ( - self._collect_segments(captures_dir) if captures_dir.exists() else {} - ) - days = set(segments_by_day.keys()) - # Always query today so a caught-up/no-cache observer can earn connected. - days.add(today) - - self._set_progress("checking journal...") - pass_success = True - pass_error_type: ErrorType | None = None - pass_error_code: int | None = None - legacy_logged = False - - for day in sorted(days, reverse=True): - if not self._running: - pass_success = False - break - - if self._circuit_open: - pass_success = False - break - - # Skip past days already fully synced (unless forcing) - if day != today and day in self._synced_days and not force_full: - continue - - local_segments = segments_by_day.get(day, []) - - # Query server for existing segments - self._set_progress(f"checking {day}...") - query_result = await asyncio.to_thread( - self._client.get_server_segments, day - ) - if query_result.error_type is not None or query_result.segments is None: - pass_success = False - pass_error_type = query_result.error_type - pass_error_code = query_result.status_code - self._record_failure(query_result.error_type, query_result.status_code) - logger.warning(f"Failed to query server for day {day}") - if self._circuit_open: - break - continue - self._record_contact() - - if query_result.legacy: - if not legacy_logged: - logger.warning( - "Journal listing is pre-v2 bare array; per-file reconcile " - "unavailable; syncing on key-membership; cleanup will not delete" - ) - legacy_logged = True - key_set = set(_index_entries(query_result.segments).keys()) - entries_by_key = {} - else: - key_set = set() - entries_by_key = _index_entries(query_result.segments) - - any_needed_upload = False - - for segment_dir in local_segments: - if not self._running or self._circuit_open: - break - - segment_key = segment_dir.name - if query_result.legacy: - server_key = _read_server_key(segment_dir) - held = segment_key in key_set or ( - server_key is not None and server_key in key_set - ) - elif query_result.truncated: - held = False - else: - entry = _lookup_entry(entries_by_key, segment_dir) - held = entry is not None and _segment_proven_held( - segment_dir, entry - ) - - if held: - continue - - # Quarantine segments where all files are zero-byte (corrupt) - files = _eligible_files(segment_dir) - if files and all(f.stat().st_size == 0 for f in files): - self._quarantine_segment(segment_dir, "all files zero-byte") - continue - - any_needed_upload = True - self._set_progress(f"uploading {segment_key}") - success = await self._upload_segment(day, segment_dir) - - if not success: - pass_success = False - pass_error_type = self._last_error_type - pass_error_code = None - if self._last_error_type == ErrorType.CLIENT: - # Non-retryable client error (e.g. 400) — quarantine, don't trip circuit - self._quarantine_segment( - segment_dir, "server rejected (client error)" - ) - self._record_failure(self._last_error_type) - continue - - self._record_failure(self._last_error_type) - if self._circuit_open: - break - else: - self._consecutive_failures = 0 - self._last_error_type = None - - # Mark past days as synced if nothing needed upload - if day != today and not any_needed_upload: - self._synced_days.add(day) - self._save_synced_days() - - if pass_success and not self._circuit_open and self._running: - self._commit_pass_result(True) - else: - self._commit_pass_result( - False, - pass_error_type or self._facts.last_error_class, - pass_error_code or self._facts.last_error_code, - ) - - # Cleanup old synced segments - if not self._circuit_open and self._running: - try: - await self._cleanup_synced_segments() - except Exception as e: - logger.error(f"Cleanup error: {e}", exc_info=True) - - # Expire quarantined segments past the local TTL — runs even when the - # circuit is open / server is down / day was never synced / retention == -1. - if self._running: - try: - self._sweep_expired_quarantine() - except Exception as e: - logger.error(f"Quarantine sweep error: {e}", exc_info=True) - - def _collect_segments(self, captures_dir: Path) -> dict[str, list[Path]]: - """Collect completed segments grouped by day.""" - result: dict[str, list[Path]] = {} - - for day_dir in sorted(captures_dir.iterdir(), reverse=True): - if not day_dir.is_dir(): - continue - - day = day_dir.name - - for stream_dir in day_dir.iterdir(): - if not stream_dir.is_dir(): - continue - - segments = [] - for seg_dir in sorted(stream_dir.iterdir(), reverse=True): - if not seg_dir.is_dir(): - continue - name = seg_dir.name - # Skip incomplete and failed - if name.endswith(".incomplete") or name.endswith(".failed"): - continue - segments.append(seg_dir) - - if segments: - result.setdefault(day, []).extend(segments) - - return result - - async def _upload_segment(self, day: str, segment_dir: Path) -> bool: - """Upload a single segment with retry logic.""" - segment_key = segment_dir.name - files = _eligible_files(segment_dir) - if not files: - return True # Nothing to upload - - result = await asyncio.to_thread( - self._client.upload_segment, day, segment_key, files - ) - - if result.success: - if result.stored_key and result.stored_key != segment_key: - _write_server_key(segment_dir, result.stored_key) - self._record_contact() - logger.info(f"Uploaded: {day}/{segment_key} ({len(files)} files)") - return True - - # Track error type for circuit breaker - self._last_error_type = result.error_type - - # Non-retryable errors - if self._client.is_revoked: - logger.error("Client revoked — disabling sync") - self._circuit_open = True - self._circuit_open_permanent = True - return False - - logger.error(f"Upload failed: {day}/{segment_key}") - return False diff --git a/src/solstone_linux/sync_health.py b/src/solstone_linux/sync_health.py deleted file mode 100644 index 0f259bf..0000000 --- a/src/solstone_linux/sync_health.py +++ /dev/null @@ -1,364 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Sync health facts, derivation, persistence, and surface copy.""" - -from __future__ import annotations - -import json -import os -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from pathlib import Path -from typing import Any - -from .config import DEFAULT_SYNC_STALE_THRESHOLD - -SCHEMA_VERSION = 1 - - -class ErrorType(Enum): - """Classification of sync errors for health and circuit decisions.""" - - AUTH = "auth" - CLIENT = "client" - TRANSIENT = "transient" - INCOMPATIBLE = "incompatible" - - -class HealthState(Enum): - """User-facing sync health state.""" - - CONNECTED = "connected" - SYNCING = "syncing" - OFFLINE = "offline" - UPDATE_NEEDED = "update-needed" - REVOKED = "revoked" - STALE = "stale" - UNKNOWN = "unknown" - - -@dataclass -class SyncFacts: - """Durable facts used to derive sync health.""" - - last_successful_sync: float | None = None - last_successful_contact: float | None = None - last_error_class: ErrorType | None = None - last_error_code: int | None = None - pending_confirmed: int | None = None - in_progress: bool = False - progress: str = "" - - -@dataclass(frozen=True) -class HealthSurface: - """State-specific copy and presentation data.""" - - header_recording: str - header_idle: str - sync_line: str - tooltip: str - accessible_recording: str - accessible_idle: str - icon: str - sni: str - cli: str - doctor_severity: str - doctor_detail: str - dbus: str - - -@dataclass(frozen=True) -class SyncHealth: - """Derived health state and fully resolved surface strings.""" - - state: HealthState - header_recording: str - header_idle: str - sync_line: str - tooltip: str - accessible_recording: str - accessible_idle: str - icon: str - sni_status: str - cli: str - doctor_severity: str - doctor_detail: str - dbus: str - pending_display: str - last_success_age: float | None - progress: str - - -SURFACE_BY_STATE: dict[HealthState, HealthSurface] = { - HealthState.CONNECTED: HealthSurface( - header_recording="on — connected", - header_idle="idle — connected", - sync_line="sync: up to date", - tooltip="sync: up to date", - accessible_recording="sol — on, sync up to date", - accessible_idle="sol — idle, sync up to date", - icon="recording", - sni="Active", - cli="Sync: connected — up to date (0 pending)", - doctor_severity="ok", - doctor_detail="sync health: up to date; 0 pending confirmed at {sync_ts}", - dbus="connected", - ), - HealthState.SYNCING: HealthSurface( - header_recording="on — syncing", - header_idle="idle — syncing", - sync_line="sync: {progress}", - tooltip="sync: {progress}", - accessible_recording="sol — on, syncing", - accessible_idle="sol — idle, syncing", - icon="syncing", - sni="Active", - cli="Sync: syncing — pending unconfirmed until this pass finishes", - doctor_severity="ok", - doctor_detail="sync health: sync pass active; pending unconfirmed until check completes", - dbus="syncing", - ), - HealthState.OFFLINE: HealthSurface( - header_recording="on — offline (saving locally)", - header_idle="idle — offline (saving locally)", - sync_line="sync: offline; will retry", - tooltip="sync: offline; saving locally", - accessible_recording="sol — on, offline, saving locally", - accessible_idle="sol — idle, offline, saving locally", - icon="syncing", - sni="Active", - cli="Sync: offline — saving locally; pending unconfirmed (will retry)", - doctor_severity="warn", - doctor_detail="sync health: offline; pending unconfirmed; will retry", - dbus="offline", - ), - HealthState.UPDATE_NEEDED: HealthSurface( - header_recording="on — update needed", - header_idle="idle — update needed", - sync_line="sync: update solstone-linux", - tooltip="sync: update needed; update solstone-linux", - accessible_recording="sol — on, update needed", - accessible_idle="sol — idle, update needed", - icon="error", - sni="NeedsAttention", - cli="Sync: update needed — update solstone-linux; pending unconfirmed", - doctor_severity="fail", - doctor_detail="sync health: update needed; server route returned 404", - dbus="update-needed", - ), - HealthState.REVOKED: HealthSurface( - header_recording="on — re-auth needed", - header_idle="idle — re-auth needed", - sync_line="sync: re-auth required", - tooltip="sync: access revoked; re-auth required", - accessible_recording="sol — on, re-auth required", - accessible_idle="sol — idle, re-auth required", - icon="error", - sni="NeedsAttention", - cli="Sync: revoked — re-auth required; pending unconfirmed", - doctor_severity="fail", - doctor_detail="sync health: access revoked; re-auth required", - dbus="revoked", - ), - HealthState.STALE: HealthSurface( - header_recording="on — sync stale", - header_idle="idle — sync stale", - sync_line="sync: stale; no journal response in {contact_age}", - tooltip="sync: stale; last contact {contact_ts}", - accessible_recording="sol — on, sync stale", - accessible_idle="sol — idle, sync stale", - icon="error", - sni="NeedsAttention", - cli="Sync: stale — no journal response in {contact_age}; check service and journal", - doctor_severity="fail", - doctor_detail="sync health: stale; last contact {contact_ts}, threshold {threshold}", - dbus="stale", - ), - HealthState.UNKNOWN: HealthSurface( - header_recording="on — sync unconfirmed", - header_idle="idle — sync unconfirmed", - sync_line="sync: checking...", - tooltip="sync: not confirmed yet", - accessible_recording="sol — on, sync unconfirmed", - accessible_idle="sol — idle, sync unconfirmed", - icon="syncing", - sni="Active", - cli="Sync: unconfirmed — waiting for first successful journal check; pending unconfirmed", - doctor_severity="warn", - doctor_detail="sync health: unconfirmed; no successful journal check yet", - dbus="unknown", - ), -} - - -def _format_age(seconds: float | None) -> str: - if seconds is None: - return "unknown" - seconds = max(0, int(seconds)) - if seconds < 60: - return f"{seconds}s" - minutes = seconds // 60 - if minutes < 60: - return f"{minutes}m" - hours = minutes // 60 - if hours < 24: - return f"{hours}h" - days = hours // 24 - return f"{days}d" - - -def _format_ts(timestamp: float | None) -> str: - if timestamp is None: - return "unknown" - return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") - - -def _fill(template: str, values: dict[str, str]) -> str: - return template.format(**values) - - -def derive_health( - facts: SyncFacts, - now: float, - stale_threshold: float = DEFAULT_SYNC_STALE_THRESHOLD, -) -> SyncHealth: - """Derive the sync health state and resolved surface copy from facts.""" - if facts.last_error_class == ErrorType.AUTH: - state = HealthState.REVOKED - elif facts.last_error_class == ErrorType.INCOMPATIBLE: - state = HealthState.UPDATE_NEEDED - elif ( - facts.last_successful_contact is not None - and now - facts.last_successful_contact > stale_threshold - ): - state = HealthState.STALE - elif facts.in_progress: - state = HealthState.SYNCING - elif facts.pending_confirmed == 0: - state = HealthState.CONNECTED - elif facts.last_error_class == ErrorType.TRANSIENT: - state = HealthState.OFFLINE - else: - state = HealthState.UNKNOWN - - surface = SURFACE_BY_STATE[state] - progress = facts.progress.strip() or "syncing..." - sync_age = ( - now - facts.last_successful_sync - if facts.last_successful_sync is not None - else None - ) - contact_age = ( - now - facts.last_successful_contact - if facts.last_successful_contact is not None - else None - ) - values = { - "progress": progress, - "sync_ts": _format_ts(facts.last_successful_sync), - "contact_ts": _format_ts(facts.last_successful_contact), - "contact_age": _format_age(contact_age), - "threshold": _format_age(stale_threshold), - } - pending_display = ( - "0 pending" if state == HealthState.CONNECTED else "pending unconfirmed" - ) - - return SyncHealth( - state=state, - header_recording=_fill(surface.header_recording, values), - header_idle=_fill(surface.header_idle, values), - sync_line=_fill(surface.sync_line, values), - tooltip=_fill(surface.tooltip, values), - accessible_recording=_fill(surface.accessible_recording, values), - accessible_idle=_fill(surface.accessible_idle, values), - icon=surface.icon, - sni_status=surface.sni, - cli=_fill(surface.cli, values), - doctor_severity=surface.doctor_severity, - doctor_detail=_fill(surface.doctor_detail, values), - dbus=surface.dbus, - pending_display=pending_display, - last_success_age=sync_age, - progress=facts.progress, - ) - - -def sync_health_path(state_dir: Path) -> Path: - return state_dir / "sync_health.json" - - -def _parse_error_type(value: Any) -> ErrorType | None: - if not isinstance(value, str): - return None - try: - return ErrorType(value) - except ValueError: - return None - - -def _parse_optional_float(value: Any) -> float | None: - if value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -def _parse_optional_int(value: Any) -> int | None: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def load_facts(state_dir: Path) -> SyncFacts: - path = sync_health_path(state_dir) - if not path.exists(): - return SyncFacts() - try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError): - return SyncFacts() - if not isinstance(data, dict): - return SyncFacts() - return SyncFacts( - last_successful_sync=_parse_optional_float(data.get("last_successful_sync")), - last_successful_contact=_parse_optional_float( - data.get("last_successful_contact") - ), - last_error_class=_parse_error_type(data.get("last_error_class")), - last_error_code=_parse_optional_int(data.get("last_error_code")), - pending_confirmed=_parse_optional_int(data.get("pending_confirmed")), - in_progress=bool(data.get("in_progress", False)), - progress=str(data.get("progress", "")), - ) - - -def save_facts(state_dir: Path, facts: SyncFacts) -> None: - state_dir.mkdir(parents=True, exist_ok=True) - path = sync_health_path(state_dir) - tmp = path.with_suffix(f".{os.getpid()}.tmp") - data = { - "schema_version": SCHEMA_VERSION, - "last_successful_sync": facts.last_successful_sync, - "last_successful_contact": facts.last_successful_contact, - "last_error_class": ( - facts.last_error_class.value if facts.last_error_class is not None else None - ), - "last_error_code": facts.last_error_code, - "pending_confirmed": facts.pending_confirmed, - "in_progress": facts.in_progress, - "progress": facts.progress, - } - with open(tmp, "w", encoding="utf-8") as f: - json.dump(data, f) - f.write("\n") - os.rename(str(tmp), str(path)) diff --git a/src/solstone_linux/tray.py b/src/solstone_linux/tray.py deleted file mode 100644 index bdfe9f7..0000000 --- a/src/solstone_linux/tray.py +++ /dev/null @@ -1,564 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc -"""solstone tray app — in-process D-Bus SNI component. - -Exports the tray icon, menu, and tooltip on the observer's existing -session bus connection. No separate tray process is required. -""" - -import asyncio -import logging -import os -import subprocess -import time -from pathlib import Path - -from dbus_fast.aio import MessageBus - -from . import __version__ -from .dbusmenu import DBusMenu, MenuItem, separator -from .sni import StatusNotifierItem, register_with_watcher -from .sync_health import HealthState, SyncFacts, SyncHealth, derive_health - -log = logging.getLogger(__name__) - -# Icon names — these reference SVGs in our icon theme -ICONS = { - "recording": "solstone-recording", - "paused": "solstone-paused", - "idle": "solstone-paused", - "stopped": "solstone-error", - "syncing": "solstone-syncing", - "error": "solstone-error", -} - -# Agent instructions template copied to clipboard -AGENT_INSTRUCTIONS = """sol for Linux (repo: solstone-linux) -Source: {source_dir} -Read INSTALL.md at https://github.com/solpbc/solstone-linux/blob/main/INSTALL.md for setup and architecture. -Config: {config_path} -Captures: {captures_dir} -Logs: journalctl --user -u solstone-linux -f -Service: systemctl --user status solstone-linux""" - -SOURCE_DIR = str(Path(__file__).resolve().parent) - - -def _compute_header_label(status: str, health: SyncHealth, pause_remaining: int) -> str: - if status == "paused": - if pause_remaining and pause_remaining > 0: - mins = pause_remaining // 60 - return f"paused ({mins}m remaining)" - return "paused" - if status == "stopped": - return "not running" - if status == "recording": - return health.header_recording - if status == "idle": - return health.header_idle - return str(status) - - -def resolve_icon_theme_path() -> str: - """Return the SNI icon-theme search path, or '' if none is available. - - Prefers the installed location, then the in-repo contrib dir for - development. No index.theme is needed: the SNI host merges this path - against the system hicolor theme, which already declares scalable/status. - """ - installed_icon = ( - Path.home() - / ".local/share/icons/hicolor/scalable/status/solstone-recording.svg" - ) - if installed_icon.exists(): - return str(Path.home() / ".local/share/icons") - contrib = Path(__file__).resolve().parent.parent.parent / "contrib" / "icons" - if (contrib / "hicolor").is_dir(): - return str(contrib) - return "" - - -class TrayApp: - """In-process tray component — exports SNI on the observer's bus.""" - - def __init__(self, observer, bus): - self._observer = observer - self.config = observer.config - self.bus: MessageBus = bus - self.sni = StatusNotifierItem("solstone-observer") - self.menu = DBusMenu() - - # State cache (for change detection) — empty string forces first update() - # call to always go through _update_status regardless of initial mode - self.status = "" - self.health = self._fallback_health() - self.error = "" - self.paused_remaining = 0 - self.stats = {} - - # Menu item references for dynamic updates - self._status_header: MenuItem = None - self._status_item: MenuItem = None - self._sync_item: MenuItem = None - self._segment_item: MenuItem = None - self._cache_item: MenuItem = None - self._captures_item: MenuItem = None - self._uptime_item: MenuItem = None - self._pause_submenu: MenuItem = None - self._resume_item: MenuItem = None - - def _fallback_health(self) -> SyncHealth: - return derive_health( - SyncFacts(), - time.time(), - self.config.sync_stale_threshold, - ) - - def _current_health(self) -> SyncHealth: - obs = self._observer - if obs._sync: - return obs._sync.health - return self._fallback_health() - - async def start(self): - pid = os.getpid() - bus_name = f"org.kde.StatusNotifierItem-{pid}-1" - await self.bus.request_name(bus_name) - - # Export interfaces - self.bus.export("/StatusNotifierItem", self.sni) - self.bus.export("/MenuBar", self.menu) - - # Resolve icon theme: installed location, then dev/contrib fallback - self.sni._icon_theme_path = resolve_icon_theme_path() - if self.sni._icon_theme_path: - log.info(f"Icon theme path: {self.sni._icon_theme_path}") - - # Set initial icon - self.sni.set_icon(ICONS["recording"]) - self._update_accessible_descriptions() - self.sni.set_tooltip("sol", "starting…") - - # Build menu - self._build_menu() - - # Register with watcher (3 attempts) - registered = False - for attempt in range(3): - registered = await register_with_watcher(self.bus, bus_name) - if registered: - break - if attempt < 2: - await asyncio.sleep(1) - log.info(f"SNI watcher retry {attempt + 1}/2...") - - if not registered: - log.info("No StatusNotifierWatcher available") - return False - - return True - - def update(self): - """Read observer state and update tray display.""" - obs = self._observer - now = time.monotonic() - - # Determine status - if obs._paused: - status = "paused" - elif obs.current_mode == "screencast": - status = "recording" - else: - status = "idle" - - health = self._current_health() - - # Segment timer - if obs._paused or obs.segment_dir is None: - segment_timer = 0 - else: - remaining = obs.interval - (now - obs.start_at_mono) - segment_timer = max(0, int(remaining)) - - # Pause remaining - if not obs._paused or obs._pause_until <= 0: - pause_remaining = 0 - else: - pause_remaining = max(0, int(obs._pause_until - now)) - - capture_stats = obs.capture_stats - self.stats = { - "captures_today": capture_stats["captures_today"], - "total_size_mb": capture_stats["total_size_mb"], - "uptime_seconds": int(now - obs._start_mono), - } - - self._update_status(status, health) - self._update_sync(health) - self._update_header(pause_remaining, health) - self._update_live_stats(segment_timer, pause_remaining) - self.paused_remaining = pause_remaining - - def _on_about_to_show(self) -> bool: - """Full recompute on menu open; returns True if any item changed. - - Runs outside _refresh_tray so a failure here never tears down the tray. - """ - before = self.menu._props_emitted - try: - self.update() - except Exception: - log.warning("Tray on-open recompute failed", exc_info=True) - return False - return self.menu._props_emitted > before - - def _build_menu(self): - """Build the full tray menu structure.""" - - self._status_header = MenuItem(label="on", enabled=False) - - # ── Status submenu (live data) ── - self._status_item = MenuItem(label="on", enabled=False) - self._sync_item = MenuItem(label="sync: checking...", enabled=False) - self._segment_item = MenuItem(label="segment: --:--", enabled=False) - self._cache_item = MenuItem(label="cache: --", enabled=False) - self._captures_item = MenuItem(label="today: --", enabled=False) - self._uptime_item = MenuItem(label="uptime: --", enabled=False) - - status_submenu = MenuItem( - label="status", - children_display="submenu", - ) - status_submenu.children = [ - self._status_item, - self._sync_item, - separator(), - self._segment_item, - self._cache_item, - self._captures_item, - self._uptime_item, - ] - - # ── Pause / Resume ── - pause_15m = MenuItem(label="15 minutes", callback=lambda: self._pause(900)) - pause_30m = MenuItem(label="30 minutes", callback=lambda: self._pause(1800)) - pause_1h = MenuItem(label="1 hour", callback=lambda: self._pause(3600)) - pause_indef = MenuItem(label="until I resume", callback=lambda: self._pause(0)) - - self._pause_submenu = MenuItem( - label="pause", - children_display="submenu", - ) - self._pause_submenu.children = [pause_15m, pause_30m, pause_1h, pause_indef] - - self._resume_item = MenuItem( - label="resume", - visible=False, - callback=self._resume, - ) - - # ── Open journal / Show captures ── - open_journal = MenuItem( - label="open journal", - callback=self._open_journal, - ) - - # ── Settings submenu ── - settings_open_config = MenuItem( - label="open config.json", - callback=self._open_config, - ) - settings_submenu = MenuItem( - label="settings", - children_display="submenu", - ) - settings_submenu.children = [ - settings_open_config, - ] - - # ── About submenu ── - about_version = MenuItem( - label=f"sol v{__version__}", - enabled=False, - ) - about_website = MenuItem( - label="solstone.app", - callback=lambda: self._open_url("https://solstone.app/observers"), - ) - about_source = MenuItem( - label="source code", - callback=lambda: self._open_url("https://github.com/solpbc/solstone-linux"), - ) - about_privacy = MenuItem( - label="privacy policy", - callback=lambda: self._open_url("https://solpbc.org/privacy"), - ) - about_copyright = MenuItem( - label="\u00a9 2026 sol pbc \u2014 a public benefit corporation", - enabled=False, - ) - - about_submenu = MenuItem( - label="about", - children_display="submenu", - ) - about_copy_agent = MenuItem( - label="copy help agent instructions", - callback=self._copy_agent_instructions, - ) - - about_submenu.children = [ - about_version, - about_website, - about_source, - about_privacy, - about_copy_agent, - separator(), - about_copyright, - ] - - # ── Service hint ── - service_hint = MenuItem( - label="managed via systemctl", - enabled=False, - ) - - # ── Assemble full menu ── - self.menu.set_menu( - [ - self._status_header, - separator(), - self._pause_submenu, - self._resume_item, - separator(), - status_submenu, - open_journal, - settings_submenu, - about_submenu, - separator(), - service_hint, - ] - ) - self.menu.on_about_to_show = self._on_about_to_show - - def _icon_for_health(self, status: str, health: SyncHealth) -> str: - if self.error: - return ICONS["error"] - if status == "stopped": - return ICONS["stopped"] - if health.icon == "error": - return ICONS["error"] - if status == "paused": - return ICONS["paused"] - if health.icon == "syncing": - return ICONS["syncing"] - if status == "idle" and health.state == HealthState.CONNECTED: - return ICONS["idle"] - return ICONS.get(health.icon, ICONS["recording"]) - - def _update_status(self, status: str, health: SyncHealth): - """Update tray icon and menu for observer status.""" - old_status = self.status - old_health_state = self.health.state - self.health = health - if status == old_status and health.state == old_health_state: - return - self.status = status - - icon = self._icon_for_health(status, health) - self.sni.set_icon(icon) - - # Update tooltip - self.sni.set_tooltip("sol", self._build_tooltip(health)) - - # Toggle pause/resume - is_paused = status == "paused" - self._pause_submenu.visible = not is_paused - self._resume_item.visible = is_paused - if is_paused and self.paused_remaining > 0: - mins = self.paused_remaining // 60 - self._resume_item.label = f"resume ({mins}m remaining)" - else: - self._resume_item.label = "resume" - self.menu.update_properties(self._pause_submenu, "visible") - self.menu.update_properties(self._resume_item, "visible", "label") - - # SNI status - if status == "stopped" or self.error: - self.sni.set_status("NeedsAttention") - else: - self.sni.set_status(health.sni_status) - self._update_accessible_descriptions(health) - - log.info(f"Status -> {status} (icon: {icon})") - - def _update_header(self, pause_remaining: int, health: SyncHealth): - label = _compute_header_label(self.status, health, pause_remaining) - if label == self._status_header.label: - return - self._status_header.label = label - self._status_item.label = label - self.menu.update_properties(self._status_header, "label") - self.menu.update_properties(self._status_item, "label") - - def _update_sync(self, health: SyncHealth): - """Update sync status display.""" - if self._sync_item.label == health.sync_line: - return - self.health = health - self._sync_item.label = health.sync_line - self.menu.update_properties(self._sync_item, "label") - - if not self.error: - self.sni.set_icon(self._icon_for_health(self.status, health)) - if self.status == "stopped": - self.sni.set_status("NeedsAttention") - else: - self.sni.set_status(health.sni_status) - - self.sni.set_tooltip("sol", self._build_tooltip(health)) - self._update_accessible_descriptions(health) - - def _update_live_stats(self, segment_timer: int, pause_remaining: int): - """Update the live stats in the status submenu.""" - # Segment timer - mins = segment_timer // 60 - secs = segment_timer % 60 - new_label = f"segment: {mins}:{secs:02d} remaining" - if self._segment_item.label != new_label: - self._segment_item.label = new_label - self.menu.update_properties(self._segment_item, "label") - - # Stats (computed in update()) - if self.stats: - captures = self.stats.get("captures_today", 0) - size_mb = self.stats.get("total_size_mb", 0) - uptime = self.stats.get("uptime_seconds", 0) - - new_cache = f"cache: {size_mb} MB" - new_captures = f"today: {captures} segments" - - hours = uptime // 3600 - mins_up = (uptime % 3600) // 60 - new_uptime = f"uptime: {hours}h {mins_up}m" - - if self._cache_item.label != new_cache: - self._cache_item.label = new_cache - self.menu.update_properties(self._cache_item, "label") - if self._captures_item.label != new_captures: - self._captures_item.label = new_captures - self.menu.update_properties(self._captures_item, "label") - if self._uptime_item.label != new_uptime: - self._uptime_item.label = new_uptime - self.menu.update_properties(self._uptime_item, "label") - - # Update pause remaining in resume button - if self.status == "paused" and pause_remaining > 0: - pr_mins = pause_remaining // 60 - new_resume = f"resume ({pr_mins}m remaining)" - if self._resume_item.label != new_resume: - self._resume_item.label = new_resume - self.menu.update_properties(self._resume_item, "label") - - def _build_tooltip(self, health: SyncHealth | None = None) -> str: - """Build plain-text tooltip body (cross-DE compatible).""" - if health is None: - health = self.health - parts = [] - - status_labels = { - "recording": "on", - "paused": "paused", - "idle": "idle (screen inactive)", - "stopped": "not running", - } - parts.append(status_labels.get(self.status, self.status)) - - parts.append(health.tooltip) - - if self.error: - parts.append(self.error) - - return "\n".join(parts) - - def _update_accessible_descriptions(self, health: SyncHealth | None = None): - if health is None: - health = self.health - if self.error: - desc = "sol — error" - elif self.status == "paused": - desc = "sol — paused" - elif self.status == "idle": - desc = health.accessible_idle - elif self.status == "stopped": - desc = "sol — stopped" - else: - desc = health.accessible_recording - - self.sni.set_icon_accessible_desc(desc) - self.sni.set_attention_accessible_desc(desc) - - # ── Menu callbacks ── - - def _pause(self, seconds: int): - log.info(f"Pause: {seconds}s") - self._observer.pause(seconds) - - def _resume(self): - log.info("Resume") - self._observer.resume() - - def _open_journal(self): - log.info("Opening journal") - self._open_url(self.config.server_url or "https://solstone.app") - - def _open_config(self): - config_path = str(self.config.config_path) - log.info(f"Opening config: {config_path}") - try: - subprocess.Popen( - ["xdg-open", config_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - log.error(f"Failed to open config: {e}") - - def _copy_agent_instructions(self): - """Copy coding agent instructions to clipboard.""" - text = AGENT_INSTRUCTIONS.format( - source_dir=SOURCE_DIR, - config_path=str(self.config.config_path), - captures_dir=str(self.config.captures_dir), - ) - log.info("Copying agent instructions to clipboard") - try: - # wl-copy for Wayland, xclip for X11 - session_type = os.environ.get("XDG_SESSION_TYPE", "") - if session_type == "wayland" or os.environ.get("WAYLAND_DISPLAY"): - proc = subprocess.Popen(["wl-copy"], stdin=subprocess.PIPE) - else: - proc = subprocess.Popen( - ["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE - ) - proc.communicate(text.encode()) - log.info("Copied to clipboard") - except FileNotFoundError: - # Fallback: try xsel - try: - proc = subprocess.Popen( - ["xsel", "--clipboard", "--input"], stdin=subprocess.PIPE - ) - proc.communicate(text.encode()) - log.info("Copied to clipboard (xsel)") - except FileNotFoundError: - log.error("No clipboard tool found (wl-copy, xclip, or xsel)") - - def _open_url(self, url: str): - log.info(f"Opening: {url}") - try: - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - log.error(f"Failed to open URL: {e}") diff --git a/src/solstone_linux/upload.py b/src/solstone_linux/upload.py deleted file mode 100644 index 2734a29..0000000 --- a/src/solstone_linux/upload.py +++ /dev/null @@ -1,350 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""HTTP upload client for solstone ingest server. - -Extracted from solstone's observe/remote_client.py. Accepts Config -as constructor parameter instead of reading config internally. - -Refinements over tmux baseline: -- Bounded immediate in-call retries (MAX_IMMEDIATE_ATTEMPTS); long retry is - owned by the sync loop + circuit breaker -- Error classification: auth (401/403) vs transient (5xx/network) -""" - -from __future__ import annotations - -import logging -import platform -import socket -import threading -import time -from pathlib import Path -from typing import Any, NamedTuple - -import requests - -from . import __version__ -from .config import Config -from .event_sender import EventSender -from .sync_health import ErrorType - -logger = logging.getLogger(__name__) - -UPLOAD_TIMEOUT = 300 -EVENT_TIMEOUT = 30 -EVENT_DRAIN_TIMEOUT = 3.0 -STREAM_TYPE = "desktop" -OBSERVER_PROTOCOL_VERSION = 2 -OBSERVER_PROTOCOL_VERSION_HEADER = "X-Solstone-Protocol-Version" - -# Immediate in-call upload attempts before deferring to the sync loop. -# Long retry/backoff is owned by SyncService + the circuit breaker, not here. -MAX_IMMEDIATE_ATTEMPTS = 2 - -_CONTENT_TYPES = {".flac": "audio/flac", ".webm": "video/webm"} - - -def _auth_headers(key: str) -> dict[str, str]: - return {"Authorization": f"Bearer {key}"} - - -class UploadResult(NamedTuple): - success: bool - duplicate: bool = False - error_type: ErrorType | None = None - stored_key: str | None = None - - -class QueryResult(NamedTuple): - segments: list[dict] | None - error_type: ErrorType | None = None - status_code: int | None = None - legacy: bool = False - truncated: bool = False - - -class UploadClient: - """HTTP client for uploading observer segments to the ingest server.""" - - def __init__(self, config: Config): - self._url = config.server_url.rstrip("/") if config.server_url else "" - self._key = config.key - self._stream = config.stream - self._revoked = False - self._stop_event = threading.Event() - self._session = requests.Session() - self._event_session = requests.Session() - self._event_sender = EventSender(self.relay_event) - self._retry_backoff = config.sync_retry_delays or [5, 30, 120, 300] - # Immediate in-call attempts: floor at 1, honor a low cap, bound a high one. - # Long retry is owned by SyncService + circuit breaker (see upload_segment). - self._immediate_attempts = max( - 1, min(config.sync_max_retries, MAX_IMMEDIATE_ATTEMPTS) - ) - - @property - def is_revoked(self) -> bool: - return self._revoked - - @property - def is_registered(self) -> bool: - return bool(self._key) - - def request_stop(self) -> None: - """Signal any in-flight upload retry wait to return promptly (transient).""" - self._stop_event.set() - - def _persist_registration(self, config: Config, key: str, stream: str) -> None: - """Persist the server-issued handle and locked stream back to config.""" - from .config import save_config - - config.key = key - config.stream = stream - save_config(config) - - def ensure_registered(self, config: Config) -> bool: - """Register this observer over HTTP, persisting the handle + locked stream. - - Short-circuits if a key is already present. Returns True if a key is available. - """ - if self._key: - return True - if not self._url: - return False - - descriptor: dict[str, Any] = { - "platform": platform.system().lower(), - "hostname": socket.gethostname(), - "stream_type": STREAM_TYPE, - "version": __version__, - } - if self._stream: - descriptor["label"] = self._stream - - url = f"{self._url}/app/observer/register" - - retries = min(3, len(self._retry_backoff)) - for attempt in range(retries): - delay = self._retry_backoff[min(attempt, len(self._retry_backoff) - 1)] - try: - resp = self._session.post(url, json=descriptor, timeout=EVENT_TIMEOUT) - if resp.status_code == 200: - data = resp.json() - self._key = data["key"] - self._stream = data["name"] - self._persist_registration(config, data["key"], data["name"]) - logger.info( - f"Registered as '{data['name']}' (key: {self._key[:8]}...)" - ) - return True - elif resp.status_code == 403: - self._revoked = True - logger.error("Registration rejected (403)") - return False - else: - logger.warning( - f"Registration attempt {attempt + 1} failed: {resp.status_code}" - ) - except requests.RequestException as e: - logger.warning(f"Registration attempt {attempt + 1} failed: {e}") - if attempt < retries - 1: - time.sleep(delay) - - logger.error(f"Registration failed after {retries} attempts") - return False - - @staticmethod - def classify_error( - status_code: int | None, is_network_error: bool = False - ) -> ErrorType: - """Classify an error for circuit breaker and retry decisions.""" - if is_network_error: - return ErrorType.TRANSIENT - if status_code is None: - return ErrorType.TRANSIENT - if status_code in (401, 403): - return ErrorType.AUTH - if status_code == 400: - return ErrorType.CLIENT - if status_code == 404: - return ErrorType.INCOMPATIBLE - # 5xx and anything else - return ErrorType.TRANSIENT - - def upload_segment( - self, - day: str, - segment: str, - files: list[Path], - ) -> UploadResult: - """Upload a segment's files to the ingest server.""" - if self._revoked or not self._key or not self._url: - return UploadResult( - False, error_type=ErrorType.AUTH if self._revoked else ErrorType.CLIENT - ) - - url = f"{self._url}/app/observer/ingest" - - for attempt in range(self._immediate_attempts): - file_handles = [] - files_data = [] - error_type = None - try: - for path in files: - if not path.exists(): - logger.warning(f"File not found, skipping: {path}") - continue - fh = open(path, "rb") - file_handles.append(fh) - content_type = _CONTENT_TYPES.get( - path.suffix.lower(), "application/octet-stream" - ) - files_data.append(("files", (path.name, fh, content_type))) - - if not files_data: - return UploadResult(False) - - data = {"day": day, "segment": segment} - - response = self._session.post( - url, - data=data, - files=files_data, - headers=_auth_headers(self._key), - timeout=UPLOAD_TIMEOUT, - ) - - if response.status_code == 200: - resp_data = response.json() - status = resp_data.get("status") - is_duplicate = status == "duplicate" - stored_key = ( - resp_data.get("existing_segment") - if is_duplicate - else resp_data.get("segment") - ) - return UploadResult( - True, duplicate=is_duplicate, stored_key=stored_key - ) - - error_type = self.classify_error(response.status_code) - - if error_type == ErrorType.AUTH: - if response.status_code == 403: - self._revoked = True - logger.error( - f"Upload rejected ({response.status_code}): {response.text}" - ) - return UploadResult(False, error_type=error_type) - - if error_type in (ErrorType.CLIENT, ErrorType.INCOMPATIBLE): - logger.error( - f"Upload rejected ({response.status_code}): {response.text}" - ) - return UploadResult(False, error_type=error_type) - - logger.warning( - f"Upload attempt {attempt + 1} failed: " - f"{response.status_code} {response.text}" - ) - except requests.RequestException as e: - error_type = ErrorType.TRANSIENT - logger.warning(f"Upload attempt {attempt + 1} failed: {e}") - finally: - for fh in file_handles: - try: - fh.close() - except Exception: - pass - - if attempt < self._immediate_attempts - 1: - delay = self._retry_backoff[min(attempt, len(self._retry_backoff) - 1)] - if self._stop_event.wait(delay): - return UploadResult(False, error_type=ErrorType.TRANSIENT) - - logger.error( - f"Upload failed after {self._immediate_attempts} attempts: {day}/{segment}" - ) - return UploadResult(False, error_type=error_type) - - def get_server_segments(self, day: str) -> QueryResult: - """Query server for segments on a given day. - - Returns segment dicts on success, with error details on failure. - """ - if self._revoked: - return QueryResult(None, ErrorType.AUTH, None) - if not self._key or not self._url: - return QueryResult(None, ErrorType.CLIENT, None) - - url = f"{self._url}/app/observer/ingest/segments/{day}" - headers = { - **_auth_headers(self._key), - OBSERVER_PROTOCOL_VERSION_HEADER: str(OBSERVER_PROTOCOL_VERSION), - } - - try: - resp = self._session.get(url, headers=headers, timeout=EVENT_TIMEOUT) - if resp.status_code == 200: - body = resp.json() - if isinstance(body, list): - return QueryResult(body, None, resp.status_code, legacy=True) - if isinstance(body, dict): - items = body.get("items", []) - total = body.get("total", len(items)) - truncated = total != len(items) - return QueryResult( - items, - None, - resp.status_code, - legacy=False, - truncated=truncated, - ) - return QueryResult([], None, resp.status_code) - error_type = self.classify_error(resp.status_code) - if error_type == ErrorType.AUTH: - if resp.status_code == 403: - self._revoked = True - logger.error(f"Segments query rejected ({resp.status_code})") - logger.warning(f"Segments query failed: {resp.status_code}") - return QueryResult(None, error_type, resp.status_code) - except requests.RequestException as e: - logger.debug(f"Segments query failed: {e}") - return QueryResult(None, ErrorType.TRANSIENT, None) - - def relay_event(self, tract: str, event: str, **fields: Any) -> bool: - """Fire-and-forget event relay.""" - if self._revoked or not self._key or not self._url: - return False - - url = f"{self._url}/app/observer/ingest/event" - payload = {"tract": tract, "event": event, **fields} - try: - resp = self._event_session.post( - url, - json=payload, - headers=_auth_headers(self._key), - timeout=EVENT_TIMEOUT, - ) - if resp.status_code == 200: - return True - if resp.status_code == 403: - self._revoked = True - return False - except requests.RequestException: - return False - - def enqueue_status(self, fields: dict[str, Any]) -> None: - self._event_sender.submit_status(fields) - self._event_sender.start() - - def enqueue_stream_silent(self, fields: dict[str, Any]) -> None: - self._event_sender.submit_stream_silent(fields) - self._event_sender.start() - - def stop(self) -> None: - self._stop_event.set() - self._event_sender.stop(EVENT_DRAIN_TIMEOUT) - self._event_session.close() - self._session.close() diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index f89094c..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import pytest - - -@pytest.fixture(autouse=True) -def _isolate_xdg_config(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config-home")) - yield diff --git a/tests/fixtures/introspection/dbusmenu.xml b/tests/fixtures/introspection/dbusmenu.xml deleted file mode 100644 index 0fba32f..0000000 --- a/tests/fixtures/introspection/dbusmenu.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/fixtures/introspection/status_notifier_item.xml b/tests/fixtures/introspection/status_notifier_item.xml deleted file mode 100644 index c1c3584..0000000 --- a/tests/fixtures/introspection/status_notifier_item.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/test_activity.py b/tests/test_activity.py deleted file mode 100644 index 0453b62..0000000 --- a/tests/test_activity.py +++ /dev/null @@ -1,933 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for cross-desktop activity detection backends.""" - -import logging -import subprocess -import weakref -from unittest.mock import AsyncMock, MagicMock, call - -import pytest -from dbus_fast.errors import DBusError, InvalidMemberNameError - -from solstone_linux import activity - - -@pytest.fixture(autouse=True) -def _clear_proxy_cache(): - activity._PROXY_CACHE.clear() - yield - activity._PROXY_CACHE.clear() - - -def _make_proxy_with_interface(interface: MagicMock) -> MagicMock: - proxy = MagicMock() - proxy.get_interface.return_value = interface - return proxy - - -def _make_variant(value: int) -> MagicMock: - variant = MagicMock() - variant.value = value - return variant - - -def _service_unknown(detail: str) -> DBusError: - return DBusError("org.freedesktop.DBus.Error.ServiceUnknown", detail) - - -def _no_reply(detail: str) -> DBusError: - return DBusError("org.freedesktop.DBus.Error.NoReply", detail) - - -def _make_name_has_owner_bus( - *, return_value: bool | None = None, side_effect=None -) -> tuple[MagicMock, MagicMock]: - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - if side_effect is not None: - iface.call_name_has_owner = AsyncMock(side_effect=side_effect) - else: - iface.call_name_has_owner = AsyncMock(return_value=return_value) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - return bus, iface - - -class _WeakrefLessBus: - """Mimics dbus-fast's cython MessageBus: no __weakref__ slot, so - weakref.ref(bus) raises TypeError exactly like the real object.""" - - __slots__ = ("introspect", "get_proxy_object") - - def __init__(self, introspect, get_proxy_object): - self.introspect = introspect - self.get_proxy_object = get_proxy_object - - -class TestIsScreenLocked: - """Test screen lock fallback order.""" - - @pytest.fixture(autouse=True) - def _clear_xdg_desktop(self, monkeypatch): - monkeypatch.delenv("XDG_CURRENT_DESKTOP", raising=False) - - @pytest.mark.asyncio - async def test_fdo_backend_returns_true_without_gnome_fallback(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_screen_locked(bus) - - assert result is True - assert bus.introspect.await_count == 1 - bus.introspect.assert_awaited_once_with( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - @pytest.mark.asyncio - async def test_xdg_current_desktop_ubuntu_gnome_skips_fdo_and_returns_gnome_state( - self, monkeypatch, caplog - ): - monkeypatch.setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME") - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - with caplog.at_level(logging.WARNING): - result = await activity.is_screen_locked(bus) - - assert result is True - assert bus.introspect.await_args_list == [ - call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH) - ] - assert not any( - "is_screen_locked FDO backend failed" in record.message - for record in caplog.records - ) - - @pytest.mark.asyncio - async def test_xdg_current_desktop_kde_still_probes_fdo_first(self, monkeypatch): - monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE") - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_screen_locked(bus) - - assert result is True - assert bus.introspect.await_count == 1 - bus.introspect.assert_awaited_once_with( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - @pytest.mark.asyncio - async def test_xdg_current_desktop_not_gnome_does_not_match_substring( - self, monkeypatch - ): - monkeypatch.setenv("XDG_CURRENT_DESKTOP", "NOT-GNOME") - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_screen_locked(bus) - - assert result is True - assert bus.introspect.await_count == 1 - bus.introspect.assert_awaited_once_with( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - @pytest.mark.asyncio - async def test_fdo_backend_returns_false_without_gnome_fallback(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_active = AsyncMock(return_value=False) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_screen_locked(bus) - - assert result is False - assert bus.introspect.await_count == 1 - bus.introspect.assert_awaited_once_with( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - @pytest.mark.asyncio - async def test_fdo_failure_gnome_returns_true(self): - bus = MagicMock() - bus.introspect = AsyncMock( - side_effect=[_service_unknown("fdo unavailable"), object()] - ) - gnome_iface = MagicMock() - gnome_iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(gnome_iface) - - result = await activity.is_screen_locked(bus) - - assert result is True - assert bus.introspect.await_args_list == [ - call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), - call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), - ] - - @pytest.mark.asyncio - async def test_fdo_failure_gnome_returns_false(self): - bus = MagicMock() - bus.introspect = AsyncMock( - side_effect=[_service_unknown("fdo unavailable"), object()] - ) - gnome_iface = MagicMock() - gnome_iface.call_get_active = AsyncMock(return_value=False) - bus.get_proxy_object.return_value = _make_proxy_with_interface(gnome_iface) - - result = await activity.is_screen_locked(bus) - - assert result is False - assert bus.introspect.await_args_list == [ - call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), - call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), - ] - - @pytest.mark.asyncio - async def test_both_backends_fail_returns_false(self): - bus = MagicMock() - bus.introspect = AsyncMock( - side_effect=[ - _service_unknown("fdo unavailable"), - _service_unknown("gnome unavailable"), - ] - ) - - result = await activity.is_screen_locked(bus) - - assert result is False - assert bus.introspect.await_args_list == [ - call(activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH), - call(activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH), - ] - - @pytest.mark.asyncio - async def test_is_screen_locked_fdo_parser_error_falls_through_to_gnome( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock( - side_effect=[InvalidMemberNameError("bad"), object()] - ) - gnome_iface = MagicMock() - gnome_iface.call_get_active = AsyncMock(return_value=True) - bus.get_proxy_object.return_value = _make_proxy_with_interface(gnome_iface) - - with caplog.at_level(logging.WARNING): - result = await activity.is_screen_locked(bus) - - assert result is True - assert [record.message for record in caplog.records] == [ - "is_screen_locked FDO backend failed: " - "service=org.freedesktop.ScreenSaver path=/ScreenSaver: " - "InvalidMemberNameError: invalid member name: bad" - ] - - @pytest.mark.parametrize( - "error_name", - [ - "org.freedesktop.DBus.Error.ServiceUnknown", - "org.freedesktop.DBus.Error.NameHasNoOwner", - ], - ) - @pytest.mark.asyncio - async def test_is_screen_locked_service_missing_does_not_log( - self, caplog, error_name - ): - bus = MagicMock() - bus.introspect = AsyncMock( - side_effect=[ - DBusError(error_name, "missing"), - DBusError(error_name, "missing"), - ] - ) - - with caplog.at_level(logging.WARNING): - result = await activity.is_screen_locked(bus) - - assert result is False - assert caplog.records == [] - - @pytest.mark.asyncio - async def test_is_screen_locked_both_backends_broken_logs_both_warnings( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_no_reply("broke"), _no_reply("broke")]) - - with caplog.at_level(logging.WARNING): - result = await activity.is_screen_locked(bus) - - assert result is False - assert [record.message for record in caplog.records] == [ - "is_screen_locked FDO backend failed: " - "service=org.freedesktop.ScreenSaver path=/ScreenSaver: " - "DBusError: broke", - "is_screen_locked GNOME backend failed: " - "service=org.gnome.ScreenSaver path=/org/gnome/ScreenSaver: " - "DBusError: broke", - ] - - @pytest.mark.asyncio - async def test_is_screen_locked_caches_and_invalidates_same_bus(self): - activity._PROXY_CACHE.clear() - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - fdo_iface = MagicMock() - fdo_iface.call_get_active = AsyncMock( - side_effect=[False, False, _no_reply("broke"), False] - ) - gnome_iface = MagicMock() - gnome_iface.call_get_active = AsyncMock(return_value=False) - - def get_proxy_object(service, _path, _intro): - if service == activity.FDO_SCREENSAVER_BUS: - return _make_proxy_with_interface(fdo_iface) - return _make_proxy_with_interface(gnome_iface) - - bus.get_proxy_object.side_effect = get_proxy_object - - assert await activity.is_screen_locked(bus) is False - assert await activity.is_screen_locked(bus) is False - assert bus.introspect.await_count == 1 - - assert await activity.is_screen_locked(bus) is False - assert bus.introspect.await_count == 2 - assert bus.introspect.await_args_list[-1] == call( - activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH - ) - - assert await activity.is_screen_locked(bus) is False - assert bus.introspect.await_count == 3 - assert bus.introspect.await_args_list[-1] == call( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - @pytest.mark.asyncio - async def test_is_screen_locked_caches_weakref_less_bus(self): - introspect = AsyncMock(return_value=object()) - fdo_iface = MagicMock() - fdo_iface.call_get_active = AsyncMock( - side_effect=[False, False, _no_reply("broke"), False] - ) - gnome_iface = MagicMock() - gnome_iface.call_get_active = AsyncMock(return_value=False) - - def get_proxy_object(service, _path, _intro): - if service == activity.FDO_SCREENSAVER_BUS: - return _make_proxy_with_interface(fdo_iface) - return _make_proxy_with_interface(gnome_iface) - - bus = _WeakrefLessBus( - introspect=introspect, - get_proxy_object=MagicMock(side_effect=get_proxy_object), - ) - - with pytest.raises(TypeError): - weakref.ref(bus) - - assert await activity.is_screen_locked(bus) is False - assert await activity.is_screen_locked(bus) is False - assert introspect.await_count == 1 - - assert await activity.is_screen_locked(bus) is False - assert introspect.await_count == 2 - assert introspect.await_args_list[-1] == call( - activity.GNOME_SCREENSAVER_BUS, activity.GNOME_SCREENSAVER_PATH - ) - - assert await activity.is_screen_locked(bus) is False - assert introspect.await_count == 3 - assert introspect.await_args_list[-1] == call( - activity.FDO_SCREENSAVER_BUS, activity.FDO_SCREENSAVER_PATH - ) - - -class TestIsPowerSaveActive: - """Test power save fallback order.""" - - @pytest.fixture(autouse=True) - def _clear_warning_cache(self, monkeypatch): - activity._POWER_SAVE_WARNED_BACKENDS.clear() - # Prevent the DPMS path from being entered in existing tests - monkeypatch.delenv("XDG_SESSION_TYPE", raising=False) - - @pytest.mark.asyncio - async def test_gnome_backend_nonzero_mode_returns_true(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get = AsyncMock(return_value=_make_variant(2)) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_power_save_active(bus) - - assert result is True - bus.introspect.assert_awaited_once_with( - activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH - ) - - @pytest.mark.asyncio - async def test_gnome_backend_zero_mode_returns_false(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get = AsyncMock(return_value=_make_variant(0)) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.is_power_save_active(bus) - - assert result is False - bus.introspect.assert_awaited_once_with( - activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH - ) - - @pytest.mark.asyncio - async def test_mutter_backend_failure_returns_false(self): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_service_unknown("gnome unavailable")]) - - result = await activity.is_power_save_active(bus) - - assert result is False - assert bus.introspect.await_args_list == [ - call(activity.DISPLAY_CONFIG_BUS, activity.DISPLAY_CONFIG_PATH), - ] - - @pytest.mark.asyncio - async def test_is_power_save_active_mutter_parser_error_non_x11_returns_false( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[InvalidMemberNameError("bad")]) - - with caplog.at_level(logging.WARNING): - result = await activity.is_power_save_active(bus) - - assert result is False - assert [record.message for record in caplog.records] == [ - "is_power_save_active Mutter backend failed: " - "service=org.gnome.Mutter.DisplayConfig " - "path=/org/gnome/Mutter/DisplayConfig: " - "InvalidMemberNameError: invalid member name: bad" - ] - - @pytest.mark.parametrize( - "error_name", - [ - "org.freedesktop.DBus.Error.ServiceUnknown", - "org.freedesktop.DBus.Error.NameHasNoOwner", - ], - ) - @pytest.mark.asyncio - async def test_is_power_save_active_service_missing_does_not_log( - self, caplog, error_name - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[DBusError(error_name, "missing")]) - - with caplog.at_level(logging.WARNING): - result = await activity.is_power_save_active(bus) - - assert result is False - assert caplog.records == [] - - @pytest.mark.asyncio - async def test_is_power_save_active_mutter_backend_broken_logs_warning( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_no_reply("broke")]) - - with caplog.at_level(logging.WARNING): - result = await activity.is_power_save_active(bus) - - assert result is False - assert [record.message for record in caplog.records] == [ - "is_power_save_active Mutter backend failed: " - "service=org.gnome.Mutter.DisplayConfig " - "path=/org/gnome/Mutter/DisplayConfig: DBusError: broke", - ] - - @pytest.mark.asyncio - async def test_is_power_save_active_repeated_mutter_failures_log_debug_after_first( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_no_reply("broke")] * 2) - - with caplog.at_level(logging.DEBUG): - assert await activity.is_power_save_active(bus) is False - assert await activity.is_power_save_active(bus) is False - - warnings = [ - record.message - for record in caplog.records - if record.levelno == logging.WARNING - ] - debug = [ - record.message - for record in caplog.records - if record.levelno == logging.DEBUG - ] - assert warnings == [ - "is_power_save_active Mutter backend failed: " - "service=org.gnome.Mutter.DisplayConfig " - "path=/org/gnome/Mutter/DisplayConfig: DBusError: broke", - ] - assert debug == warnings - - @pytest.mark.asyncio - async def test_mutter_unavailable_non_gnome_x11_dpms_standby_reads_power_save( - self, monkeypatch - ): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_service_unknown("gnome unavailable")]) - monkeypatch.setattr(activity, "is_dpms_active", AsyncMock(return_value=True)) - - result = await activity.is_power_save_active(bus) - - assert result is True - - @pytest.mark.asyncio - async def test_mutter_unavailable_x11_dpms_returns_false(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_service_unknown("gnome unavailable")]) - monkeypatch.setattr(activity, "is_dpms_active", AsyncMock(return_value=False)) - - result = await activity.is_power_save_active(bus) - - assert result is False - - @pytest.mark.asyncio - async def test_mutter_unavailable_non_x11_skips_dpms(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=[_service_unknown("gnome unavailable")]) - mock_dpms = AsyncMock(return_value=True) - monkeypatch.setattr(activity, "is_dpms_active", mock_dpms) - - result = await activity.is_power_save_active(bus) - - assert result is False - mock_dpms.assert_not_called() - - -class TestProbeActivityServices: - """Test activity backend probing and logging.""" - - @pytest.mark.asyncio - async def test_all_services_available_returns_true_results(self): - bus, _ = _make_name_has_owner_bus(return_value=True) - - results = await activity.probe_activity_services(bus) - - assert results["fdo_screensaver"] is True - assert results["gnome_screensaver"] is True - assert results["gnome_display_config"] is True - assert results["kscreen"] is True - assert results["gtk4"] is activity._HAS_GTK - - @pytest.mark.asyncio - async def test_no_services_available_logs_warning(self, caplog): - bus, _ = _make_name_has_owner_bus(return_value=False) - - with caplog.at_level(logging.WARNING): - results = await activity.probe_activity_services(bus) - - assert results["fdo_screensaver"] is False - assert results["gnome_screensaver"] is False - assert results["gnome_display_config"] is False - assert results["kscreen"] is False - assert "No activity backends available" in caplog.text - - @pytest.mark.asyncio - async def test_mixed_service_availability_returns_correct_results(self): - bus, _ = _make_name_has_owner_bus(side_effect=[True, False, True, True]) - - results = await activity.probe_activity_services(bus) - - assert results["fdo_screensaver"] is True - assert results["gnome_screensaver"] is False - assert results["gnome_display_config"] is True - assert results["kscreen"] is True - - @pytest.mark.asyncio - async def test_probe_activity_services_parser_error_on_one_service_logs_and_continues( - self, caplog - ): - bus, _ = _make_name_has_owner_bus( - side_effect=[True, InvalidMemberNameError("bad"), True, True] - ) - - with caplog.at_level(logging.INFO): - results = await activity.probe_activity_services(bus) - - assert results["fdo_screensaver"] is True - assert results["gnome_screensaver"] is False - assert results["gnome_display_config"] is True - assert results["kscreen"] is True - assert results["gtk4"] is activity._HAS_GTK - assert "dpms" in results - messages = [record.message for record in caplog.records] - assert ( - "NameHasOwner probe failed: service=org.gnome.ScreenSaver " - "path=/org/freedesktop/DBus: " - "InvalidMemberNameError: invalid member name: bad" - ) in messages - assert any(message.startswith("Screen lock backends:") for message in messages) - assert any(message.startswith("Power save backends:") for message in messages) - assert any(message.startswith("Monitor backends:") for message in messages) - - -class TestGetMonitorGeometriesKscreen: - """Test KDE KScreen monitor geometry detection.""" - - @pytest.mark.asyncio - async def test_returns_monitors_from_kscreen_dbus(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_config = AsyncMock( - return_value={ - "outputs": { - 1: { - "enabled": True, - "connected": True, - "name": "DP-1", - "pos": {"x": 0, "y": 0}, - "size": {"width": 1920, "height": 1080}, - "scale": 1.0, - }, - 2: { - "enabled": True, - "connected": True, - "name": "DP-2", - "pos": {"x": 1920, "y": 0}, - "size": {"width": 2560, "height": 1440}, - "scale": 1.0, - }, - } - } - ) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, - {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, - ] - bus.introspect.assert_awaited_once_with( - activity.KSCREEN_BUS, activity.KSCREEN_PATH - ) - - @pytest.mark.asyncio - async def test_skips_disabled_outputs(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_config = AsyncMock( - return_value={ - "outputs": { - 1: { - "enabled": True, - "connected": True, - "name": "DP-1", - "pos": {"x": 0, "y": 0}, - "size": {"width": 1920, "height": 1080}, - "scale": 1.0, - }, - 2: { - "enabled": False, - "connected": True, - "name": "DP-2", - "pos": {"x": 1920, "y": 0}, - "size": {"width": 2560, "height": 1440}, - "scale": 1.0, - }, - } - } - ) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "center"} - ] - - @pytest.mark.asyncio - async def test_returns_empty_on_dbus_failure(self): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=_service_unknown("missing")) - - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [] - - @pytest.mark.asyncio - async def test_applies_scale_factor(self): - bus = MagicMock() - bus.introspect = AsyncMock(return_value=object()) - iface = MagicMock() - iface.call_get_config = AsyncMock( - return_value={ - "outputs": { - 1: { - "enabled": True, - "connected": True, - "name": "DP-1", - "pos": {"x": 0, "y": 0}, - "size": {"width": 3840, "height": 2160}, - "scale": 2.0, - } - } - } - ) - bus.get_proxy_object.return_value = _make_proxy_with_interface(iface) - - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "center"} - ] - - @pytest.mark.asyncio - async def test_get_monitor_geometries_kscreen_dbus_error_logs_and_returns_empty( - self, caplog - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=_no_reply("broke")) - - with caplog.at_level(logging.WARNING): - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [] - assert [record.message for record in caplog.records] == [ - "get_monitor_geometries_kscreen failed: " - "service=org.kde.KScreen path=/backend: DBusError: broke" - ] - - @pytest.mark.parametrize( - "error_name", - [ - "org.freedesktop.DBus.Error.ServiceUnknown", - "org.freedesktop.DBus.Error.NameHasNoOwner", - ], - ) - @pytest.mark.asyncio - async def test_get_monitor_geometries_kscreen_service_missing_does_not_log( - self, caplog, error_name - ): - bus = MagicMock() - bus.introspect = AsyncMock(side_effect=DBusError(error_name, "missing")) - - with caplog.at_level(logging.WARNING): - result = await activity.get_monitor_geometries_kscreen(bus) - - assert result == [] - assert caplog.records == [] - - -class TestGetMonitorGeometriesX11: - """Test xrandr-based monitor geometry detection.""" - - XRANDR_TWO_MONITORS = ( - "Screen 0: minimum 8 x 8, current 3840 x 1080, maximum 32767 x 32767\n" - "DP-1 connected primary 1920x1080+0+0 (normal left inverted right) 527mm x 296mm\n" - " 1920x1080 60.00*+\n" - "DP-2 connected 1920x1080+1920+0 (normal left inverted right) 527mm x 296mm\n" - " 1920x1080 60.00*+\n" - "HDMI-1 disconnected (normal left inverted right)\n" - ) - - def _make_run(self, stdout, returncode=0): - return lambda *a, **kw: subprocess.CompletedProcess( - [], returncode=returncode, stdout=stdout, stderr="" - ) - - def test_parses_two_connected_monitors(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run(self.XRANDR_TWO_MONITORS) - ) - - result = activity.get_monitor_geometries_x11() - - assert len(result) == 2 - connectors = {m["id"] for m in result} - assert connectors == {"DP-1", "DP-2"} - - def test_assigns_position_labels(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run(self.XRANDR_TWO_MONITORS) - ) - - result = activity.get_monitor_geometries_x11() - - positions = {m["id"]: m["position"] for m in result} - assert positions["DP-1"] == "left" - assert positions["DP-2"] == "right" - - def test_correct_box_coordinates(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run(self.XRANDR_TWO_MONITORS) - ) - - result = activity.get_monitor_geometries_x11() - - by_id = {m["id"]: m for m in result} - assert by_id["DP-1"]["box"] == [0, 0, 1920, 1080] - assert by_id["DP-2"]["box"] == [1920, 0, 3840, 1080] - - def test_skips_disconnected_monitors(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run(self.XRANDR_TWO_MONITORS) - ) - - result = activity.get_monitor_geometries_x11() - - assert all(m["id"] != "HDMI-1" for m in result) - - def test_xrandr_missing_returns_empty(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, - "run", - lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError()), - ) - - result = activity.get_monitor_geometries_x11() - - assert result == [] - - def test_xrandr_nonzero_returns_empty(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run("", returncode=1) - ) - - result = activity.get_monitor_geometries_x11() - - assert result == [] - - def test_single_monitor_gets_center_position(self, monkeypatch): - xrandr_out = ( - "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\n" - "DP-1 connected primary 1920x1080+0+0 (normal left inverted right) 527mm x 296mm\n" - ) - monkeypatch.setattr(activity.subprocess, "run", self._make_run(xrandr_out)) - - result = activity.get_monitor_geometries_x11() - - assert len(result) == 1 - assert result[0]["position"] == "center" - - def test_negative_offset_skipped_with_warning(self, monkeypatch, caplog): - xrandr_out = ( - "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\n" - "DP-1 connected 1920x1080+-100+0 (normal left inverted right)\n" - ) - monkeypatch.setattr(activity.subprocess, "run", self._make_run(xrandr_out)) - - with caplog.at_level(logging.WARNING): - result = activity.get_monitor_geometries_x11() - - assert result == [] - assert any("negative" in r.message for r in caplog.records) - - -class TestIsDpmsActive: - """Test DPMS power-save state detection via xset.""" - - def _make_run(self, stdout, returncode=0): - return lambda *a, **kw: subprocess.CompletedProcess( - [], returncode=returncode, stdout=stdout, stderr="" - ) - - XSET_ON = ( - "Keyboard Control:\n" - "DPMS (Energy Star):\n" - " Standby: 600 Suspend: 600 Off: 600\n" - " DPMS is Enabled\n" - " Monitor is On\n" - ) - XSET_STANDBY = "DPMS (Energy Star):\n DPMS is Enabled\n Monitor is Standby\n" - XSET_OFF = "DPMS (Energy Star):\n DPMS is Enabled\n Monitor is Off\n" - - @pytest.mark.asyncio - async def test_monitor_on_returns_false(self, monkeypatch): - monkeypatch.setattr(activity.subprocess, "run", self._make_run(self.XSET_ON)) - - result = await activity.is_dpms_active() - - assert result is False - - @pytest.mark.asyncio - async def test_monitor_standby_returns_true(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run(self.XSET_STANDBY) - ) - - result = await activity.is_dpms_active() - - assert result is True - - @pytest.mark.asyncio - async def test_monitor_off_returns_true(self, monkeypatch): - monkeypatch.setattr(activity.subprocess, "run", self._make_run(self.XSET_OFF)) - - result = await activity.is_dpms_active() - - assert result is True - - @pytest.mark.asyncio - async def test_xset_missing_returns_false(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, - "run", - lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError()), - ) - - result = await activity.is_dpms_active() - - assert result is False - - @pytest.mark.asyncio - async def test_xset_nonzero_returns_false(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, "run", self._make_run("", returncode=1) - ) - - result = await activity.is_dpms_active() - - assert result is False - - @pytest.mark.asyncio - async def test_no_monitor_line_returns_false(self, monkeypatch): - monkeypatch.setattr( - activity.subprocess, - "run", - self._make_run("DPMS is Disabled\n"), - ) - - result = await activity.is_dpms_active() - - assert result is False diff --git a/tests/test_audio_detect.py b/tests/test_audio_detect.py deleted file mode 100644 index af1d2ae..0000000 --- a/tests/test_audio_detect.py +++ /dev/null @@ -1,75 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for structural audio device detection.""" - -import threading -import time -from unittest.mock import Mock, patch - -from solstone_linux.audio_detect import input_detect - - -class _FakeMic: - def __init__(self, device_id: str, is_loopback: bool, block_event=None): - self.id = device_id - self._is_loopback = is_loopback - self._block_event = block_event - self.record = Mock() - - @property - def isloopback(self): - if self._block_event is not None: - self._block_event.wait() - return self._is_loopback - - -def test_input_detect_detects_both_legs_without_any_signal(): - mic = _FakeMic("mic-1", False) - loopback = _FakeMic("loopback-1", True) - ignored_mic = _FakeMic("mic-2", False) - devices = [mic, loopback, ignored_mic] - - with ( - patch("solstone_linux.audio_detect.sc.all_microphones", return_value=devices), - patch("solstone_linux.audio_detect.sc.default_speaker") as default_speaker, - ): - detected_mic, detected_loopback = input_detect(timeout=0.2) - - assert detected_mic is mic - assert detected_loopback is loopback - default_speaker.assert_not_called() - for device in devices: - device.record.assert_not_called() - - -def test_input_detect_never_plays_tone(): - devices = [_FakeMic("mic-1", False), _FakeMic("loopback-1", True)] - - with ( - patch("solstone_linux.audio_detect.sc.all_microphones", return_value=devices), - patch("solstone_linux.audio_detect.sc.default_speaker") as default_speaker, - ): - input_detect(timeout=0.2) - - default_speaker.assert_not_called() - - -def test_input_detect_hung_device_treated_absent_within_bound(): - release = threading.Event() - hung = _FakeMic("hung-mic", False, block_event=release) - mic = _FakeMic("mic-1", False) - loopback = _FakeMic("loopback-1", True) - - with patch( - "solstone_linux.audio_detect.sc.all_microphones", - return_value=[hung, mic, loopback], - ): - started = time.monotonic() - detected_mic, detected_loopback = input_detect(timeout=0.3) - elapsed = time.monotonic() - started - - release.set() - assert elapsed < 0.9 - assert detected_mic is mic - assert detected_loopback is loopback diff --git a/tests/test_audio_recorder.py b/tests/test_audio_recorder.py deleted file mode 100644 index a303448..0000000 --- a/tests/test_audio_recorder.py +++ /dev/null @@ -1,366 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for audio recording behavior.""" - -import logging -import signal -import threading -from unittest.mock import patch - -import numpy as np - -from solstone_linux.audio_recorder import AudioRecorder - - -class _FakeRecorder: - def __init__(self, data: np.ndarray): - self.data = data - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - return None - - def record(self, numframes): - return self.data - - -class _FakeDevice: - def __init__(self, data: np.ndarray): - self.data = data - self.id = "fake-device" - - def recorder(self, samplerate, channels, blocksize): - return _FakeRecorder(self.data) - - -class _FakeDetectedDevice(_FakeDevice): - def __init__(self, device_id: str, data: np.ndarray | None = None): - if data is None: - data = np.array([0.1, 0.2], dtype=np.float32) - super().__init__(data) - self.id = device_id - - -class _SetupFailDevice: - id = "setup-fail" - - def recorder(self, samplerate, channels, blocksize): - raise RuntimeError("setup failed") - - -class _SequenceRecorder: - def __init__(self, values): - self.values = list(values) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - return None - - def record(self, numframes): - value = self.values.pop(0) - if isinstance(value, BaseException): - raise value - return value - - -class _SequenceDevice: - id = "sequence-device" - - def __init__(self, values): - self.values = values - - def recorder(self, samplerate, channels, blocksize): - return _SequenceRecorder(self.values) - - -def test_record_both_stereo_layout_mic_left_sys_right(): - recorder = AudioRecorder() - mic_data = np.array([0.1, 0.2, 0.3], dtype=np.float32) - sys_data = np.array([0.4, 0.5, 0.6], dtype=np.float32) - recorder.mic_device = _FakeDevice(mic_data) - recorder.sys_device = _FakeDevice(sys_data) - - original_put = recorder.audio_queue.put - - def put_and_stop(chunk): - original_put(chunk) - recorder._running = False - - with patch.object(recorder.audio_queue, "put", side_effect=put_and_stop): - recorder.record_both() - - chunk = recorder.audio_queue.get_nowait() - np.testing.assert_allclose(chunk[:, 0], mic_data) - np.testing.assert_allclose(chunk[:, 1], sys_data) - - -def test_create_flac_and_mono_flac_bytes_nonempty(): - recorder = AudioRecorder() - stereo_data = np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) - mono_data = np.array([0.1, 0.2, 0.3], dtype=np.float32) - empty_stereo = np.array([], dtype=np.float32).reshape(0, 2) - empty_mono = np.array([], dtype=np.float32) - - assert recorder.create_flac_bytes(stereo_data).startswith(b"fLaC") - assert recorder.create_mono_flac_bytes(mono_data).startswith(b"fLaC") - assert recorder.create_flac_bytes(empty_stereo) == b"" - assert recorder.create_mono_flac_bytes(empty_mono) == b"" - - -def test_set_audio_available_edge_logs_once(caplog): - recorder = AudioRecorder() - - with caplog.at_level(logging.INFO): - recorder._set_audio_available(False) - recorder._set_audio_available(False) - recorder._set_audio_available(False) - recorder._set_audio_available(True) - recorder._set_audio_available(True) - - warnings = [ - record for record in caplog.records if record.levelno == logging.WARNING - ] - infos = [record for record in caplog.records if record.levelno == logging.INFO] - assert [record.message for record in warnings] == [ - "Audio devices unavailable — continuing with screen capture only" - ] - assert [record.message for record in infos] == [ - "Audio devices recovered — resuming audio capture" - ] - - -def test_degraded_recorder_recovers_without_restart(caplog): - recorder = AudioRecorder() - mic = _FakeDetectedDevice("mic-id") - loopback = _FakeDetectedDevice("loopback-id") - original_put = recorder.audio_queue.put - - def put_and_stop(chunk): - original_put(chunk) - recorder._running = False - - with ( - caplog.at_level(logging.INFO), - patch.object(recorder.audio_queue, "put", side_effect=put_and_stop), - patch.object(recorder, "_sleep_interruptibly"), - patch( - "solstone_linux.audio_detect.input_detect", - side_effect=[(None, None), (None, None), (mic, loopback)], - ), - ): - recorder._set_audio_available(False) - thread = threading.Thread(target=recorder.record_both) - thread.start() - thread.join(timeout=1.0) - - assert not thread.is_alive() - assert recorder.audio_available is True - assert recorder.mic_device is mic - assert recorder.sys_device is loopback - assert not recorder.audio_queue.empty() - assert ( - sum( - record.message - == "Audio devices unavailable — continuing with screen capture only" - for record in caplog.records - ) - == 1 - ) - assert ( - sum( - record.message == "Audio devices recovered — resuming audio capture" - for record in caplog.records - ) - == 1 - ) - - -def test_detect_degrades_when_only_mic(caplog): - recorder = AudioRecorder() - mic = _FakeDetectedDevice("mic-id") - - with ( - caplog.at_level(logging.INFO), - patch("solstone_linux.audio_detect.input_detect", return_value=(mic, None)), - ): - result = recorder.detect() - - assert result is False - assert recorder.audio_available is False - assert "Detection failed" not in caplog.text - assert ( - caplog.text.count( - "Audio devices unavailable — continuing with screen capture only" - ) - == 1 - ) - - -def test_detect_degrades_when_only_loopback(caplog): - recorder = AudioRecorder() - loopback = _FakeDetectedDevice("loopback-id") - - with ( - caplog.at_level(logging.INFO), - patch( - "solstone_linux.audio_detect.input_detect", return_value=(None, loopback) - ), - ): - result = recorder.detect() - - assert result is False - assert recorder.audio_available is False - assert "Detection failed" not in caplog.text - assert ( - caplog.text.count( - "Audio devices unavailable — continuing with screen capture only" - ) - == 1 - ) - - -def test_record_both_setup_failures_trigger_redetect(): - recorder = AudioRecorder() - recorder.mic_device = _SetupFailDevice() - recorder.sys_device = _FakeDevice(np.array([0.4, 0.5], dtype=np.float32)) - working_mic = _FakeDetectedDevice("mic-id") - working_loopback = _FakeDetectedDevice("loopback-id") - original_put = recorder.audio_queue.put - - def recover(): - recorder.mic_device = working_mic - recorder.sys_device = working_loopback - recorder._set_audio_available(True) - return True - - def put_and_stop(chunk): - original_put(chunk) - recorder._running = False - - with ( - patch.object(recorder, "_sleep_interruptibly"), - patch.object(recorder, "detect", side_effect=recover) as detect_mock, - patch.object(recorder.audio_queue, "put", side_effect=put_and_stop), - ): - recorder.record_both() - - detect_mock.assert_called_once() - assert recorder._consecutive_failures == 0 - assert not recorder.audio_queue.empty() - - -def test_record_both_inner_record_failures_trigger_redetect(): - recorder = AudioRecorder() - recorder.mic_device = _SequenceDevice( - [ - RuntimeError("record failed 1"), - RuntimeError("record failed 2"), - RuntimeError("record failed 3"), - ] - ) - recorder.sys_device = _FakeDevice(np.array([0.4, 0.5], dtype=np.float32)) - working_mic = _FakeDetectedDevice("mic-id") - working_loopback = _FakeDetectedDevice("loopback-id") - original_put = recorder.audio_queue.put - - def recover(): - recorder.mic_device = working_mic - recorder.sys_device = working_loopback - recorder._set_audio_available(True) - return True - - def put_and_stop(chunk): - original_put(chunk) - recorder._running = False - - with ( - patch("solstone_linux.audio_recorder.time.sleep"), - patch.object(recorder, "detect", side_effect=recover) as detect_mock, - patch.object(recorder.audio_queue, "put", side_effect=put_and_stop), - ): - recorder.record_both() - - detect_mock.assert_called_once() - assert recorder._consecutive_failures == 0 - assert not recorder.audio_queue.empty() - - -def test_record_both_success_resets_counter(): - recorder = AudioRecorder() - data = np.array([0.1, 0.2], dtype=np.float32) - recorder.mic_device = _SequenceDevice( - [ - RuntimeError("record failed before success"), - data, - RuntimeError("record failed after success 1"), - RuntimeError("record failed after success 2"), - data, - ] - ) - recorder.sys_device = _FakeDevice(np.array([0.4, 0.5], dtype=np.float32)) - original_put = recorder.audio_queue.put - put_count = 0 - - def put_and_stop_after_second_success(chunk): - nonlocal put_count - put_count += 1 - original_put(chunk) - if put_count == 2: - recorder._running = False - - with ( - patch("solstone_linux.audio_recorder.time.sleep"), - patch.object(recorder, "detect") as detect_mock, - patch.object( - recorder.audio_queue, "put", side_effect=put_and_stop_after_second_success - ), - ): - recorder.record_both() - - detect_mock.assert_not_called() - assert recorder._consecutive_failures == 0 - assert put_count == 2 - - -def test_sleep_interruptibly_exits_when_stopped(): - recorder = AudioRecorder() - - def stop_recording(_duration): - recorder._running = False - - with ( - patch("solstone_linux.audio_recorder.time.monotonic", side_effect=[10.0, 10.0]), - patch( - "solstone_linux.audio_recorder.time.sleep", side_effect=stop_recording - ) as sleep_mock, - ): - recorder._sleep_interruptibly(5) - - sleep_mock.assert_called_once_with(1.0) - assert recorder._running is False - - -def test_fatal_format_error_untouched_by_counter(): - recorder = AudioRecorder() - recorder.mic_device = _FakeDevice(np.array([0.1, 0.2], dtype=np.float32)) - recorder.sys_device = _FakeDevice(np.array([0.4, 0.5], dtype=np.float32)) - - with ( - patch( - "solstone_linux.audio_recorder.np.column_stack", - side_effect=TypeError("bad format"), - ), - patch("solstone_linux.audio_recorder.os.kill") as kill_mock, - ): - recorder.record_both() - - assert recorder.fatal_error == "Fatal audio format error: bad format" - assert recorder._running is False - kill_mock.assert_called_once() - assert kill_mock.call_args.args[1] == signal.SIGTERM - assert recorder._consecutive_failures == 0 diff --git a/tests/test_capture_stats.py b/tests/test_capture_stats.py deleted file mode 100644 index 86ec9a8..0000000 --- a/tests/test_capture_stats.py +++ /dev/null @@ -1,40 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import os - -from solstone_linux.capture_stats import compute_quarantine_stats - - -def test_compute_quarantine_stats_counts_both_shapes_and_oldest_age(tmp_path): - captures_dir = tmp_path / "captures" - duration_shape = captures_dir / "20260101" / "archon" / "120000_300.failed" - bare_shape = captures_dir / "20260101" / "archon" / "130000.failed" - duration_shape.mkdir(parents=True) - bare_shape.mkdir(parents=True) - now = 1_000_000.0 - newer_mtime = now - 2 * 86400 - older_mtime = now - 5 * 86400 - os.utime(duration_shape, (newer_mtime, newer_mtime)) - os.utime(bare_shape, (older_mtime, older_mtime)) - - stats = compute_quarantine_stats(captures_dir, now=now) - - assert stats["count"] == 2 - assert abs(stats["oldest_age_seconds"] - 5 * 86400) < 1 - - -def test_compute_quarantine_stats_empty_or_missing_tree(tmp_path): - now = 1_000_000.0 - - assert compute_quarantine_stats(tmp_path / "missing", now=now) == { - "count": 0, - "oldest_age_seconds": None, - } - - captures_dir = tmp_path / "captures" - captures_dir.mkdir() - assert compute_quarantine_stats(captures_dir, now=now) == { - "count": 0, - "oldest_age_seconds": None, - } diff --git a/tests/test_chat_bridge.py b/tests/test_chat_bridge.py deleted file mode 100644 index adda8b6..0000000 --- a/tests/test_chat_bridge.py +++ /dev/null @@ -1,901 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import asyncio -import errno -import logging -import os -import threading -from collections import OrderedDict -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from solstone_linux import chat_bridge -from solstone_linux.chat_bridge import ( - EVENT_OWNER_CHAT_DISMISSED, - EVENT_OWNER_CHAT_OPEN, - EVENT_SOL_CHAT_REQUEST, - EVENT_SOL_CHAT_REQUEST_SUPERSEDED, - HEALTHY_RUN_SECONDS, - HEARTBEAT_STALE_SECONDS, - NOTIFY_ACTION_KEY, - PENDING_CAP, - SSE_CONNECT_TIMEOUT_SECONDS, - SSE_READ_TIMEOUT_SECONDS, - PendingRequest, - _chat_url, - _dispatch_event, - _handle_one_notification, - _mark_live_frame, - _mark_stale_if_needed, - _sse_worker, - _SseParser, - _write_fifo, - run_chat_bridge, -) -from solstone_linux.config import Config - - -class FakeResponse: - def __init__(self, status_code=200, data=None, lines=None): - self.status_code = status_code - self._data = data if data is not None else {} - self._lines = lines if lines is not None else [] - - def json(self): - return self._data - - def iter_lines(self, decode_unicode=True): - yield from self._lines - - -class FakeProc: - def __init__(self, returncode=0, stdout=b""): - self.returncode = returncode - self._stdout = stdout - self.terminated = False - self.killed = False - - async def communicate(self): - return self._stdout, b"" - - async def wait(self): - return self.returncode - - def terminate(self): - self.terminated = True - - def kill(self): - self.killed = True - - -def _config(enabled=True) -> Config: - config = Config() - config.server_url = "https://server.test" - config.key = "key-123" - config.chat_bridge_enabled = enabled - return config - - -def _payload(event=EVENT_SOL_CHAT_REQUEST, request_id="req-1", **extra): - payload = { - "tract": "chat", - "event": event, - "request_id": request_id, - "summary": "hello", - "day": "20260509", - "event_index": 7, - } - payload.update(extra) - return payload - - -async def _never_notify(req, server_url, key): - await asyncio.Event().wait() - - -async def _never_poll(server_url, key, state): - await asyncio.Event().wait() - - -def _terminal_worker(status): - def worker(url, key, queue, loop, stop_event): - loop.call_soon_threadsafe( - queue.put_nowait, {"_terminal": True, "status": status} - ) - - return worker - - -def _transport_worker(url, key, queue, loop, thread_stop): - loop.call_soon_threadsafe( - queue.put_nowait, {"_transport_error": True, "error": "boom"} - ) - - -def test_sse_parser_data_only_frame(): - parser = _SseParser() - - assert parser.feed_line("data: hello") is None - assert parser.feed_line("") == {"event": None, "data": "hello", "id": None} - - -def test_sse_parser_event_and_data_frame(): - parser = _SseParser() - - parser.feed_line("event: message") - parser.feed_line("id: 42") - parser.feed_line("data: hello") - - assert parser.feed_line("") == {"event": "message", "data": "hello", "id": "42"} - - -def test_sse_parser_multiline_data(): - parser = _SseParser() - - parser.feed_line("data: hello") - parser.feed_line("data: world") - - assert parser.feed_line("")["data"] == "hello\nworld" - - -def test_sse_parser_ignores_comment(): - parser = _SseParser() - - assert parser.feed_line(": heartbeat") is None - parser.feed_line("data: after") - - assert parser.feed_line("")["data"] == "after" - - -def test_sse_parser_partial_frame_without_terminator_returns_none(): - parser = _SseParser() - - assert parser.feed_line("event: message") is None - assert parser.feed_line("data: partial") is None - - -@pytest.mark.asyncio -async def test_dispatch_drops_non_chat_tract(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - await _dispatch_event( - {"tract": "other", "event": EVENT_SOL_CHAT_REQUEST}, - pending, - True, - False, - _config(), - ) - - write_fifo.assert_not_called() - assert not pending - - -@pytest.mark.asyncio -async def test_dispatch_drops_unrecognized_chat_event(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - await _dispatch_event( - {"tract": "chat", "event": "unknown", "request_id": "req-1"}, - pending, - True, - False, - _config(), - ) - - write_fifo.assert_not_called() - assert not pending - - -@pytest.mark.asyncio -async def test_dispatch_recognized_events(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - await _dispatch_event(_payload(), pending, False, False, _config()) - await _dispatch_event( - _payload(EVENT_SOL_CHAT_REQUEST_SUPERSEDED), - pending, - False, - False, - _config(), - ) - await _dispatch_event( - _payload(EVENT_OWNER_CHAT_OPEN), pending, False, False, _config() - ) - await _dispatch_event( - _payload(EVENT_OWNER_CHAT_DISMISSED), pending, False, False, _config() - ) - - assert write_fifo.call_count == 4 - - -@pytest.mark.asyncio -async def test_request_opt_in_off_writes_fifo_without_notify(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - with patch("solstone_linux.chat_bridge._handle_one_notification") as notify: - await _dispatch_event(_payload(), pending, False, False, _config()) - - write_fifo.assert_called_once_with("sol-ping req-1 hello\n") - notify.assert_not_called() - assert not pending - - -def test_request_fifo_absent_no_error(tmp_path: Path): - _write_fifo("sol-ping req hello\n", tmp_path / "missing") - - -@pytest.mark.asyncio -async def test_request_opt_in_on_not_stale_fires_notify(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo"): - with patch( - "solstone_linux.chat_bridge._handle_one_notification", new=_never_notify - ): - await _dispatch_event(_payload(), pending, True, False, _config()) - - assert list(pending) == ["req-1"] - assert pending["req-1"].notify_task is not None - await chat_bridge._cancel_pending_notifications(pending) - - -@pytest.mark.asyncio -async def test_request_stale_skips_notify_but_writes_fifo(): - pending = OrderedDict() - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - with patch("solstone_linux.chat_bridge._handle_one_notification") as notify: - await _dispatch_event(_payload(), pending, True, True, _config()) - - write_fifo.assert_called_once() - notify.assert_not_called() - assert not pending - - -async def _assert_clear_event_cancels(event): - pending = OrderedDict() - task = asyncio.create_task(asyncio.Event().wait()) - pending["req-1"] = PendingRequest("req-1", "hello", "https://server.test", task) - - with patch("solstone_linux.chat_bridge._write_fifo") as write_fifo: - await _dispatch_event(_payload(event), pending, True, False, _config()) - - write_fifo.assert_called_once_with("clear req-1\n") - assert not pending - result = await asyncio.gather(task, return_exceptions=True) - assert isinstance(result[0], asyncio.CancelledError) - - -@pytest.mark.asyncio -async def test_superseded_removes_pending_writes_clear_and_cancels_task(): - await _assert_clear_event_cancels(EVENT_SOL_CHAT_REQUEST_SUPERSEDED) - - -@pytest.mark.asyncio -async def test_owner_chat_open_removes_pending_writes_clear_and_cancels_task(): - await _assert_clear_event_cancels(EVENT_OWNER_CHAT_OPEN) - - -@pytest.mark.asyncio -async def test_owner_chat_dismissed_removes_pending_writes_clear_and_cancels_task(): - await _assert_clear_event_cancels(EVENT_OWNER_CHAT_DISMISSED) - - -def test_fifo_present_with_reader_succeeds(tmp_path: Path): - fifo = tmp_path / "notify" - os.mkfifo(fifo) - reader = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) - try: - _write_fifo("line one\n", fifo) - assert os.read(reader, 1024) == b"line one\n" - finally: - os.close(reader) - - -def test_fifo_present_no_reader_enxio_swallowed(tmp_path: Path): - fifo = tmp_path / "notify" - os.mkfifo(fifo) - - _write_fifo("line one\n", fifo) - - -def test_fifo_missing_noop(tmp_path: Path): - _write_fifo("line one\n", tmp_path / "missing") - - -def test_fifo_regular_file_noop(tmp_path: Path): - regular = tmp_path / "notify" - regular.write_text("") - - _write_fifo("line one\n", regular) - - assert regular.read_text() == "" - - -def test_fifo_eagain_swallowed(tmp_path: Path): - fifo = tmp_path / "notify" - os.mkfifo(fifo) - - with patch( - "solstone_linux.chat_bridge.os.open", - side_effect=OSError(errno.EAGAIN, "try again"), - ): - _write_fifo("line one\n", fifo) - - -def test_heartbeat_staleness_marks_stale_and_logs_once_after_60s(caplog): - with patch("solstone_linux.chat_bridge.time.monotonic", return_value=1000): - is_stale, stale_logged = _mark_stale_if_needed( - 1000 - HEARTBEAT_STALE_SECONDS - 1, False, False - ) - assert is_stale - assert stale_logged - _mark_stale_if_needed(1000 - HEARTBEAT_STALE_SECONDS - 2, True, True) - - assert [r.message for r in caplog.records].count("Chat bridge heartbeat stale") == 1 - - -def test_heartbeat_new_frame_recovers_from_stale(caplog): - with caplog.at_level(logging.INFO): - is_stale, stale_logged = _mark_live_frame(True, True) - - assert not is_stale - assert not stale_logged - assert "Chat bridge heartbeat recovered" in [r.message for r in caplog.records] - - -@pytest.mark.asyncio -async def test_sse_worker_uses_finite_read_timeout(): - queue = asyncio.Queue() - loop = asyncio.get_running_loop() - - with patch( - "solstone_linux.chat_bridge.requests.get", - return_value=FakeResponse(status_code=200, lines=[]), - ) as get: - _sse_worker( - "https://server.test/app/observer/callosum", - "key-123", - queue, - loop, - threading.Event(), - ) - - timeout = get.call_args.kwargs["timeout"] - assert timeout == (SSE_CONNECT_TIMEOUT_SECONDS, SSE_READ_TIMEOUT_SECONDS) - assert timeout != (10, None) - - -def test_read_timeout_exceeds_staleness_threshold(): - assert SSE_READ_TIMEOUT_SECONDS > HEARTBEAT_STALE_SECONDS - - -@pytest.mark.asyncio -async def test_reconnect_transport_error_backoff_sequence(): - stop_event = asyncio.Event() - delays = [] - - async def fake_sleep(delay): - delays.append(delay) - if len(delays) >= 7: - stop_event.set() - - with patch("solstone_linux.chat_bridge._sse_worker", new=_transport_worker): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", side_effect=fake_sleep - ): - await run_chat_bridge(_config(), stop_event) - - assert delays == [1, 2, 4, 8, 16, 30, 30] - - -@pytest.mark.asyncio -async def test_read_timeout_reconnects_and_clears_stale(): - stop_event = asyncio.Event() - attempts = 0 - delays = [] - - def worker(url, key, queue, loop, thread_stop): - nonlocal attempts - attempts += 1 - if attempts == 1: - loop.call_soon_threadsafe( - queue.put_nowait, - {"_transport_error": True, "error": "read timed out"}, - ) - return - loop.call_soon_threadsafe(queue.put_nowait, {"_heartbeat": True}) - loop.call_soon_threadsafe( - queue.put_nowait, {"_transport_error": True, "error": "stop"} - ) - - async def fake_sleep(delay): - delays.append(delay) - if len(delays) >= 2: - stop_event.set() - - with patch("solstone_linux.chat_bridge._sse_worker", new=worker): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", side_effect=fake_sleep - ): - await run_chat_bridge(_config(), stop_event) - - assert attempts == 2 - assert delays == [1, 1] - - -@pytest.mark.asyncio -async def test_reconnect_successful_frame_resets_backoff_index(): - stop_event = asyncio.Event() - attempts = 0 - delays = [] - - def worker(url, key, queue, loop, thread_stop): - nonlocal attempts - attempts += 1 - if attempts == 4: - loop.call_soon_threadsafe(queue.put_nowait, {"_heartbeat": True}) - loop.call_soon_threadsafe( - queue.put_nowait, {"_transport_error": True, "error": "boom"} - ) - - async def fake_sleep(delay): - delays.append(delay) - if len(delays) >= 4: - stop_event.set() - - with patch("solstone_linux.chat_bridge._sse_worker", new=worker): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", side_effect=fake_sleep - ): - await run_chat_bridge(_config(), stop_event) - - assert delays == [1, 2, 4, 1] - - -@pytest.mark.asyncio -async def test_terminal_401_exits_without_reconnect(caplog): - with patch("solstone_linux.chat_bridge._sse_worker", new=_terminal_worker(401)): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", new_callable=AsyncMock - ) as sleep: - await run_chat_bridge(_config(), asyncio.Event()) - - sleep.assert_not_called() - assert "status 401" in caplog.text - - -@pytest.mark.asyncio -async def test_terminal_403_exits_without_reconnect(caplog): - with patch("solstone_linux.chat_bridge._sse_worker", new=_terminal_worker(403)): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", new_callable=AsyncMock - ) as sleep: - await run_chat_bridge(_config(), asyncio.Event()) - - sleep.assert_not_called() - assert "status 403" in caplog.text - - -@pytest.mark.asyncio -async def test_click_post_reachable_posts_then_xdg_open(): - proc = FakeProc(returncode=0, stdout=f"{NOTIFY_ACTION_KEY}\n".encode("utf-8")) - response = FakeResponse(status_code=200) - - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc - ): - with patch( - "solstone_linux.chat_bridge.requests.post", return_value=response - ) as post: - with patch("solstone_linux.chat_bridge.subprocess.Popen") as popen: - await _handle_one_notification( - PendingRequest("req-1", "hello", "https://server.test/app/chat/x"), - "https://server.test", - "key-123", - ) - - post.assert_called_once_with( - "https://server.test/api/chat/sol_chat_request/open", - json={"request_id": "req-1"}, - headers={"Authorization": "Bearer key-123"}, - timeout=10, - ) - popen.assert_called_once() - - -@pytest.mark.asyncio -async def test_click_post_unreachable_still_xdg_open(): - proc = FakeProc(returncode=0, stdout=f"{NOTIFY_ACTION_KEY}\n".encode("utf-8")) - - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc - ): - with patch( - "solstone_linux.chat_bridge.requests.post", - side_effect=chat_bridge.requests.RequestException("down"), - ): - with patch("solstone_linux.chat_bridge.subprocess.Popen") as popen: - await _handle_one_notification( - PendingRequest("req-1", "hello", "https://server.test/app/chat/x"), - "https://server.test", - "key-123", - ) - - popen.assert_called_once() - - -@pytest.mark.asyncio -async def test_dismissal_empty_stdout_no_ack_no_open(): - proc = FakeProc(returncode=0, stdout=b"") - - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc - ): - with patch("solstone_linux.chat_bridge.requests.post") as post: - with patch("solstone_linux.chat_bridge.subprocess.Popen") as popen: - await _handle_one_notification( - PendingRequest("req-1", "hello", "https://server.test/app/chat/x"), - "https://server.test", - "key-123", - ) - - post.assert_not_called() - popen.assert_not_called() - - -@pytest.mark.parametrize("stdout", [b"nope", b"op"]) -@pytest.mark.asyncio -async def test_nonaction_stdout_treated_as_dismissal(stdout): - proc = FakeProc(returncode=0, stdout=stdout) - - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc - ): - with patch("solstone_linux.chat_bridge.requests.post") as post: - with patch("solstone_linux.chat_bridge.subprocess.Popen") as popen: - await _handle_one_notification( - PendingRequest("req-1", "hello", "https://server.test/app/chat/x"), - "https://server.test", - "key-123", - ) - - post.assert_not_called() - popen.assert_not_called() - - -@pytest.mark.asyncio -async def test_click_notify_nonzero_does_not_xdg_open(): - proc = FakeProc(returncode=1) - - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc - ): - with patch("solstone_linux.chat_bridge.requests.post") as post: - with patch("solstone_linux.chat_bridge.subprocess.Popen") as popen: - await _handle_one_notification( - PendingRequest("req-1", "hello", "https://server.test/app/chat/x"), - "https://server.test", - "key-123", - ) - - post.assert_not_called() - popen.assert_not_called() - - -def test_chat_url_with_day_and_event_index(): - assert ( - _chat_url("https://server.test/", "20260509", 7) - == "https://server.test/app/chat/20260509#event-7" - ) - - -def test_chat_url_missing_day_or_event_index_uses_today(): - with patch("solstone_linux.chat_bridge.datetime") as mock_datetime: - mock_datetime.now.return_value.strftime.return_value = "20260509" - assert _chat_url("https://server.test/", None, None) == ( - "https://server.test/app/chat/20260509" - ) - - -@pytest.mark.asyncio -async def test_bridge_crash_restarts_after_backoff(caplog): - stop_event = asyncio.Event() - delays = [] - worker_calls = 0 - - def worker(url, key, queue, loop, thread_stop): - nonlocal worker_calls - worker_calls += 1 - loop.call_soon_threadsafe( - queue.put_nowait, - { - "data": ( - '{"tract": "chat", "event": "sol_chat_request", ' - '"request_id": "req-1"}' - ) - }, - ) - - async def fake_sleep(delay): - delays.append(delay) - stop_event.set() - - with caplog.at_level(logging.ERROR): - with patch("solstone_linux.chat_bridge._sse_worker", new=worker): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", - side_effect=fake_sleep, - ): - with patch( - "solstone_linux.chat_bridge._dispatch_event", - new_callable=AsyncMock, - side_effect=RuntimeError("boom"), - ): - await run_chat_bridge(_config(), stop_event) - - error_records = [r for r in caplog.records if r.levelno == logging.ERROR] - assert any("Chat bridge crashed" in r.message for r in error_records) - assert any(r.exc_info for r in error_records) - assert delays == [1] - assert worker_calls == 1 - - -@pytest.mark.asyncio -async def test_supervision_backoff_climbs_then_healthy_reset(): - stop_event = asyncio.Event() - delays = [] - durations = [1, 1, 1, 1, 1, 1, 1, HEALTHY_RUN_SECONDS] - monotonic_values = [] - now = 1000.0 - - for duration in durations: - monotonic_values.extend([now, now + duration]) - now += duration + 1 - - async def fake_sleep(delay): - delays.append(delay) - if len(delays) >= len(durations): - stop_event.set() - - body = AsyncMock(side_effect=RuntimeError("boom")) - - with patch("solstone_linux.chat_bridge._run_bridge_body", body): - with patch( - "solstone_linux.chat_bridge.time.monotonic", - side_effect=monotonic_values, - ): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", side_effect=fake_sleep - ): - await run_chat_bridge(_config(), stop_event) - - assert body.await_count == len(durations) - assert delays == [1, 2, 4, 8, 16, 30, 30, 1] - - -@pytest.mark.asyncio -async def test_supervision_no_task_leak_across_restarts(): - stop_event = asyncio.Event() - delays = [] - worker_calls = 0 - opt_in_entries = 0 - opt_in_tasks = [] - opt_in_alive_before = [] - opt_in_cancelled = [] - notify_tasks = [] - notify_cancelled = [] - wait_failures = [] - condition = threading.Condition() - real_sleep = asyncio.sleep - - async def fake_opt_in_poll_loop(server_url, key, state): - nonlocal opt_in_entries - task = asyncio.current_task() - opt_in_alive_before.append(sum(1 for t in opt_in_tasks if not t.done())) - opt_in_tasks.append(task) - state["value"] = True - with condition: - opt_in_entries += 1 - condition.notify_all() - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - opt_in_cancelled.append(task) - raise - - async def fake_notify(req, server_url, key): - task = asyncio.current_task() - notify_tasks.append(task) - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - notify_cancelled.append(task) - raise - - async def crashing_dispatch(payload, pending, opt_in, is_stale, config): - await _dispatch_event(payload, pending, opt_in, is_stale, config) - await real_sleep(0) - raise RuntimeError("boom") - - def worker(url, key, queue, loop, thread_stop): - nonlocal worker_calls - worker_calls += 1 - with condition: - if not condition.wait_for( - lambda: opt_in_entries >= worker_calls, timeout=1 - ): - wait_failures.append(worker_calls) - loop.call_soon_threadsafe( - queue.put_nowait, - { - "data": ( - '{"tract": "chat", "event": "sol_chat_request", ' - '"request_id": "req-1", "summary": "hello"}' - ) - }, - ) - - async def fake_sleep(delay): - delays.append(delay) - if len(delays) >= 3: - stop_event.set() - - with patch("solstone_linux.chat_bridge._sse_worker", new=worker): - with patch( - "solstone_linux.chat_bridge._opt_in_poll_loop", - new=fake_opt_in_poll_loop, - ): - with patch( - "solstone_linux.chat_bridge._handle_one_notification", - new=fake_notify, - ): - with patch("solstone_linux.chat_bridge._write_fifo"): - with patch( - "solstone_linux.chat_bridge._dispatch_event", - new=crashing_dispatch, - ): - with patch( - "solstone_linux.chat_bridge.asyncio.sleep", - side_effect=fake_sleep, - ): - await run_chat_bridge(_config(), stop_event) - - assert wait_failures == [] - assert worker_calls == 3 - assert delays == [1, 2, 4] - assert len(opt_in_tasks) == worker_calls - assert opt_in_alive_before == [0, 0, 0] - assert len(opt_in_cancelled) == worker_calls - assert all(task.done() and task.cancelled() for task in opt_in_tasks) - assert len(notify_tasks) == worker_calls - assert len(notify_cancelled) == worker_calls - assert all(task.done() and task.cancelled() for task in notify_tasks) - - -@pytest.mark.asyncio -async def test_stop_during_supervision_backoff_no_restart(): - stop_event = asyncio.Event() - delays = [] - body = AsyncMock(side_effect=RuntimeError("boom")) - - async def fake_sleep(delay): - delays.append(delay) - stop_event.set() - - with patch("solstone_linux.chat_bridge._run_bridge_body", body): - with patch("solstone_linux.chat_bridge.asyncio.sleep", side_effect=fake_sleep): - await run_chat_bridge(_config(), stop_event) - - assert body.await_count == 1 - assert delays == [1] - - -@pytest.mark.asyncio -async def test_chat_bridge_enabled_false_no_sse_attempt(): - with patch("solstone_linux.chat_bridge.requests.get") as get: - await run_chat_bridge(_config(enabled=False), asyncio.Event()) - - get.assert_not_called() - - -@pytest.mark.asyncio -async def test_chat_bridge_uses_keyless_callosum_url_with_bearer(): - stop_event = asyncio.Event() - seen = {} - - def worker(url, key, queue, loop, thread_stop): - seen["url"] = url - seen["key"] = key - loop.call_soon_threadsafe( - queue.put_nowait, {"_transport_error": True, "error": "stop"} - ) - - async def fake_sleep(_delay): - stop_event.set() - - with patch("solstone_linux.chat_bridge._sse_worker", new=worker): - with patch("solstone_linux.chat_bridge._opt_in_poll_loop", new=_never_poll): - with patch("solstone_linux.chat_bridge.asyncio.sleep", new=fake_sleep): - await run_chat_bridge(_config(), stop_event) - - assert seen == { - "url": "https://server.test/app/observer/callosum", - "key": "key-123", - } - - -def test_observer_bridge_task_none_when_disabled(): - import inspect - - from solstone_linux.observer import Observer - - source = inspect.getsource(Observer.main_loop) - assert "if self.config.chat_bridge_enabled:" in source - assert "bridge_task = None" in source - - -@pytest.mark.asyncio -async def test_pending_cap_33rd_entry_evicts_oldest_and_cancels_task(caplog): - pending = OrderedDict() - tasks = [] - - with caplog.at_level(logging.DEBUG): - with patch("solstone_linux.chat_bridge._write_fifo"): - with patch( - "solstone_linux.chat_bridge._handle_one_notification", new=_never_notify - ): - for i in range(PENDING_CAP + 1): - await _dispatch_event( - _payload(request_id=f"req-{i}"), - pending, - True, - False, - _config(), - ) - if i < PENDING_CAP: - tasks.append(pending[f"req-{i}"].notify_task) - - assert "req-0" not in pending - assert len(pending) == PENDING_CAP - assert "Evicted pending chat request: req-0" in caplog.text - result = await asyncio.gather(tasks[0], return_exceptions=True) - assert isinstance(result[0], asyncio.CancelledError) - await chat_bridge._cancel_pending_notifications(pending) - - -def test_constants_forbidden_literals_appear_once_in_src_only_in_chat_bridge_module_level(): - src_dir = Path(__file__).resolve().parents[1] / "src" / "solstone_linux" - files = list(src_dir.glob("*.py")) - event_literals = [ - '"sol_chat_request"', - '"sol_chat_request_superseded"', - '"owner_chat_open"', - '"owner_chat_dismissed"', - ] - - for literal in event_literals: - hits = [] - for path in files: - for lineno, line in enumerate(path.read_text().splitlines(), 1): - if literal in line: - hits.append((path.name, lineno, line.strip())) - assert len(hits) == 1 - assert hits[0][0] == "chat_bridge.py" - - text = (src_dir / "chat_bridge.py").read_text() - assert text.count('NOTIFY_TITLE = "sol"') == 1 - assert text.count('SURFACE = "linux"') == 1 diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index e0d31af..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,675 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import argparse -import os -import sys -import time -from pathlib import Path -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest - -from solstone_linux import __version__ -from solstone_linux import cli as cli_module -from solstone_linux.cli import ( - _cmd_setup_interactive, - cmd_install_service, - cmd_setup, - cmd_settings, - cmd_status, -) -from solstone_linux.config import ( - Config, - DEFAULT_SERVER_URL, - load_config as real_load_config, -) -from solstone_linux.sync_health import ErrorType, SyncFacts, save_facts - - -def _args() -> argparse.Namespace: - return argparse.Namespace() - - -def _settings_config(tmp_path: Path) -> Config: - return Config( - base_dir=tmp_path, - config_dir=tmp_path / "config", - server_url="https://id", - key="KKKK", - stream="strm", - capture_framerate=2, - ) - - -def _run_settings(tmp_path: Path, inputs: list[str]) -> Config: - config = _settings_config(tmp_path) - - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch("builtins.input", side_effect=inputs): - assert cmd_settings(_args()) == 0 - - return save_mock.call_args.args[0] - - -def test_main_version_flag(monkeypatch, capsys): - monkeypatch.setattr(sys, "argv", ["solstone-linux", "--version"]) - - with pytest.raises(SystemExit) as excinfo: - cli_module.main() - - assert excinfo.value.code == 0 - out = capsys.readouterr().out - assert __version__ in out - - -_BINARY = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" -_EXPECTED_SVGS = { - "solstone-error.svg", - "solstone-paused.svg", - "solstone-recording.svg", - "solstone-syncing.svg", -} - - -_REAL_IS_DIR = Path.is_dir - - -def _is_dir_without_icons(self: Path) -> bool: - icon_source = Path(cli_module.__file__).resolve().parent / "icons" / "hicolor" - if self == icon_source: - return False - return _REAL_IS_DIR(self) - - -def test_cmd_settings_enter_keeps_all(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["", "", "", "", "", ""]) - - assert saved_config.capture_framerate == 2 - assert saved_config.draw_cursor is True - assert saved_config.start_paused is False - assert saved_config.segment_interval == 300 - assert saved_config.chat_bridge_enabled is True - assert saved_config.cache_retention_days == 7 - assert saved_config.server_url == "https://id" - assert saved_config.key == "KKKK" - assert saved_config.stream == "strm" - - -def test_cmd_settings_changes_framerate(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["5", "", "", "", "", ""]) - - assert saved_config.capture_framerate == 5 - assert saved_config.server_url == "https://id" - assert saved_config.key == "KKKK" - assert saved_config.stream == "strm" - - -def test_cmd_settings_framerate_clamped(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["99", "", "", "", "", ""]) - - assert saved_config.capture_framerate == 10 - - -def test_cmd_settings_framerate_reprompts_on_invalid(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["abc", "3", "", "", "", "", ""]) - - assert saved_config.capture_framerate == 3 - - -def test_cmd_settings_toggles_bool(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["", "n", "", "", "", ""]) - - assert saved_config.draw_cursor is False - assert saved_config.server_url == "https://id" - assert saved_config.key == "KKKK" - assert saved_config.stream == "strm" - - -def test_cmd_settings_retention_semantics(tmp_path: Path): - saved_config = _run_settings(tmp_path, ["", "", "", "", "", "-1"]) - - assert saved_config.cache_retention_days == -1 - - -def test_cmd_status_prints_sync_health(tmp_path: Path, monkeypatch, capsys): - config = Config( - base_dir=tmp_path, - server_url="https://test.example.com", - key="K123456789", - stream="test-stream", - ) - config.ensure_dirs() - save_facts(config.state_dir, SyncFacts(last_error_class=ErrorType.TRANSIENT)) - monkeypatch.setattr(cli_module, "load_config", lambda: config) - monkeypatch.setattr( - cli_module.subprocess, - "run", - MagicMock(return_value=MagicMock(stdout="active\n")), - ) - - assert cmd_status(_args()) == 0 - - out = capsys.readouterr().out - assert "Sync: offline — saving locally; pending unconfirmed (will retry)" in out - assert "Synced:" not in out - assert "Quarantine:" not in out - - -def test_cmd_status_prints_quarantine_line(tmp_path: Path, monkeypatch, capsys): - config = Config( - base_dir=tmp_path, - server_url="https://test.example.com", - key="K123456789", - stream="test-stream", - ) - config.ensure_dirs() - failed_dir = config.captures_dir / "20260101" / "test-stream" / "120000_300.failed" - failed_dir.mkdir(parents=True) - old_time = time.time() - 5 * 86400 - os.utime(failed_dir, (old_time, old_time)) - monkeypatch.setattr(cli_module, "load_config", lambda: config) - monkeypatch.setattr( - cli_module.subprocess, - "run", - MagicMock(return_value=MagicMock(stdout="active\n")), - ) - - assert cmd_status(_args()) == 0 - - out = capsys.readouterr().out - assert "Quarantine:" in out - - -def test_cmd_status_handles_corrupt_config(tmp_path: Path, monkeypatch): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text("[]") - - monkeypatch.setattr( - cli_module, - "load_config", - lambda: real_load_config(base_dir=tmp_path, config_dir=config_dir), - ) - monkeypatch.setattr( - cli_module.subprocess, - "run", - MagicMock(return_value=MagicMock(stdout="inactive\n")), - ) - - assert cmd_status(_args()) == 0 - - -def test_cmd_install_service_uses_environment_path(tmp_path: Path): - binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" - unit_path = tmp_path / ".config" / "systemd" / "user" / "solstone-linux.service" - env = { - "PATH": "/home/user/.local/pipx/venvs/solstone-linux/bin:/usr/local/bin:/usr/bin:/bin:/home/user/.local/bin" - } - - with patch.dict(os.environ, env, clear=True): - with patch("solstone_linux.cli.shutil.which", return_value=binary): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - with patch("solstone_linux.cli.Path.is_dir", return_value=False): - assert cmd_install_service(_args()) == 0 - - unit_content = unit_path.read_text() - assert "PartOf=graphical-session.target" in unit_content - assert "WantedBy=graphical-session.target" in unit_content - path_line = next( - line - for line in unit_content.splitlines() - if line.startswith("Environment=PATH=") - ) - service_path = path_line.removeprefix("Environment=PATH=").split(":") - - assert service_path[0] == "/home/user/.local/pipx/venvs/solstone-linux/bin" - assert service_path == list(dict.fromkeys(service_path)) - - -def test_cmd_install_service_uses_default_path_when_missing(tmp_path: Path): - binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" - unit_path = tmp_path / ".config" / "systemd" / "user" / "solstone-linux.service" - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.shutil.which", return_value=binary): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - with patch("solstone_linux.cli.Path.is_dir", return_value=False): - assert cmd_install_service(_args()) == 0 - - unit_content = unit_path.read_text() - path_line = next( - line - for line in unit_content.splitlines() - if line.startswith("Environment=PATH=") - ) - - assert ( - path_line - == "Environment=PATH=/home/user/.local/pipx/venvs/solstone-linux/bin:/usr/local/bin:/usr/bin:/bin" - ) - - -def test_cmd_install_service_uses_default_path_when_empty(tmp_path: Path): - binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" - unit_path = tmp_path / ".config" / "systemd" / "user" / "solstone-linux.service" - - with patch.dict(os.environ, {"PATH": ""}, clear=True): - with patch("solstone_linux.cli.shutil.which", return_value=binary): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - with patch("solstone_linux.cli.Path.is_dir", return_value=False): - assert cmd_install_service(_args()) == 0 - - unit_content = unit_path.read_text() - path_line = next( - line - for line in unit_content.splitlines() - if line.startswith("Environment=PATH=") - ) - - assert ( - path_line - == "Environment=PATH=/home/user/.local/pipx/venvs/solstone-linux/bin:/usr/local/bin:/usr/bin:/bin" - ) - - -def test_cmd_install_service_always_rewrites(tmp_path: Path, capsys): - binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" - - with patch.dict(os.environ, {"PATH": "/usr/local/bin:/usr/bin:/bin"}, clear=True): - with patch("solstone_linux.cli.shutil.which", return_value=binary): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run") as run_mock: - with patch( - "solstone_linux.cli.Path.is_dir", - autospec=True, - side_effect=_is_dir_without_icons, - ): - assert cmd_install_service(_args()) == 0 - assert cmd_install_service(_args()) == 0 - - captured = capsys.readouterr() - assert "nothing to do" not in captured.out.lower() - assert run_mock.call_count == 8 - - -def test_cmd_install_service_installs_svgs_without_index_theme(tmp_path: Path): - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - - hicolor = tmp_path / ".local/share/icons/hicolor" - status = hicolor / "scalable/status" - - assert {path.name for path in status.glob("*.svg")} == _EXPECTED_SVGS - assert not (hicolor / "index.theme").exists() - - -def test_cmd_install_service_removes_stale_solstone_index_theme(tmp_path: Path): - hicolor = tmp_path / ".local/share/icons/hicolor" - hicolor.mkdir(parents=True) - (hicolor / "index.theme").write_text( - "[Icon Theme]\nName=solstone\nInherits=hicolor\nDirectories=scalable/status\n" - ) - - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - - assert not (hicolor / "index.theme").exists() - - -def test_cmd_install_service_keeps_foreign_index_theme(tmp_path: Path): - hicolor = tmp_path / ".local/share/icons/hicolor" - hicolor.mkdir(parents=True) - index = hicolor / "index.theme" - content = "[Icon Theme]\nName=MyTheme\nName=solstone-custom\n" - index.write_text(content) - - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - - assert index.exists() - assert index.read_text() == content - - -def test_cmd_install_service_reports_unreadable_index_theme( - tmp_path: Path, - capsys, -): - hicolor = tmp_path / ".local/share/icons/hicolor" - hicolor.mkdir(parents=True) - index = hicolor / "index.theme" - index.write_bytes(b"\xff\xfe\x00not utf8") - - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - - captured = capsys.readouterr() - warning_lines = [ - line - for line in captured.out.splitlines() - if "Left existing icon theme index in place" in line - ] - - assert index.exists() - assert len(warning_lines) == 1 - assert str(index) in warning_lines[0] - - -def test_cmd_install_service_icon_step_idempotent(tmp_path: Path): - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - assert cmd_install_service(_args()) == 0 - - hicolor = tmp_path / ".local/share/icons/hicolor" - status = hicolor / "scalable/status" - - assert {path.name for path in status.glob("*.svg")} == _EXPECTED_SVGS - assert not (hicolor / "index.theme").exists() - - -def test_cmd_install_service_survives_missing_gtk_update_icon_cache(tmp_path: Path): - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch( - "solstone_linux.cli.subprocess.run", - side_effect=FileNotFoundError, - ): - assert cmd_install_service(_args()) == 0 - - hicolor = tmp_path / ".local/share/icons/hicolor" - status = hicolor / "scalable/status" - - assert {path.name for path in status.glob("*.svg")} == _EXPECTED_SVGS - assert not (hicolor / "index.theme").exists() - - -def test_cmd_install_service_survives_nonzero_gtk_update_icon_cache(tmp_path: Path): - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run") as run_mock: - run_mock.return_value = MagicMock(returncode=1) - - assert cmd_install_service(_args()) == 0 - - -def test_cmd_install_service_writes_autostart_entry(tmp_path: Path): - with patch("solstone_linux.cli.shutil.which", return_value=_BINARY): - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): - with patch("solstone_linux.cli.subprocess.run"): - assert cmd_install_service(_args()) == 0 - - autostart = tmp_path / ".config" / "autostart" / "solstone-linux.desktop" - assert autostart.exists() - content = autostart.read_text() - assert "Type=Application" in content - assert "solstone-linux.service" in content - assert "import-environment" in content - assert "DISPLAY" in content - assert "XAUTHORITY" in content - assert "XDG_SESSION_TYPE" in content - - -def test_cmd_setup_non_interactive_happy_path(tmp_path: Path): - args = argparse.Namespace( - server_url="https://x", - token="t", - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch("solstone_linux.cli.streams.stream_name", return_value="host-a"): - with patch("solstone_linux.upload.UploadClient.ensure_registered"): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - assert saved_config.server_url == "https://x" - assert saved_config.key == "t" - assert saved_config.stream == "host-a" - - -def test_cmd_setup_non_interactive_defaults_server_url(tmp_path: Path, capsys): - args = argparse.Namespace( - server_url=None, - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=True, - ): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - captured = capsys.readouterr() - assert saved_config.server_url == DEFAULT_SERVER_URL - assert "--server-url" not in captured.err - assert "required" not in captured.err - - -def test_cmd_setup_server_url_override_persists(tmp_path: Path): - args = argparse.Namespace( - server_url="http://192.168.1.50:5015", - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=True, - ): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - assert saved_config.server_url == "http://192.168.1.50:5015" - - -def test_cmd_setup_preserves_existing_server_url(tmp_path: Path): - args = argparse.Namespace( - server_url=None, - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - config.server_url = "https://saved.example" - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=True, - ): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - assert saved_config.server_url == "https://saved.example" - - -def test_cmd_setup_flagged_interactive_empty_input_defaults(tmp_path: Path): - args = argparse.Namespace( - server_url=None, - token=None, - stream_name="host-x", - non_interactive=False, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch("builtins.input", return_value=""): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=True, - ): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - assert saved_config.server_url == DEFAULT_SERVER_URL - - -def test_cmd_setup_interactive_legacy_empty_input_defaults(tmp_path: Path): - config = Config(base_dir=tmp_path) - - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch("solstone_linux.cli.stream_name", return_value="host-a"): - with patch("builtins.input", return_value=""): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=True, - ): - assert _cmd_setup_interactive() == 0 - - saved_config = save_mock.call_args.args[0] - assert saved_config.server_url == DEFAULT_SERVER_URL - - -def test_cmd_setup_env_token_fallback(tmp_path: Path, capsys): - args = argparse.Namespace( - server_url="https://x", - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {"SOLSTONE_TOKEN": "envtok"}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", - return_value="host-a", - ): - with patch("solstone_linux.upload.UploadClient.ensure_registered"): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - captured = capsys.readouterr() - assert saved_config.key == "envtok" - assert "shared computers" not in captured.err - - -def test_cmd_setup_cli_token_beats_env(tmp_path: Path, capsys): - args = argparse.Namespace( - server_url="https://x", - token="clitok", - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {"SOLSTONE_TOKEN": "envtok"}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config") as save_mock: - with patch( - "solstone_linux.cli.streams.stream_name", - return_value="host-a", - ): - with patch("solstone_linux.upload.UploadClient.ensure_registered"): - assert cmd_setup(args) == 0 - - saved_config = save_mock.call_args.args[0] - captured = capsys.readouterr() - assert saved_config.key == "clitok" - assert "shared computers" in captured.err - - -def test_cmd_setup_registers_via_http_when_no_token(tmp_path: Path): - args = argparse.Namespace( - server_url="http://localhost:9", - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - def _register(cfg): - cfg.key = "newkey00" - cfg.stream = "locked-stream" - return True - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config"): - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - side_effect=_register, - ) as reg_mock: - assert cmd_setup(args) == 0 - - reg_mock.assert_called_once() - assert config.key == "newkey00" - assert config.stream == "locked-stream" - - -def test_cmd_setup_http_register_failure_non_interactive_returns_1( - tmp_path: Path, capsys -): - args = argparse.Namespace( - server_url="http://localhost:9", - token=None, - stream_name=None, - non_interactive=True, - ) - config = Config(base_dir=tmp_path) - - with patch.dict(os.environ, {}, clear=True): - with patch("solstone_linux.cli.load_config", return_value=config): - with patch("solstone_linux.cli.save_config"): - with patch( - "solstone_linux.cli.streams.stream_name", return_value="host-a" - ): - with patch( - "solstone_linux.upload.UploadClient.ensure_registered", - return_value=False, - ): - assert cmd_setup(args) == 1 - - captured = capsys.readouterr() - assert "registration failed" in captured.out.lower() diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 35177e7..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,315 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -import logging -import os -import stat -from pathlib import Path - -import pytest - -from solstone_linux.config import ( - DEFAULT_SEGMENT_INTERVAL, - DEFAULT_SYNC_MAX_RETRIES, - DEFAULT_SYNC_RETRY_DELAYS, - Config, - load_config, - save_config, -) - - -class TestConfig: - def test_defaults(self): - config = Config() - assert config.server_url == "" - assert config.key == "" - assert config.segment_interval == 300 - - def test_captures_dir(self): - config = Config() - assert config.captures_dir == config.base_dir / "captures" - - def test_restore_token_path(self): - config = Config() - assert config.restore_token_path == config.config_dir / "restore_token" - - def test_config_dir_uses_absolute_xdg(self, tmp_path: Path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - config = Config() - - assert config.config_dir == tmp_path / "solstone-linux" - assert config.config_path == tmp_path / "solstone-linux" / "config.json" - assert ( - config.restore_token_path == tmp_path / "solstone-linux" / "restore_token" - ) - - def test_config_dir_ignores_relative_xdg(self, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path") - - assert Config().config_dir == Path.home() / ".config" / "solstone-linux" - - def test_config_dir_falls_back_when_xdg_unset(self, monkeypatch): - monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - - assert Config().config_dir == Path.home() / ".config" / "solstone-linux" - - def test_round_trip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.server_url = "https://example.com" - config.key = "test-key-123" - config.stream = "archon" - config.segment_interval = 600 - - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.server_url == "https://example.com" - assert loaded.key == "test-key-123" - assert loaded.stream == "archon" - assert loaded.segment_interval == 600 - - def test_load_missing(self, tmp_path: Path): - config = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert config.server_url == "" - assert config.key == "" - - def test_load_corrupt(self, tmp_path: Path): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text("not json!") - - config = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert config.server_url == "" - - @pytest.mark.parametrize( - ("field", "value", "expected"), - [ - ("capture_framerate", "abc", 1), - ("segment_interval", "300", DEFAULT_SEGMENT_INTERVAL), - ("sync_retry_delays", "oops", list(DEFAULT_SYNC_RETRY_DELAYS)), - ("sync_max_retries", "many", DEFAULT_SYNC_MAX_RETRIES), - ("cache_retention_days", [], 7), - ], - ) - def test_load_invalid_typed_fields_warn_and_default( - self, - tmp_path: Path, - caplog, - field, - value, - expected, - ): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text(json.dumps({field: value})) - - with caplog.at_level(logging.WARNING): - config = load_config(base_dir=tmp_path, config_dir=config_dir) - - assert getattr(config, field) == expected - warning_records = [ - record for record in caplog.records if field in record.message - ] - assert len(warning_records) == 1 - - def test_load_non_object_json_warns_and_defaults(self, tmp_path: Path, caplog): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text("[]") - - with caplog.at_level(logging.WARNING): - config = load_config(base_dir=tmp_path, config_dir=config_dir) - - assert config.server_url == "" - assert config.key == "" - assert config.stream == "" - assert config.segment_interval == DEFAULT_SEGMENT_INTERVAL - assert config.sync_retry_delays == list(DEFAULT_SYNC_RETRY_DELAYS) - assert config.sync_max_retries == DEFAULT_SYNC_MAX_RETRIES - assert config.cache_retention_days == 7 - assert config.capture_framerate == 1 - warning_records = [ - record for record in caplog.records if "not a JSON object" in record.message - ] - assert len(warning_records) == 1 - - def test_permissions(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.server_url = "https://example.com" - config.key = "secret" - save_config(config) - - mode = config.config_path.stat().st_mode & 0o777 - assert mode == 0o600 - - def test_sync_config_roundtrip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.sync_retry_delays = [10, 60, 300] - config.sync_max_retries = 5 - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.sync_retry_delays == [10, 60, 300] - assert loaded.sync_max_retries == 5 - - def test_cache_retention_days_roundtrip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.cache_retention_days = 14 - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.cache_retention_days == 14 - - def test_cache_retention_days_default(self, tmp_path: Path): - """Existing configs without cache_retention_days default to 7.""" - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text('{"server_url": "http://test"}') - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.cache_retention_days == 7 - - def test_capture_framerate_default(self): - config = Config() - assert config.capture_framerate == 1 - - def test_draw_cursor_default(self): - config = Config() - assert config.draw_cursor is True - - def test_capture_framerate_roundtrip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.capture_framerate = 2 - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.capture_framerate == 2 - - def test_draw_cursor_roundtrip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.draw_cursor = False - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.draw_cursor is False - - def test_capture_framerate_defaults_on_old_config(self, tmp_path: Path): - """Existing configs without capture_framerate default to 1.""" - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text('{"server_url": "http://test"}') - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.capture_framerate == 1 - assert loaded.draw_cursor is True - - def test_capture_framerate_clamped_to_max(self, tmp_path: Path): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text('{"capture_framerate": 999}') - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.capture_framerate == 10 - - def test_capture_framerate_clamped_to_min(self, tmp_path: Path): - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text('{"capture_framerate": 0}') - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.capture_framerate == 1 - - def test_start_paused_default(self): - config = Config() - assert config.start_paused is False - - def test_start_paused_roundtrip(self, tmp_path: Path): - config = Config(base_dir=tmp_path, config_dir=tmp_path / "config") - config.start_paused = True - save_config(config) - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.start_paused is True - - def test_start_paused_defaults_on_old_config(self, tmp_path: Path): - """Existing configs without start_paused default to False.""" - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True) - (config_dir / "config.json").write_text('{"server_url": "http://test"}') - - loaded = load_config(base_dir=tmp_path, config_dir=tmp_path / "config") - assert loaded.start_paused is False - - def test_migrates_legacy_config(self, tmp_path: Path, caplog): - old_dir = tmp_path / "config" - old_dir.mkdir() - old_config = old_dir / "config.json" - old_config.write_text( - json.dumps( - { - "server_url": "https://example.com", - "key": "test-key-123", - "stream": "archon", - "capture_framerate": 3, - } - ) - ) - os.chmod(old_config, stat.S_IRUSR | stat.S_IWUSR) - old_token = old_dir / "restore_token" - old_token.write_text("tok") - new_dir = tmp_path / "newcfg" - - with caplog.at_level(logging.INFO): - loaded = load_config(base_dir=tmp_path, config_dir=new_dir) - - new_config = new_dir / "config.json" - new_token = new_dir / "restore_token" - assert loaded.server_url == "https://example.com" - assert loaded.key == "test-key-123" - assert loaded.stream == "archon" - assert loaded.capture_framerate == 3 - assert new_config.exists() - assert stat.S_IMODE(new_config.stat().st_mode) == 0o600 - assert new_token.read_text() == "tok" - assert not old_config.exists() - assert not old_token.exists() - assert not old_dir.exists() - migration_records = [ - record for record in caplog.records if "Migrated config" in record.message - ] - assert len(migration_records) == 1 - - snapshot = ( - new_config.read_text(), - new_token.read_text(), - stat.S_IMODE(new_config.stat().st_mode), - ) - caplog.clear() - - with caplog.at_level(logging.INFO): - loaded_again = load_config(base_dir=tmp_path, config_dir=new_dir) - - assert loaded_again.capture_framerate == 3 - assert [ - record for record in caplog.records if "Migrated config" in record.message - ] == [] - assert ( - new_config.read_text(), - new_token.read_text(), - stat.S_IMODE(new_config.stat().st_mode), - ) == snapshot - - def test_no_migration_when_config_dir_is_legacy(self, tmp_path: Path): - config_dir = tmp_path / "config" - config_dir.mkdir() - config_path = config_dir / "config.json" - content = '{"server_url": "http://test", "capture_framerate": 4}' - config_path.write_text(content) - - loaded = load_config(base_dir=tmp_path, config_dir=config_dir) - - assert loaded.server_url == "http://test" - assert loaded.capture_framerate == 4 - assert config_path.exists() - assert config_path.read_text() == content diff --git a/tests/test_dbus_introspection.py b/tests/test_dbus_introspection.py deleted file mode 100644 index a450dab..0000000 --- a/tests/test_dbus_introspection.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import time -import xml.etree.ElementTree as ET -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from dbus_fast import introspection - -from solstone_linux.dbus_service import ObserverService -from solstone_linux.dbusmenu import DBusMenu -from solstone_linux.sni import StatusNotifierItem - - -HYPHEN_XML = """ - - - - -""" - - -def _make_observer(): - observer = MagicMock() - observer._paused = False - observer._pause_until = 0.0 - observer.current_mode = "screencast" - observer.config = MagicMock() - observer.config.captures_dir = Path("/tmp/test-captures") - observer.config.server_url = "https://test.example.com" - observer.interval = 300 - observer.segment_dir = None - observer.start_at_mono = time.monotonic() - observer._start_mono = time.monotonic() - observer.stream = "test-stream" - observer._sync = None - observer.capture_stats = {"captures_today": 0, "total_size_mb": 0} - return observer - - -def normalize(xml_str: str): - root = ET.fromstring(xml_str) - interfaces = root.findall("interface") if root.tag == "node" else [root] - assert len(interfaces) == 1 - interface = interfaces[0] - - def args_for(member): - return [ - (arg.attrib.get("direction", ""), arg.attrib["type"]) - for arg in member.findall("arg") - ] - - return { - "interface": interface.attrib["name"], - "methods": { - method.attrib["name"]: args_for(method) - for method in interface.findall("method") - }, - "signals": { - signal.attrib["name"]: args_for(signal) - for signal in interface.findall("signal") - }, - "properties": { - prop.attrib["name"]: (prop.attrib["type"], prop.attrib["access"]) - for prop in interface.findall("property") - }, - } - - -def test_hyphenated_portal_property_names_parse_without_monkeypatch(): - # dbus-fast tolerates hyphenated members natively; if a strict validator returns, this fails before screencast.py needs a monkeypatch. - node = introspection.Node.parse(HYPHEN_XML) - - properties = [ - prop.name for interface in node.interfaces for prop in interface.properties - ] - assert "power-saver-enabled" in properties - - -@pytest.mark.parametrize( - ("service_factory", "fixture_name"), - [ - (lambda: ObserverService(_make_observer()), "observer1.xml"), - (StatusNotifierItem, "status_notifier_item.xml"), - (DBusMenu, "dbusmenu.xml"), - ], - ids=["observer1", "status-notifier-item", "dbusmenu"], -) -def test_served_introspection_matches_legacy_baseline(service_factory, fixture_name): - fixture_path = Path(__file__).parent / "fixtures" / "introspection" / fixture_name - baseline_xml = fixture_path.read_text(encoding="utf-8") - service = service_factory() - dbus_fast_xml = introspection.Node(interfaces=[service.introspect()]).tostring() - - assert normalize(dbus_fast_xml) == normalize(baseline_xml) diff --git a/tests/test_dbus_service.py b/tests/test_dbus_service.py deleted file mode 100644 index b607b05..0000000 --- a/tests/test_dbus_service.py +++ /dev/null @@ -1,296 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import time -from datetime import datetime, timedelta -from pathlib import Path -from unittest.mock import MagicMock - -from dbus_fast.service import ServiceInterface - -from solstone_linux.capture_stats import compute_capture_stats -from solstone_linux.config import Config -from solstone_linux.dbus_service import ObserverService -from solstone_linux.sync import SyncService -from solstone_linux.sync_health import SyncFacts -from solstone_linux.upload import UploadClient - - -def _get_prop(service, name): - for prop in ServiceInterface._get_properties(service): - if prop.name == name: - return prop.prop_getter(service) - raise KeyError(name) - - -def _call_method(service, name, *args): - for method in ServiceInterface._get_methods(service): - if method.name == name: - return method.fn(service, *args) - raise KeyError(name) - - -def _make_observer(captures_dir: Path | None = None): - observer = MagicMock() - observer._paused = False - observer._pause_until = 0.0 - observer.current_mode = "screencast" - observer.config = MagicMock() - observer.config.captures_dir = captures_dir or Path("/tmp/test-captures") - observer.config.server_url = "https://test.example.com" - observer.interval = 300 - observer.segment_dir = None - observer.start_at_mono = time.monotonic() - observer._start_mono = time.monotonic() - observer.stream = "test-stream" - observer._sync = None - observer._dbus_service = None - observer.capture_stats = {"captures_today": 0, "total_size_mb": 0} - return observer - - -class TestObserverServiceStatus: - def test_status_recording(self): - observer = _make_observer() - observer.current_mode = "screencast" - - service = ObserverService(observer) - - assert _get_prop(service, "Status") == "recording" - - def test_status_idle(self): - observer = _make_observer() - observer.current_mode = "idle" - - service = ObserverService(observer) - - assert _get_prop(service, "Status") == "idle" - - def test_status_paused(self): - observer = _make_observer() - observer._paused = True - - service = ObserverService(observer) - - assert _get_prop(service, "Status") == "paused" - - -class TestPauseResume: - def test_pause_calls_observer(self): - observer = _make_observer() - service = ObserverService(observer) - - result = _call_method(service, "Pause", 30) - - assert result == "ok" - observer.pause.assert_called_once_with(30) - - def test_pause_indefinite_calls_observer(self): - observer = _make_observer() - service = ObserverService(observer) - - _call_method(service, "Pause", 0) - - observer.pause.assert_called_once_with(0) - - def test_resume_calls_observer(self): - observer = _make_observer() - service = ObserverService(observer) - - result = _call_method(service, "Resume") - - assert result == "ok" - observer.resume.assert_called_once() - - -class TestAutoResume: - def test_auto_resume_expiry(self): - observer = _make_observer() - observer._paused = True - observer._pause_until = time.monotonic() - 1 - - if ( - observer._paused - and observer._pause_until > 0 - and time.monotonic() >= observer._pause_until - ): - observer._paused = False - observer._pause_until = 0.0 - - assert observer._paused is False - assert observer._pause_until == 0.0 - - -class TestSegmentTimerAndPauseRemaining: - def test_segment_timer_while_recording(self): - observer = _make_observer() - observer.segment_dir = Path("/tmp/test.incomplete") - observer.start_at_mono = time.monotonic() - 60 - service = ObserverService(observer) - - timer = _get_prop(service, "SegmentTimer") - - assert 238 <= timer <= 242 - - def test_segment_timer_zero_when_paused(self): - observer = _make_observer() - observer._paused = True - service = ObserverService(observer) - - assert _get_prop(service, "SegmentTimer") == 0 - - def test_segment_timer_zero_when_no_segment(self): - observer = _make_observer() - observer.segment_dir = None - service = ObserverService(observer) - - assert _get_prop(service, "SegmentTimer") == 0 - - def test_pause_remaining_during_timed_pause(self): - observer = _make_observer() - observer._paused = True - observer._pause_until = time.monotonic() + 120 - service = ObserverService(observer) - - remaining = _get_prop(service, "PauseRemaining") - - assert 118 <= remaining <= 122 - - def test_pause_remaining_zero_when_not_paused(self): - observer = _make_observer() - observer._paused = False - service = ObserverService(observer) - - assert _get_prop(service, "PauseRemaining") == 0 - - def test_pause_remaining_zero_for_indefinite_pause(self): - observer = _make_observer() - observer._paused = True - observer._pause_until = 0.0 - service = ObserverService(observer) - - assert _get_prop(service, "PauseRemaining") == 0 - - -class TestComputeCaptureStats: - def test_returns_walk_counts(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - today = datetime.now().strftime("%Y%m%d") - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") - segment_dir = captures_dir / today / "stream-a" / "120000_300" - incomplete_dir = captures_dir / today / "stream-a" / "120500.incomplete" - failed_dir = captures_dir / today / "stream-a" / "121000_300.failed" - old_segment = captures_dir / yesterday / "stream-a" / "130000_300" - segment_dir.mkdir(parents=True) - incomplete_dir.mkdir(parents=True) - failed_dir.mkdir(parents=True) - old_segment.mkdir(parents=True) - (segment_dir / "audio.flac").write_bytes(b"x" * (1024 * 1024)) - (incomplete_dir / "audio.flac").write_bytes(b"x" * (1024 * 1024)) - (failed_dir / "audio.flac").write_bytes(b"x" * (1024 * 1024)) - (old_segment / "audio.flac").write_bytes(b"x") - - stats = compute_capture_stats(captures_dir, today) - - assert stats == {"captures_today": 1, "total_size_mb": 1} - - def test_empty_captures(self, tmp_path: Path): - stats = compute_capture_stats(tmp_path / "captures", "20260101") - - assert stats == {"captures_today": 0, "total_size_mb": 0} - - -class TestGetStats: - def test_returns_cached_stats_dict(self, tmp_path: Path): - observer = _make_observer(tmp_path / "captures") - observer.capture_stats = {"captures_today": 7, "total_size_mb": 42} - service = ObserverService(observer) - - stats = _call_method(service, "GetStats") - - assert stats["captures_today"].value == 7 - assert stats["total_size_mb"].value == 42 - assert "synced_days" not in stats - assert stats["uptime_seconds"].value >= 0 - - def test_empty_captures(self, tmp_path: Path): - observer = _make_observer(tmp_path / "captures") - service = ObserverService(observer) - - stats = _call_method(service, "GetStats") - - assert stats["captures_today"].value == 0 - assert stats["total_size_mb"].value == 0 - assert "synced_days" not in stats - - def test_uses_cached_today_count(self, tmp_path: Path): - observer = _make_observer(tmp_path / "captures") - observer.capture_stats = {"captures_today": 1, "total_size_mb": 0} - service = ObserverService(observer) - - stats = _call_method(service, "GetStats") - - assert stats["captures_today"].value == 1 - - -class TestSyncStatusTracking: - def test_initial_status(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - - sync = SyncService(config, client) - - assert sync.health.state.value == "unknown" - assert sync.progress == "" - - def test_progress_drives_syncing_status(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - - sync = SyncService(config, client) - sync._facts = SyncFacts(in_progress=True, progress="uploading 120000_300") - - assert sync.health.state.value == "syncing" - assert sync.progress == "uploading 120000_300" - - def test_progress_change_emits_signal(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - - sync = SyncService(config, client) - sync._dbus_service = MagicMock() - - sync._set_progress("30s until probe") - - sync._dbus_service.SyncProgressChanged.assert_called_once_with( - "syncing:30s until probe" - ) - - -class TestObserverServiceConfig: - def test_capture_dir(self, tmp_path: Path): - observer = _make_observer(tmp_path / "captures") - service = ObserverService(observer) - - assert _get_prop(service, "CaptureDir") == str(observer.config.captures_dir) - - def test_server_url(self): - observer = _make_observer() - service = ObserverService(observer) - - assert _get_prop(service, "ServerUrl") == "https://test.example.com" - - def test_stream(self): - observer = _make_observer() - service = ObserverService(observer) - - assert _get_prop(service, "Stream") == "test-stream" - - def test_segment_interval(self): - observer = _make_observer() - service = ObserverService(observer) - - assert _get_prop(service, "SegmentInterval") == 300 diff --git a/tests/test_dbusmenu.py b/tests/test_dbusmenu.py deleted file mode 100644 index 23cafdd..0000000 --- a/tests/test_dbusmenu.py +++ /dev/null @@ -1,108 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from unittest.mock import MagicMock - -from dbus_fast import Variant - -from solstone_linux.dbusmenu import DBusMenu, MenuItem - - -def test_default_emits_enabled_and_visible_true(): - props = MenuItem(label="foo").get_properties() - - assert props["enabled"] == Variant("b", True) - assert props["visible"] == Variant("b", True) - - -def test_explicit_false_emits_false(): - props = MenuItem(label="x", enabled=False, visible=False).get_properties() - - assert props["enabled"] == Variant("b", False) - assert props["visible"] == Variant("b", False) - - -def test_toggle_true_after_false_still_emits(): - item = MenuItem(label="x", enabled=False, visible=False) - item.enabled = True - item.visible = True - - props = item.get_properties() - - assert props["enabled"] == Variant("b", True) - assert props["visible"] == Variant("b", True) - - -def test_other_keys_still_conditional(): - props = MenuItem().get_properties() - - assert "icon-name" not in props - assert "toggle-type" not in props - assert "children-display" not in props - - -def test_update_properties_emits_items_properties_updated(): - menu = DBusMenu() - item = MenuItem(label="resume", visible=True) - menu.set_menu([item]) - menu.ItemsPropertiesUpdated = MagicMock() - menu.LayoutUpdated = MagicMock() - revision = menu._revision - item.visible = False - - menu.update_properties(item, "visible") - - menu.ItemsPropertiesUpdated.assert_called_once() - menu.LayoutUpdated.assert_not_called() - assert menu._revision == revision - assert menu._props_emitted == 1 - - updated_props, removed_props = menu.ItemsPropertiesUpdated.call_args.args - assert removed_props == [] - assert len(updated_props) == 1 - item_id, props = updated_props[0] - assert item_id == item.id - assert props.keys() == {"visible"} - assert props["visible"].signature == "b" - assert props["visible"].value is False - - -def test_update_properties_noop_when_no_names(): - menu = DBusMenu() - item = MenuItem(label="resume") - menu.set_menu([item]) - menu.ItemsPropertiesUpdated = MagicMock() - menu.LayoutUpdated = MagicMock() - revision = menu._revision - - menu.update_properties(item) - - menu.ItemsPropertiesUpdated.assert_not_called() - menu.LayoutUpdated.assert_not_called() - assert menu._revision == revision - assert menu._props_emitted == 0 - - -def test_about_to_show_uses_optional_hook(): - menu = DBusMenu() - - assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False - - menu.on_about_to_show = lambda: True - assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is True - - menu.on_about_to_show = lambda: False - assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False - - -def test_about_to_show_group_uses_optional_hook(): - menu = DBusMenu() - ids = [1, 2, 3] - - assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []] - - menu.on_about_to_show = lambda: True - assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [ids, []] - - menu.on_about_to_show = lambda: False - assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []] diff --git a/tests/test_docs_mirror.py b/tests/test_docs_mirror.py deleted file mode 100644 index 9c91d45..0000000 --- a/tests/test_docs_mirror.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import re -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent -DISTROS = { - "fedora": "fedora", - "debian / ubuntu": "debian / ubuntu", - "arch": "arch", - "opensuse": "opensuse", -} - - -def _normalize_command(lines): - joined = " ".join(line.strip().rstrip("\\").strip() for line in lines) - return re.sub(r"\s+", " ", joined).strip() - - -def _dependency_commands(path): - lines = path.read_text().splitlines() - commands = {} - for index, line in enumerate(lines): - match = re.match(r"\s*\*\*([^*]+):\*\*", line) - if not match: - continue - key = DISTROS.get(match.group(1).strip().casefold()) - if key is None: - continue - if key in commands: - continue - - fence_start = None - for probe in range(index + 1, len(lines)): - if lines[probe].strip() == "```": - fence_start = probe + 1 - break - assert fence_start is not None, f"missing command fence after {line!r}" - - command_lines = [] - for probe in range(fence_start, len(lines)): - if lines[probe].strip() == "```": - break - command_lines.append(lines[probe]) - commands[key] = _normalize_command(command_lines) - - return commands - - -def test_readme_and_install_dependency_commands_match(): - readme = _dependency_commands(ROOT / "README.md") - install = _dependency_commands(ROOT / "INSTALL.md") - - assert readme.keys() == install.keys() == DISTROS.keys() - assert readme == install diff --git a/tests/test_doctor.py b/tests/test_doctor.py deleted file mode 100644 index 4517e14..0000000 --- a/tests/test_doctor.py +++ /dev/null @@ -1,489 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import sys -from pathlib import Path - -import pytest - -from solstone_linux import doctor -from solstone_linux.config import Config -from solstone_linux.sync_health import ErrorType, SyncFacts, save_facts - - -def _set_all_checks( - monkeypatch, - *, - python_result=None, - session_type_result=None, - gtk_result=None, - gstreamer_result=None, - cairo_result=None, - pipewire_result=None, - portal_result=None, - x11_capture_result=None, - systemd_result=None, - sync_health_result=None, - pipx_result=None, - appindicator_result=None, -): - monkeypatch.setattr( - doctor, - "check_python_version", - lambda: python_result or doctor.CheckResult("python version", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_session_type", - lambda: ( - session_type_result or doctor.CheckResult("session type", "ok", "wayland") - ), - ) - monkeypatch.setattr( - doctor, - "check_gtk4_typelib", - lambda: gtk_result or doctor.CheckResult("gtk4 typelib", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_gstreamer", - lambda: gstreamer_result or doctor.CheckResult("gstreamer", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_cairo", - lambda: cairo_result or doctor.CheckResult("cairo binding", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_pipewire", - lambda: pipewire_result or doctor.CheckResult("pipewire (pactl)", "ok", ""), - ) - - async def _portal(): - return portal_result or doctor.CheckResult("xdg-desktop-portal", "ok", "") - - monkeypatch.setattr(doctor, "check_portal", _portal) - monkeypatch.setattr( - doctor, - "check_x11_capture", - lambda: x11_capture_result or doctor.CheckResult("x11 capture", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_user_systemd", - lambda: systemd_result or doctor.CheckResult("systemd --user", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_sync_health", - lambda: sync_health_result or doctor.CheckResult("sync health", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_pipx", - lambda: pipx_result or doctor.CheckResult("pipx", "ok", ""), - ) - monkeypatch.setattr( - doctor, - "check_appindicator_ext", - lambda: ( - appindicator_result - or doctor.CheckResult("appindicator ext (soft)", "ok", "") - ), - ) - monkeypatch.setattr( - doctor, - "load_config", - lambda: Config(base_dir=Path("/__solstone_linux_missing_test_base__")), - ) - - -def test_run_doctor_all_pass_returns_zero(monkeypatch, capsys): - _set_all_checks(monkeypatch) - - assert doctor.run_doctor() == 0 - - captured = capsys.readouterr() - assert "python version" in captured.out - assert "gtk4 typelib" in captured.out - assert "doctor: 12 checks, 0 failed, 0 warnings" in captured.out - - -def test_run_doctor_prints_quarantine_line(monkeypatch, tmp_path: Path, capsys): - _set_all_checks(monkeypatch) - config = Config(base_dir=tmp_path) - config.ensure_dirs() - failed_dir = config.captures_dir / "20260101" / "archon" / "120000_300.failed" - failed_dir.mkdir(parents=True) - monkeypatch.setattr(doctor, "load_config", lambda: config) - - assert doctor.run_doctor() == 0 - - captured = capsys.readouterr() - assert "Quarantine:" in captured.out - assert "doctor: 12 checks, 0 failed, 0 warnings" in captured.out - - -def test_run_doctor_omits_empty_quarantine_line(monkeypatch, tmp_path: Path, capsys): - _set_all_checks(monkeypatch) - config = Config(base_dir=tmp_path) - monkeypatch.setattr(doctor, "load_config", lambda: config) - - assert doctor.run_doctor() == 0 - - captured = capsys.readouterr() - assert "Quarantine:" not in captured.out - assert "doctor: 12 checks, 0 failed, 0 warnings" in captured.out - - -def test_run_doctor_any_fail_returns_one(monkeypatch): - _set_all_checks( - monkeypatch, - pipx_result=doctor.CheckResult("pipx", "fail", "missing"), - ) - - assert doctor.run_doctor() == 1 - - -def test_run_doctor_warn_only_returns_zero(monkeypatch): - _set_all_checks( - monkeypatch, - appindicator_result=doctor.CheckResult( - "appindicator ext (soft)", - "warn", - "install gnome-shell-extension-appindicator", - ), - ) - - assert doctor.run_doctor() == 0 - - -def test_check_sync_health_update_needed(monkeypatch, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - save_facts( - config.state_dir, - SyncFacts(last_error_class=ErrorType.INCOMPATIBLE, last_error_code=404), - ) - monkeypatch.setattr(doctor, "load_config", lambda: config) - - result = doctor.check_sync_health() - - assert result.name == "sync health" - assert result.severity == "fail" - assert "update needed" in result.detail - - -def test_check_exception_renders_as_fail(monkeypatch, capsys): - _set_all_checks(monkeypatch) - - def _boom(): - raise RuntimeError("boom") - - monkeypatch.setattr(doctor, "check_pipx", _boom) - - assert doctor.run_doctor() == 1 - - captured = capsys.readouterr() - assert "RuntimeError" in captured.out - assert "boom" in captured.out - - -def test_python_version_old_fails(monkeypatch): - monkeypatch.setattr(sys, "version_info", (3, 9, 0, "final", 0)) - - result = doctor.check_python_version() - - assert result.severity == "fail" - assert "3.10" in result.detail - - -def test_python_version_current_ok(): - result = doctor.check_python_version() - - assert result.severity == "ok" - - -def test_pipx_missing_fails(monkeypatch): - monkeypatch.setattr(doctor.shutil, "which", lambda _: None) - - result = doctor.check_pipx() - - assert result.severity == "fail" - - -def test_session_type_wayland_ok(monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") - - result = doctor.check_session_type() - - assert result.severity == "ok" - assert "wayland" in result.detail - - -def test_session_type_x11_ok(monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - - result = doctor.check_session_type() - - assert result.severity == "ok" - assert "x11" in result.detail.lower() - - -def test_session_type_unset_warns(monkeypatch): - monkeypatch.delenv("XDG_SESSION_TYPE", raising=False) - - result = doctor.check_session_type() - - assert result.severity == "warn" - assert "XDG_SESSION_TYPE" in result.detail - - -def test_session_type_unknown_warns(monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "tty") - - result = doctor.check_session_type() - - assert result.severity == "warn" - assert "tty" in result.detail - - -def test_appindicator_non_gnome_is_ok_not_applicable(monkeypatch): - monkeypatch.delenv("XDG_CURRENT_DESKTOP", raising=False) - - result = doctor.check_appindicator_ext() - - assert result.severity == "ok" - assert "not applicable" in result.detail - - -class _FakeIface: - def __init__(self, owned=True, raises=None): - self._owned = owned - self._raises = raises - - async def call_name_has_owner(self, name): - if self._raises is not None: - raise self._raises - return self._owned - - -class _FakeProxy: - def __init__(self, iface): - self._iface = iface - - def get_interface(self, name): - return self._iface - - -class _FakeBus: - def __init__( - self, - bus_type=None, - iface=None, - connect_exc=None, - introspect_exc_for_portal=None, - introspect_hang=False, - ): - self._iface = iface or _FakeIface() - self._connect_exc = connect_exc - self._introspect_exc_for_portal = introspect_exc_for_portal - self._introspect_hang = introspect_hang - self.introspect_calls = [] - self.disconnected = False - - async def connect(self): - if self._connect_exc is not None: - raise self._connect_exc - return self - - async def introspect(self, service, path): - self.introspect_calls.append((service, path)) - if service == "org.freedesktop.portal.Desktop": - if self._introspect_exc_for_portal is not None: - raise self._introspect_exc_for_portal - if self._introspect_hang: - import asyncio as _a - - await _a.Event().wait() - return object() - - def get_proxy_object(self, service, path, intro): - return _FakeProxy(self._iface) - - def disconnect(self): - self.disconnected = True - - -@pytest.mark.asyncio -async def test_check_portal_registered_returns_ok(monkeypatch): - fake_instance = _FakeBus(iface=_FakeIface(owned=True)) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "ok" - assert "registered" in result.detail - assert fake_instance.disconnected is True - - -@pytest.mark.asyncio -async def test_check_portal_not_registered_returns_fail(monkeypatch): - monkeypatch.delenv("XDG_SESSION_TYPE", raising=False) - fake_instance = _FakeBus(iface=_FakeIface(owned=False)) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "fail" - assert "not registered" in result.detail - assert "unreachable" not in result.detail - assert "timed out" not in result.detail - - -@pytest.mark.asyncio -async def test_check_portal_bus_unreachable_returns_fail(monkeypatch): - fake_instance = _FakeBus(connect_exc=OSError("no bus")) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "fail" - assert "unreachable" in result.detail - assert "no bus" in result.detail - - -@pytest.mark.asyncio -async def test_check_portal_timeout_returns_fail(monkeypatch): - fake_instance = _FakeBus(introspect_hang=True) - monkeypatch.setattr(doctor, "_PORTAL_CHECK_TIMEOUT_SEC", 0.05) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "fail" - assert "timed out" in result.detail - assert fake_instance.disconnected is True - - -@pytest.mark.asyncio -async def test_check_portal_x11_not_registered_returns_warn(monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - fake_instance = _FakeBus(iface=_FakeIface(owned=False)) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "warn" - assert "x11" in result.detail.lower() - assert "not needed" in result.detail.lower() - - -@pytest.mark.asyncio -async def test_check_portal_tolerates_hyphenated_portal_properties(monkeypatch): - from dbus_fast.errors import InvalidMemberNameError - - fake_instance = _FakeBus( - iface=_FakeIface(owned=True), - introspect_exc_for_portal=InvalidMemberNameError( - "invalid member name: power-saver-enabled" - ), - ) - monkeypatch.setattr("dbus_fast.aio.MessageBus", lambda bus_type=None: fake_instance) - - result = await doctor.check_portal() - - assert result.severity == "ok" - assert "registered" in result.detail - assert all( - service != "org.freedesktop.portal.Desktop" - for service, _path in fake_instance.introspect_calls - ), ( - "check_portal() should not introspect org.freedesktop.portal.Desktop; " - f"calls were {fake_instance.introspect_calls!r}" - ) - - -class TestCheckX11Capture: - def test_wayland_session_not_applicable(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") - monkeypatch.delenv("DISPLAY", raising=False) - - result = doctor.check_x11_capture() - - assert result.severity == "ok" - assert "not applicable" in result.detail - - def test_no_display_no_x11_session_not_applicable(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "") - monkeypatch.delenv("DISPLAY", raising=False) - - result = doctor.check_x11_capture() - - assert result.severity == "ok" - assert "not applicable" in result.detail - - def test_x11_session_no_display_fails(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - monkeypatch.delenv("DISPLAY", raising=False) - - result = doctor.check_x11_capture() - - assert result.severity == "fail" - assert "DISPLAY" in result.detail - - def test_display_set_xrandr_missing_fails(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(doctor.shutil, "which", lambda _: None) - - result = doctor.check_x11_capture() - - assert result.severity == "fail" - assert "xrandr" in result.detail - - def test_display_set_ximagesrc_missing_fails(self, monkeypatch): - import subprocess as _sp - - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(doctor.shutil, "which", lambda cmd: "/usr/bin/" + cmd) - - completed = _sp.CompletedProcess([], returncode=1, stdout=b"", stderr=b"") - monkeypatch.setattr(doctor.subprocess, "run", lambda *a, **kw: completed) - - result = doctor.check_x11_capture() - - assert result.severity == "fail" - assert "ximagesrc" in result.detail - - def test_display_set_gst_inspect_missing_warns(self, monkeypatch): - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(doctor.shutil, "which", lambda cmd: "/usr/bin/" + cmd) - monkeypatch.setattr( - doctor.subprocess, - "run", - lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError()), - ) - - result = doctor.check_x11_capture() - - assert result.severity == "warn" - assert "gst-inspect-1.0" in result.detail - - def test_all_present_ok(self, monkeypatch): - import subprocess as _sp - - monkeypatch.setenv("XDG_SESSION_TYPE", "x11") - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(doctor.shutil, "which", lambda cmd: "/usr/bin/" + cmd) - - completed = _sp.CompletedProcess([], returncode=0, stdout=b"", stderr=b"") - monkeypatch.setattr(doctor.subprocess, "run", lambda *a, **kw: completed) - - result = doctor.check_x11_capture() - - assert result.severity == "ok" - assert "ximagesrc" in result.detail diff --git a/tests/test_event_sender.py b/tests/test_event_sender.py deleted file mode 100644 index 08962b4..0000000 --- a/tests/test_event_sender.py +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import logging -import time -from threading import Event - -import solstone_linux.event_sender as event_sender -from solstone_linux.event_sender import EventSender - - -def _wait_for(predicate, timeout: float = 0.5) -> bool: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if predicate(): - return True - time.sleep(0.01) - return predicate() - - -def test_submit_status_is_nonblocking_while_relay_is_blocked(): - relay_started = Event() - release_relay = Event() - - def relay(_tract, _event, **_fields): - relay_started.set() - release_relay.wait(timeout=1) - return True - - sender = EventSender(relay) - sender.submit_status({"seq": 1}) - sender.start() - assert relay_started.wait(timeout=0.5) - - start = time.monotonic() - sender.submit_status({"seq": 2}) - elapsed = time.monotonic() - start - - release_relay.set() - sender.stop(0.5) - assert elapsed < 0.1 - - -def test_status_supersession_delivers_newest_after_blocked_relay_recovers(): - relay_started = Event() - release_relay = Event() - calls = [] - - def relay(tract, event, **fields): - calls.append((tract, event, fields)) - if fields["seq"] == 1: - relay_started.set() - release_relay.wait(timeout=1) - return True - - sender = EventSender(relay) - sender.submit_status({"seq": 1}) - sender.start() - assert relay_started.wait(timeout=0.5) - - sender.submit_status({"seq": 2}) - sender.submit_status({"seq": 3}) - release_relay.set() - - assert _wait_for(lambda: len(calls) >= 2) - sender.stop(0.5) - assert calls[-1] == ("observe", "status", {"seq": 3}) - - -def test_stream_silent_overflow_drop_and_bounded_stop(monkeypatch, caplog): - delivered = [] - monkeypatch.setattr(event_sender, "SILENT_QUEUE_MAX", 1) - sender = EventSender( - lambda tract, event, **fields: delivered.append(fields) or True - ) - - sender.submit_stream_silent( - {"connector": "HDMI-1", "position": "left", "node_id": 1} - ) - with caplog.at_level(logging.WARNING): - sender.submit_stream_silent( - {"connector": "DP-1", "position": "right", "node_id": 2} - ) - - drop_warnings = [ - record.message - for record in caplog.records - if "Dropping stream_silent event" in record.message - ] - assert drop_warnings == [ - "Dropping stream_silent event because queue is full: " - "connector=DP-1 position=right" - ] - - sender.start() - assert _wait_for(lambda: len(delivered) == 1) - sender.stop(0.5) - assert delivered == [{"connector": "HDMI-1", "position": "left", "node_id": 1}] - - relay_started = Event() - release_relay = Event() - - def blocking_relay(_tract, _event, **_fields): - relay_started.set() - release_relay.wait(timeout=1) - return True - - blocked_sender = EventSender(blocking_relay) - blocked_sender.submit_stream_silent({"connector": "eDP-1", "position": "center"}) - blocked_sender.start() - assert relay_started.wait(timeout=0.5) - - start = time.monotonic() - with caplog.at_level(logging.WARNING): - blocked_sender.stop(0.01) - elapsed = time.monotonic() - start - - release_relay.set() - blocked_sender.stop(0.5) - assert elapsed < 0.2 - assert any("may be undelivered" in record.message for record in caplog.records) diff --git a/tests/test_extract_changelog.py b/tests/test_extract_changelog.py deleted file mode 100644 index 8271a26..0000000 --- a/tests/test_extract_changelog.py +++ /dev/null @@ -1,64 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import subprocess -from pathlib import Path - -SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "extract_changelog.sh" - - -def _run(args, cwd): - return subprocess.run( - ["bash", str(SCRIPT), *args], - cwd=cwd, - capture_output=True, - text=True, - ) - - -def test_two_block_extracts_target_only(tmp_path): - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n" - "\n" - "## [0.2.0] - 2026-06-01\n" - "\n" - "second release line.\n" - "\n" - "## [0.1.0] - 2026-05-19\n" - "\n" - "first release line.\n" - ) - result = _run(["0.2.0", str(changelog)], cwd=tmp_path) - assert result.returncode == 0, result.stderr - assert "## [0.2.0]" in result.stdout - assert "second release line." in result.stdout - assert "## [0.1.0]" not in result.stdout - assert "first release line." not in result.stdout - - -def test_one_block_bootstrap(tmp_path): - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n" - "\n" - "## [0.1.0] - 2026-05-19\n" - "\n" - "first release line.\n" - "trailing line.\n" - ) - result = _run(["0.1.0", str(changelog)], cwd=tmp_path) - assert result.returncode == 0, result.stderr - assert "## [0.1.0]" in result.stdout - assert "first release line." in result.stdout - assert "trailing line." in result.stdout - - -def test_missing_version_errors(tmp_path): - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## [0.1.0] - 2026-05-19\n\nfirst release line.\n" - ) - result = _run(["9.9.9", str(changelog)], cwd=tmp_path) - assert result.returncode != 0 - assert "9.9.9" in result.stderr diff --git a/tests/test_install_guard.py b/tests/test_install_guard.py deleted file mode 100644 index 588a236..0000000 --- a/tests/test_install_guard.py +++ /dev/null @@ -1,192 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import sys -from pathlib import Path - -from solstone_linux import install_guard -from solstone_linux.install_guard import State - - -def _set_home(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setattr(install_guard.Path, "home", lambda: tmp_path) - - -def _run_main(monkeypatch, *argv: str) -> int: - monkeypatch.setattr(sys, "argv", ["install_guard", *argv]) - return install_guard.main() - - -def test_state_absent(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - - assert install_guard.check(curdir) == (State.ABSENT, None) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 0 - captured = capsys.readouterr() - assert captured.out == "mode: fresh install\n" - assert captured.err == "" - - assert _run_main(monkeypatch, "preuninstall", str(curdir)) == 0 - captured = capsys.readouterr() - assert captured.out == "no artifacts to remove\n" - assert captured.err == "" - - -def test_state_unknown_pre_hygiene(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.UNKNOWN, None) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 - captured = capsys.readouterr() - assert captured.out == "mode: aborted — unknown install state\n" - assert ( - "error: installed: unknown (no .install-source marker — likely pre-hygiene install)\n" - in captured.err - ) - - -def test_state_owned_install_mode(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.write_marker(curdir) - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.OWNED, curdir.resolve()) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 10 - captured = capsys.readouterr() - assert captured.out == "mode: upgrade\n" - assert captured.err == "" - - -def test_state_owned_uninstall_mode(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.write_marker(curdir) - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert _run_main(monkeypatch, "preuninstall", str(curdir)) == 10 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "" - - -def test_state_cross_repo(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - other = tmp_path / "other" - curdir.mkdir() - other.mkdir() - install_guard.write_marker(other) - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.CROSS_REPO, other.resolve()) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 - captured = capsys.readouterr() - assert captured.out == "mode: aborted — cross-repo contamination\n" - assert "error: cross-repo contamination detected\n" in captured.err - assert f"current repo: {curdir.resolve()}\n" in captured.err - assert f"installed from: {other.resolve()}\n" in captured.err - - -def test_state_partial_owned(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.write_marker(curdir) - - assert install_guard.check(curdir) == (State.PARTIAL_OWNED, curdir.resolve()) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 10 - captured = capsys.readouterr() - assert ( - captured.out - == "warning: .install-source marker present but pipx binary missing — reinstalling\nmode: upgrade\n" - ) - assert captured.err == "" - - -def test_malformed_marker_empty(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.marker_path().parent.mkdir(parents=True) - install_guard.marker_path().write_text("", encoding="utf-8") - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.UNKNOWN, None) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 - captured = capsys.readouterr() - assert ( - "error: installed: unknown (.install-source marker is malformed)\n" - in captured.err - ) - - -def test_malformed_marker_multiline(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.marker_path().parent.mkdir(parents=True) - install_guard.marker_path().write_text("/one\n/two\n", encoding="utf-8") - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.UNKNOWN, None) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 - captured = capsys.readouterr() - assert ( - "error: installed: unknown (.install-source marker is malformed)\n" - in captured.err - ) - - -def test_malformed_marker_not_absolute_path(tmp_path: Path, monkeypatch, capsys): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - install_guard.marker_path().parent.mkdir(parents=True) - install_guard.marker_path().write_text("relative/path\n", encoding="utf-8") - install_guard.pipx_bin_path().parent.mkdir(parents=True) - install_guard.pipx_bin_path().touch() - - assert install_guard.check(curdir) == (State.UNKNOWN, None) - - assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 - captured = capsys.readouterr() - assert ( - "error: installed: unknown (.install-source marker is malformed)\n" - in captured.err - ) - - -def test_write_and_remove_marker(tmp_path: Path, monkeypatch): - _set_home(monkeypatch, tmp_path) - curdir = tmp_path / "repo" - curdir.mkdir() - - assert _run_main(monkeypatch, "write", str(curdir)) == 0 - assert ( - install_guard.marker_path().read_text(encoding="utf-8") - == f"{curdir.resolve()}\n" - ) - - assert _run_main(monkeypatch, "remove") == 0 - assert not install_guard.marker_path().exists() diff --git a/tests/test_monitor_positions.py b/tests/test_monitor_positions.py deleted file mode 100644 index 2fc5342..0000000 --- a/tests/test_monitor_positions.py +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from solstone_linux.monitor_positions import assign_monitor_positions - - -class TestAssignMonitorPositions: - def test_single_monitor(self): - monitors = [{"id": "DP-1", "box": [0, 0, 1920, 1080]}] - result = assign_monitor_positions(monitors) - assert result[0]["position"] == "center" - - def test_two_horizontal(self): - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080]}, - {"id": "DP-2", "box": [1920, 0, 3840, 1080]}, - ] - result = assign_monitor_positions(monitors) - positions = {m["id"]: m["position"] for m in result} - assert positions["DP-1"] == "left" - assert positions["DP-2"] == "right" - - def test_three_horizontal(self): - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080]}, - {"id": "DP-2", "box": [1920, 0, 3840, 1080]}, - {"id": "DP-3", "box": [3840, 0, 5760, 1080]}, - ] - result = assign_monitor_positions(monitors) - positions = {m["id"]: m["position"] for m in result} - assert positions["DP-1"] == "left" - assert positions["DP-2"] == "center" - assert positions["DP-3"] == "right" - - def test_stacked_vertical(self): - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080]}, - {"id": "DP-2", "box": [0, 1080, 1920, 2160]}, - ] - result = assign_monitor_positions(monitors) - positions = {m["id"]: m["position"] for m in result} - assert positions["DP-1"] == "top" - assert positions["DP-2"] == "bottom" - - def test_empty(self): - assert assign_monitor_positions([]) == [] - - def test_offset_monitors_no_phantom_vertical(self): - # Two side-by-side monitors that don't overlap horizontally - # should NOT get vertical labels - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080]}, - {"id": "DP-2", "box": [1920, 200, 3840, 1280]}, - ] - result = assign_monitor_positions(monitors) - positions = {m["id"]: m["position"] for m in result} - assert positions["DP-1"] == "left" - assert positions["DP-2"] == "right" diff --git a/tests/test_observer.py b/tests/test_observer.py deleted file mode 100644 index 6e75091..0000000 --- a/tests/test_observer.py +++ /dev/null @@ -1,575 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for the observer module — segment lifecycle and local cache.""" - -import logging -import threading -import time -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import numpy as np -import pytest -from dbus_fast.constants import RequestNameReply - -from solstone_linux.config import Config -from solstone_linux.observer import MODE_IDLE, Observer, async_run -from solstone_linux.recovery import write_segment_metadata - - -def _fake_async_run_observer(config: Config) -> MagicMock: - observer = MagicMock() - observer.config = config - observer.running = True - observer.setup = AsyncMock(return_value=True) - observer.main_loop = AsyncMock(return_value=None) - observer.audio_recorder = MagicMock() - observer.audio_recorder.fatal_error = None - return observer - - -class TestSegmentMetadata: - """Test .metadata file creation for recovery.""" - - def test_writes_metadata(self, tmp_path: Path): - import json - - seg_dir = tmp_path / "test.incomplete" - seg_dir.mkdir() - write_segment_metadata(seg_dir, 1712160000.0) - - meta_path = seg_dir / ".metadata" - assert meta_path.exists() - - data = json.loads(meta_path.read_text()) - assert data["start_timestamp"] == 1712160000.0 - - -class TestSegmentDirStructure: - """Test that config directories follow the expected structure.""" - - def test_captures_dir_path(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - assert str(config.captures_dir).endswith("captures") - - def test_restore_token_path(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - assert config.restore_token_path == config.config_dir / "restore_token" - assert str(config.restore_token_path).endswith("restore_token") - - -class TestFinalizeSegment: - def test_finalize_segment_clamps_duration_to_interval(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.segment_interval = 5 - observer = Observer(config) - seg_dir = tmp_path / "captures" / "20260101" / "archon" / "120000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "audio.flac").write_bytes(b"audio") - observer.segment_dir = seg_dir - observer.start_at = 100.0 - - with patch("solstone_linux.observer.time.time", return_value=200.0): - segment_key = observer._finalize_segment() - - assert segment_key is not None - assert segment_key.endswith("_5") - - def test_finalize_segment_floor_is_one(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - seg_dir = tmp_path / "captures" / "20260101" / "archon" / "120000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "audio.flac").write_bytes(b"audio") - observer.segment_dir = seg_dir - observer.start_at = 200.0 - - with patch("solstone_linux.observer.time.time", return_value=199.0): - segment_key = observer._finalize_segment() - - assert segment_key is not None - assert segment_key.endswith("_1") - - -class TestAudioCharacterization: - def test_save_audio_segment_muted_writes_split_files(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.audio_recorder = MagicMock() - observer.audio_recorder.create_mono_flac_bytes.side_effect = [ - b"mic-bytes", - b"sys-bytes", - ] - observer.accumulated_audio_buffer = np.array( - [[0.1, 0.2], [0.3, 0.4]], dtype=np.float32 - ) - segment_dir = tmp_path / "segment.incomplete" - segment_dir.mkdir() - - files = observer._save_audio_segment(segment_dir, is_muted=True) - - assert files == ["mic_audio.flac", "sys_audio.flac"] - assert (segment_dir / "mic_audio.flac").read_bytes() == b"mic-bytes" - assert (segment_dir / "sys_audio.flac").read_bytes() == b"sys-bytes" - mic_arg = observer.audio_recorder.create_mono_flac_bytes.call_args_list[0].args[ - 0 - ] - sys_arg = observer.audio_recorder.create_mono_flac_bytes.call_args_list[1].args[ - 0 - ] - np.testing.assert_allclose(mic_arg, np.array([0.1, 0.3], dtype=np.float32)) - np.testing.assert_allclose(sys_arg, np.array([0.2, 0.4], dtype=np.float32)) - - def test_save_audio_segment_unmuted_writes_combined(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.audio_recorder = MagicMock() - observer.audio_recorder.create_flac_bytes.return_value = b"combined-bytes" - observer.accumulated_audio_buffer = np.array( - [[0.1, 0.2], [0.3, 0.4]], dtype=np.float32 - ) - segment_dir = tmp_path / "segment.incomplete" - segment_dir.mkdir() - - files = observer._save_audio_segment(segment_dir, is_muted=False) - - assert files == ["audio.flac"] - assert (segment_dir / "audio.flac").read_bytes() == b"combined-bytes" - arg = observer.audio_recorder.create_flac_bytes.call_args.args[0] - np.testing.assert_allclose(arg, observer.accumulated_audio_buffer) - - def test_compute_rms_mic_left_sys_right(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - mic_only = np.array([[3.0, 0.0], [4.0, 0.0]], dtype=np.float32) - sys_only = np.array([[0.0, 6.0], [0.0, 8.0]], dtype=np.float32) - - assert observer.compute_rms(mic_only) == pytest.approx(np.sqrt(12.5)) - assert observer.compute_rms(sys_only) == pytest.approx(np.sqrt(50.0)) - - def test_emit_status_audio_available_reflects_recorder(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer._client = MagicMock() - observer._client.is_registered = False - observer.threshold_hits = 2 - - observer.emit_status() - status = observer._client.enqueue_status.call_args.args[0] - assert status["audio"]["available"] is True - assert status["audio"]["threshold_hits"] == 2 - assert status["audio"]["will_save"] is False - - observer.audio_recorder._set_audio_available(False) - observer.emit_status() - status = observer._client.enqueue_status.call_args.args[0] - assert status["audio"]["available"] is False - assert status["audio"]["threshold_hits"] == 2 - assert status["audio"]["will_save"] is False - - @pytest.mark.asyncio - async def test_degraded_segment_finalizes_with_video_only(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.current_mode = MODE_IDLE - observer.threshold_hits = 0 - observer.start_at = 100.0 - seg_dir = config.captures_dir / "19700101" / config.stream / "000140.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"video") - observer.segment_dir = seg_dir - - with patch("solstone_linux.observer.time.time", return_value=105.0): - await observer.handle_boundary(MODE_IDLE) - - final_dirs = [ - path - for path in seg_dir.parent.iterdir() - if path.is_dir() and not path.name.endswith(".incomplete") - ] - assert len(final_dirs) == 1 - final_dir = final_dirs[0] - assert final_dir.exists() - assert (final_dir / "screen.webm").read_bytes() == b"video" - assert not (final_dir / "audio.flac").exists() - assert not (final_dir / "mic_audio.flac").exists() - assert not (final_dir / "sys_audio.flac").exists() - - def test_hanging_redetect_does_not_block_tick(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer._client = MagicMock() - observer._client.is_registered = False - entered = threading.Event() - release = threading.Event() - - def blocking_detect(): - entered.set() - release.wait(timeout=2.0) - return None, None - - observer.audio_recorder._set_audio_available(False) - with patch( - "solstone_linux.audio_detect.input_detect", side_effect=blocking_detect - ): - observer.audio_recorder.start_recording() - try: - assert entered.wait(timeout=0.5) - started = time.monotonic() - observer.emit_status() - elapsed = time.monotonic() - started - status = observer._client.enqueue_status.call_args.args[0] - assert elapsed < 0.2 - assert status["audio"]["available"] is False - finally: - release.set() - observer.audio_recorder.stop_recording() - - -class TestPauseResumeState: - def test_observer_init_not_paused(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - - observer = Observer(config) - - assert observer._paused is False - assert observer._pause_until == 0.0 - - def test_pause_state_fields_exist(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - - observer = Observer(config) - - assert hasattr(observer, "_paused") - assert hasattr(observer, "_pause_until") - - def test_pause_refreshes_tray(self, tmp_path: Path): - from unittest.mock import MagicMock - - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer._tray = MagicMock() - - observer.pause(900) - - assert observer._tray.update.called is True - - def test_resume_refreshes_tray(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer._tray = MagicMock() - - observer.resume() - - assert observer._tray.update.called is True - - -class TestStartPaused: - @pytest.mark.asyncio - async def test_start_paused_true_skips_initial_capture(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.start_paused = True - config.chat_bridge_enabled = False - observer = Observer(config) - observer._sync = None - - capture_calls = [] - - async def mock_check_activity(): - return "screencast" - - async def mock_initialize(): - capture_calls.append("initialize") - - async def mock_sleep(_duration): - observer.running = False - - with ( - patch.object(observer, "check_activity_status", mock_check_activity), - patch.object(observer, "initialize_screencast", mock_initialize), - patch.object( - observer, "_start_segment", lambda: capture_calls.append("segment") - ), - patch.object(observer, "emit_status"), - patch.object(observer, "_refresh_tray"), - patch.object(observer, "shutdown", AsyncMock()), - patch("solstone_linux.observer.asyncio.sleep", mock_sleep), - ): - await observer.main_loop() - - assert observer._paused is True - assert capture_calls == [] - - @pytest.mark.asyncio - async def test_start_paused_false_starts_capture(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.start_paused = False - config.chat_bridge_enabled = False - observer = Observer(config) - observer._sync = None - - capture_calls = [] - - async def mock_check_activity(): - return "idle" - - async def mock_sleep(_duration): - observer.running = False - - with ( - patch.object(observer, "check_activity_status", mock_check_activity), - patch.object( - observer, "_start_segment", lambda: capture_calls.append("segment") - ), - patch.object(observer, "emit_status"), - patch.object(observer, "_refresh_tray"), - patch.object(observer, "shutdown", AsyncMock()), - patch("solstone_linux.observer.asyncio.sleep", mock_sleep), - ): - await observer.main_loop() - - assert observer._paused is False - assert "segment" in capture_calls - - -class TestServiceLifecycle: - @pytest.mark.asyncio - async def test_async_run_returns_1_when_main_loop_runtime_error( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - observer = _fake_async_run_observer(config) - observer.main_loop.side_effect = RuntimeError("boom") - - with ( - patch("solstone_linux.session_env.check_session_ready", return_value=None), - patch("solstone_linux.observer.Observer", return_value=observer), - patch( - "solstone_linux.observer.recover_incomplete_segments", return_value=0 - ), - ): - result = await async_run(config) - - assert result == 1 - - @pytest.mark.asyncio - async def test_async_run_returns_1_when_audio_recorder_has_fatal_error( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - observer = _fake_async_run_observer(config) - - async def mark_fatal_error(): - observer.audio_recorder.fatal_error = "Fatal audio format error" - - observer.main_loop.side_effect = mark_fatal_error - - with ( - patch("solstone_linux.session_env.check_session_ready", return_value=None), - patch("solstone_linux.observer.Observer", return_value=observer), - patch( - "solstone_linux.observer.recover_incomplete_segments", return_value=0 - ), - ): - result = await async_run(config) - - assert result == 1 - - @pytest.mark.asyncio - async def test_async_run_returns_0_on_normal_main_loop_return(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = _fake_async_run_observer(config) - - with ( - patch("solstone_linux.session_env.check_session_ready", return_value=None), - patch("solstone_linux.observer.Observer", return_value=observer), - patch( - "solstone_linux.observer.recover_incomplete_segments", return_value=0 - ) as recover_mock, - ): - result = await async_run(config) - - assert result == 0 - recover_mock.assert_called_once_with( - config.captures_dir, config.segment_interval - ) - - @pytest.mark.asyncio - async def test_async_run_returns_0_when_audio_degraded_no_fatal( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - observer = _fake_async_run_observer(config) - observer.audio_recorder.audio_available = False - observer.audio_recorder.fatal_error = None - - with ( - patch("solstone_linux.session_env.check_session_ready", return_value=None), - patch("solstone_linux.observer.Observer", return_value=observer), - patch( - "solstone_linux.observer.recover_incomplete_segments", return_value=0 - ), - ): - result = await async_run(config) - - assert result == 0 - - @pytest.mark.asyncio - async def test_async_run_returns_75_when_session_not_ready(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - - with ( - patch( - "solstone_linux.session_env.check_session_ready", - return_value="missing display", - ), - patch("solstone_linux.observer.Observer") as observer_cls, - patch( - "solstone_linux.observer.recover_incomplete_segments" - ) as recover_mock, - ): - result = await async_run(config) - - assert result == 75 - observer_cls.assert_not_called() - recover_mock.assert_not_called() - - @pytest.mark.asyncio - async def test_async_run_returns_1_and_skips_recovery_when_setup_fails( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - observer = _fake_async_run_observer(config) - observer.setup.return_value = False - - with ( - patch("solstone_linux.session_env.check_session_ready", return_value=None), - patch("solstone_linux.observer.Observer", return_value=observer), - patch( - "solstone_linux.observer.recover_incomplete_segments" - ) as recover_mock, - ): - result = await async_run(config) - - assert result == 1 - recover_mock.assert_not_called() - - @pytest.mark.asyncio - async def test_initial_screencast_failure_runs_shutdown_and_propagates( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - config.chat_bridge_enabled = False - observer = Observer(config) - observer._sync = None - observer.audio_recorder = MagicMock() - observer.screencaster.stop = AsyncMock(return_value=([], [])) - - async def mock_check_activity(): - return "screencast" - - with ( - patch.object(observer, "check_activity_status", mock_check_activity), - patch.object( - observer, - "initialize_screencast", - AsyncMock(side_effect=RuntimeError("portal failed")), - ), - patch("solstone_linux.observer.asyncio.sleep", AsyncMock()), - ): - with pytest.raises(RuntimeError): - await observer.main_loop() - - observer.audio_recorder.stop_recording.assert_called_once() - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "reply", - [RequestNameReply.IN_QUEUE, RequestNameReply.EXISTS], - ) - async def test_setup_returns_false_when_dbus_name_taken( - self, - tmp_path: Path, - caplog, - reply: RequestNameReply, - ): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.audio_recorder = MagicMock() - bus_mock = MagicMock() - bus_mock.request_name = AsyncMock(return_value=reply) - bus_mock.export = MagicMock() - bus_connection = MagicMock() - bus_connection.connect = AsyncMock(return_value=bus_mock) - - with ( - caplog.at_level(logging.ERROR), - patch("solstone_linux.observer.MessageBus", return_value=bus_connection), - patch("solstone_linux.observer.UploadClient") as upload_client_cls, - ): - result = await observer.setup() - - assert result is False - observer.audio_recorder.detect.assert_not_called() - observer.audio_recorder.start_recording.assert_not_called() - upload_client_cls.assert_not_called() - bus_mock.export.assert_not_called() - assert any( - "Another solstone-linux observer is already running" in record.message - for record in caplog.records - ) - - @pytest.mark.asyncio - async def test_setup_starts_recording_when_detect_fails(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.audio_recorder = MagicMock() - observer.audio_recorder.detect.return_value = False - observer.screencaster.connect = AsyncMock(return_value=True) - bus_mock = MagicMock() - bus_mock.request_name = AsyncMock(return_value=RequestNameReply.PRIMARY_OWNER) - bus_connection = MagicMock() - bus_connection.connect = AsyncMock(return_value=bus_mock) - - with ( - patch("solstone_linux.observer.MessageBus", return_value=bus_connection), - patch("solstone_linux.observer.probe_activity_services", AsyncMock()), - patch("solstone_linux.observer.UploadClient"), - patch("solstone_linux.observer.SyncService"), - patch("solstone_linux.tray.TrayApp") as tray_cls, - ): - tray_cls.return_value.start = AsyncMock(return_value=False) - result = await observer.setup() - - assert result is True - observer.audio_recorder.detect.assert_called_once() - observer.audio_recorder.start_recording.assert_called_once() - observer.screencaster.connect.assert_awaited_once() - - @pytest.mark.asyncio - async def test_setup_returns_false_when_screencast_connect_fails( - self, tmp_path: Path - ): - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer.audio_recorder = MagicMock() - observer.audio_recorder.detect.return_value = True - observer.screencaster.connect = AsyncMock(return_value=False) - bus_mock = MagicMock() - bus_mock.request_name = AsyncMock(return_value=RequestNameReply.PRIMARY_OWNER) - bus_connection = MagicMock() - bus_connection.connect = AsyncMock(return_value=bus_mock) - - with ( - patch("solstone_linux.observer.MessageBus", return_value=bus_connection), - patch("solstone_linux.observer.probe_activity_services", AsyncMock()), - patch("solstone_linux.observer.UploadClient") as upload_client_cls, - ): - result = await observer.setup() - - assert result is False - observer.audio_recorder.detect.assert_called_once() - observer.audio_recorder.start_recording.assert_called_once() - observer.screencaster.connect.assert_awaited_once() - upload_client_cls.assert_not_called() diff --git a/tests/test_observer_emits_stream_silent_event.py b/tests/test_observer_emits_stream_silent_event.py deleted file mode 100644 index d49f13a..0000000 --- a/tests/test_observer_emits_stream_silent_event.py +++ /dev/null @@ -1,61 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import time -from pathlib import Path -from unittest.mock import MagicMock - -from solstone_linux.config import Config -from solstone_linux.observer import HOST, PLATFORM, Observer -from solstone_linux.screencast import SilentStream - - -def _silent_stream() -> SilentStream: - return SilentStream( - node_id=42, - connector="HDMI-1", - position="right", - file_path=Path("/x/right_HDMI-1_screen.webm"), - file_bytes=418, - ) - - -def test_emits_with_full_fields(tmp_path: Path): - observer = Observer(Config(base_dir=tmp_path)) - observer._client = MagicMock() - observer.segment_dir = Path("/fake/093014.incomplete") - observer.start_at = time.time() - 120 - - observer._emit_stream_silent(_silent_stream()) - - observer._client.enqueue_stream_silent.assert_called_once() - args, _ = observer._client.enqueue_stream_silent.call_args - kwargs = args[0] - assert kwargs["connector"] == "HDMI-1" - assert kwargs["position"] == "right" - assert kwargs["node_id"] == 42 - assert kwargs["file_bytes"] == 418 - assert kwargs["segment_dir"] == "093014.incomplete" - assert 118 <= kwargs["duration_seconds"] <= 122 - assert kwargs["host"] == HOST - assert kwargs["platform"] == PLATFORM - - -def test_skips_when_client_none(tmp_path: Path): - observer = Observer(Config(base_dir=tmp_path)) - observer._client = None - - observer._emit_stream_silent(_silent_stream()) - - -def test_segment_dir_empty_when_none(tmp_path: Path): - observer = Observer(Config(base_dir=tmp_path)) - observer._client = MagicMock() - observer.segment_dir = None - observer.start_at = time.time() - 10 - - observer._emit_stream_silent(_silent_stream()) - - args, _ = observer._client.enqueue_stream_silent.call_args - kwargs = args[0] - assert kwargs["segment_dir"] == "" diff --git a/tests/test_observer_health_beacon.py b/tests/test_observer_health_beacon.py deleted file mode 100644 index 5ebb6fa..0000000 --- a/tests/test_observer_health_beacon.py +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import time -from unittest.mock import MagicMock - -from solstone_linux import __version__ -from solstone_linux.config import Config -from solstone_linux.observer import MODE_SCREENCAST, Observer -from solstone_linux.screencast import StreamInfo -from solstone_linux.sync import SyncService -from solstone_linux.sync_health import ErrorType -from solstone_linux.upload import STREAM_TYPE - -FIXED_EPOCH = 1_798_888_123.5 -HEALTH_KEYS = { - "name", - "stream_type", - "version", - "uptime", - "last_successful_sync", - "pending_queue_depth", - "recent_error_count", - "last_error_reason", -} -BASE_STATUS_KEYS = { - "mode", - "screencast", - "audio", - "activity", - "host", - "platform", - "paused", -} - - -def _observer(tmp_path, registered: bool = True) -> Observer: - config = Config(base_dir=tmp_path) - observer = Observer(config) - observer._client = MagicMock() - observer._client.is_registered = registered - observer.stream = "desk-host" - observer.start_at_mono = time.monotonic() - 12 - observer._sync = SyncService(config, MagicMock(), now=lambda: FIXED_EPOCH) - return observer - - -def _status_kwargs(observer: Observer) -> dict: - observer.emit_status() - args, _ = observer._client.enqueue_status.call_args - return args[0] - - -def test_registered_first_emit_includes_all_health_fields_top_level(tmp_path): - observer = _observer(tmp_path) - - kwargs = _status_kwargs(observer) - - assert HEALTH_KEYS.issubset(kwargs) - assert "health" not in kwargs - assert kwargs["name"] == "desk-host" - assert kwargs["stream_type"] == STREAM_TYPE - assert kwargs["version"] == __version__ - assert isinstance(kwargs["uptime"], int) - assert kwargs["uptime"] >= 0 - assert kwargs["last_successful_sync"] is None - assert kwargs["pending_queue_depth"] is None - assert kwargs["recent_error_count"] == 0 - assert kwargs["last_error_reason"] is None - - -def test_periodic_reemit_carries_same_health_fields(tmp_path): - observer = _observer(tmp_path) - - first = _status_kwargs(observer) - second = _status_kwargs(observer) - - assert HEALTH_KEYS.issubset(first) - assert HEALTH_KEYS.issubset(second) - - -def test_health_fields_exclude_captured_content_and_extra_health_keys(tmp_path): - observer = _observer(tmp_path) - observer.current_mode = MODE_SCREENCAST - observer.current_streams = [ - StreamInfo( - node_id=42, - position="left", - connector="HDMI-SECRET", - x=0, - y=0, - width=1920, - height=1080, - file_path="/captured/private/window-title-meeting.webm", - ) - ] - observer.threshold_hits = 4 - observer.cached_is_active = True - observer.cached_screen_locked = False - observer.cached_is_muted = True - observer.cached_power_save = False - - kwargs = _status_kwargs(observer) - - assert set(kwargs) - BASE_STATUS_KEYS == HEALTH_KEYS - forbidden = ( - "/captured/private", - "window-title", - "meeting", - "HDMI-SECRET", - "threshold_hits", - "sink_muted", - ) - health_values = [kwargs[key] for key in HEALTH_KEYS] - for value in health_values: - assert not any(token in str(value) for token in forbidden) - - -def test_successful_no_work_sync_reflected_in_health_beacon(tmp_path): - observer = _observer(tmp_path) - observer._sync._commit_pass_result(True) - - kwargs = _status_kwargs(observer) - - assert kwargs["last_successful_sync"] == int(FIXED_EPOCH) - assert kwargs["pending_queue_depth"] == 0 - assert kwargs["recent_error_count"] == 0 - assert kwargs["last_error_reason"] is None - - -def test_status_enqueue_is_nonfatal_for_status_emit(tmp_path): - observer = _observer(tmp_path) - - observer.emit_status() - observer.emit_status() - - assert observer._client.enqueue_status.call_count == 2 - - -def test_unregistered_observer_emits_base_status_without_health_fields(tmp_path): - observer = _observer(tmp_path, registered=False) - - kwargs = _status_kwargs(observer) - - assert BASE_STATUS_KEYS.issubset(kwargs) - assert HEALTH_KEYS.isdisjoint(kwargs) - - -def test_status_includes_paused_state(tmp_path): - observer = _observer(tmp_path) - - assert _status_kwargs(observer)["paused"] is False - - observer._paused = True - - assert _status_kwargs(observer)["paused"] is True - - -def test_failure_count_clamps_and_last_error_reason_is_safe(tmp_path): - config = Config(base_dir=tmp_path) - sync = SyncService(config, MagicMock(), now=lambda: FIXED_EPOCH) - - for _ in range(150): - sync._record_failure(ErrorType.TRANSIENT, 503) - - fields = sync.health_beacon_fields() - assert fields["recent_error_count"] == 99 - assert fields["last_error_reason"] == "transient:503" diff --git a/tests/test_screencast.py b/tests/test_screencast.py deleted file mode 100644 index f97818f..0000000 --- a/tests/test_screencast.py +++ /dev/null @@ -1,837 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for portal screencast stream matching and X11 capture.""" - -import asyncio -import io -import logging -import threading -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from dbus_fast import Variant -from dbus_fast.errors import DBusError - -from solstone_linux import screencast as screencast_module -from solstone_linux.screencast import ( - Screencaster, - X11Screencaster, - _match_streams_to_monitors, -) - - -def _make_signal_message(path: str, results: dict): - msg = MagicMock() - msg.message_type.name = "SIGNAL" - msg.path = path - msg.interface = screencast_module.REQ_IFACE - msg.member = "Response" - msg.body = [0, results] - return msg - - -def _emit_portal_response(bus, token: str, results: dict): - handler = bus.add_message_handler.call_args.args[0] - handler( - _make_signal_message( - screencast_module._make_request_handle(bus, token), results - ) - ) - - -def _make_fake_portal_bus(): - bus = MagicMock() - bus.unique_name = ":1.77" - bus.introspect = AsyncMock(return_value=object()) - bus.add_message_handler = MagicMock() - bus.remove_message_handler = MagicMock() - - screencast_iface = MagicMock() - session_iface = MagicMock() - session_iface.call_close = AsyncMock(return_value=None) - - def get_proxy_object(_service, path, _intro): - obj = MagicMock() - if path == screencast_module.PORTAL_PATH: - obj.get_interface.return_value = screencast_iface - else: - obj.get_interface.return_value = session_iface - return obj - - bus.get_proxy_object.side_effect = get_proxy_object - return bus, screencast_iface, session_iface - - -def _patch_monitor_fallbacks(monkeypatch): - monkeypatch.setattr( - "solstone_linux.activity.get_monitor_geometries", - lambda: [], - ) - monkeypatch.setattr( - "solstone_linux.activity.get_monitor_geometries_kscreen", - AsyncMock(return_value=[]), - ) - - -def _configure_successful_portal_start( - bus, - screencast_iface, - *, - fd: int = 42, - streams=None, -): - if streams is None: - streams = [(10, {})] - - async def create_session(opts): - token = opts["handle_token"].value - _emit_portal_response( - bus, - token, - {"session_handle": Variant("o", "/org/freedesktop/portal/session/fake")}, - ) - - async def select_sources(_session_handle, opts): - token = opts["handle_token"].value - _emit_portal_response(bus, token, {}) - - async def start_session(_session_handle, _parent_window, opts): - token = opts["handle_token"].value - _emit_portal_response(bus, token, {"streams": streams}) - - fd_obj = MagicMock() - fd_obj.take.return_value = fd - screencast_iface.call_create_session = AsyncMock(side_effect=create_session) - screencast_iface.call_select_sources = AsyncMock(side_effect=select_sources) - screencast_iface.call_start = AsyncMock(side_effect=start_session) - screencast_iface.call_open_pipe_wire_remote = AsyncMock(return_value=fd_obj) - return fd_obj - - -def _make_running_process(*, stderr=None): - process = MagicMock() - process.poll.return_value = None - process.stderr = stderr - process.send_signal = MagicMock() - process.wait = MagicMock(return_value=0) - process.kill = MagicMock() - return process - - -def test_stderr_drain_consumes_flood_non_utf8_and_caps_lines(caplog): - long_line = b"\xff\xfe" + (b"a" * 70000) - stderr = io.BytesIO(long_line + b"\nshort\n") - drain = screencast_module._StderrDrain(stderr, "t") - - with caplog.at_level(logging.DEBUG, logger="solstone_linux.screencast"): - drain.start() - drain.join() - - assert not drain._thread.is_alive() - assert stderr.read() == b"" - - messages = [ - record.getMessage() - for record in caplog.records - if record.name == "solstone_linux.screencast" - and record.getMessage().startswith("t stderr: ") - ] - assert messages - for message in messages: - body = message.split(": ", 1)[1] - assert len(body) <= screencast_module.STDERR_DRAIN_LINE_CAP + 1 - - -class TestMatchStreamsToMonitors: - """Test matching portal streams to monitor metadata.""" - - def test_position_based_matching(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (1920, 0), "size": (2560, 1440)}, - }, - ] - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, - {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, - ] - - result = _match_streams_to_monitors(streams, monitors) - - assert result[0]["connector"] == "DP-1" - assert result[0]["position_label"] == "left" - assert result[0]["x"] == 0 - assert result[0]["y"] == 0 - assert result[0]["width"] == 1920 - assert result[0]["height"] == 1080 - assert result[1]["connector"] == "DP-2" - assert result[1]["position_label"] == "right" - assert result[1]["x"] == 1920 - assert result[1]["y"] == 0 - assert result[1]["width"] == 2560 - assert result[1]["height"] == 1440 - - def test_size_based_fallback_when_no_position(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (0, 0), "size": (2560, 1440)}, - }, - ] - monitors = [ - {"id": "DP-1", "box": [20, 0, 1940, 1080], "position": "left"}, - {"id": "DP-2", "box": [1940, 0, 4500, 1440], "position": "right"}, - ] - - result = _match_streams_to_monitors(streams, monitors) - - assert result[0]["connector"] == "DP-1" - assert result[0]["position_label"] == "left" - assert result[0]["x"] == 20 - assert result[0]["width"] == 1920 - assert result[1]["connector"] == "DP-2" - assert result[1]["position_label"] == "right" - assert result[1]["x"] == 1940 - assert result[1]["width"] == 2560 - - def test_position_match_skipped_when_all_zero(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (2560, 1440)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - ] - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, - {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, - ] - - result = _match_streams_to_monitors(streams, monitors) - - assert result[0]["connector"] == "DP-2" - assert result[0]["position_label"] == "right" - assert result[0]["x"] == 1920 - assert result[0]["width"] == 2560 - assert result[1]["connector"] == "DP-1" - assert result[1]["position_label"] == "left" - assert result[1]["x"] == 0 - assert result[1]["width"] == 1920 - - def test_ambiguous_size_assigns_in_order(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - ] - monitors = [ - {"id": "DP-1", "box": [20, 0, 1940, 1080], "position": "left"}, - {"id": "DP-2", "box": [1940, 0, 3860, 1080], "position": "right"}, - ] - - result = _match_streams_to_monitors(streams, monitors) - - assert result[0]["connector"] == "DP-1" - assert result[1]["connector"] == "DP-2" - - def test_no_monitors_falls_back_to_monitor_idx(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (1920, 0), "size": (2560, 1440)}, - }, - ] - - result = _match_streams_to_monitors(streams, []) - - assert result[0]["connector"] == "monitor-0" - assert result[0]["position_label"] == "unknown" - assert result[1]["connector"] == "monitor-1" - assert result[1]["position_label"] == "unknown" - - def test_mixed_position_and_size_matching(self): - streams = [ - { - "idx": 0, - "node_id": 10, - "props": {"position": (0, 0), "size": (1920, 1080)}, - }, - { - "idx": 1, - "node_id": 11, - "props": {"position": (0, 0), "size": (2560, 1440)}, - }, - ] - monitors = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, - {"id": "DP-2", "box": [1920, 0, 4480, 1440], "position": "right"}, - ] - - result = _match_streams_to_monitors(streams, monitors) - - assert result[0]["connector"] == "DP-1" - assert result[0]["position_label"] == "left" - assert result[1]["connector"] == "DP-2" - assert result[1]["position_label"] == "right" - - -@pytest.mark.asyncio -async def test_close_session_call_close_failure_logs_and_clears_handle(caplog): - screencaster = Screencaster(restore_token_path=Path("/tmp/fake")) - mock_bus = MagicMock() - session_iface = MagicMock() - session_iface.call_close = AsyncMock( - side_effect=DBusError("org.freedesktop.DBus.Error.NoReply", "broke") - ) - - mock_bus.introspect = AsyncMock(return_value=object()) - mock_bus.get_proxy_object.return_value.get_interface.return_value = session_iface - screencaster.bus = mock_bus - screencaster.session_handle = "/org/freedesktop/portal/desktop/session/fake" - - with caplog.at_level(logging.WARNING): - await screencaster._close_session() - - assert [record.message for record in caplog.records] == [ - "_close_session failed: " - "service=org.freedesktop.portal.Desktop " - "path=/org/freedesktop/portal/desktop/session/fake: " - "DBusError: broke" - ] - assert screencaster.session_handle is None - - -@pytest.mark.asyncio -async def test_start_times_out_unresolved_response_and_removes_handler( - monkeypatch, tmp_path -): - monkeypatch.setattr(screencast_module, "PORTAL_CALL_TIMEOUT", 0.01) - monkeypatch.setattr(screencast_module, "PORTAL_INTERACTIVE_TIMEOUT", 0.01) - _patch_monitor_fallbacks(monkeypatch) - bus, screencast_iface, session_iface = _make_fake_portal_bus() - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - async def create_session(opts): - token = opts["handle_token"].value - _emit_portal_response( - bus, - token, - {"session_handle": Variant("o", "/org/freedesktop/portal/session/fake")}, - ) - - screencast_iface.call_create_session = AsyncMock(side_effect=create_session) - screencast_iface.call_select_sources = AsyncMock(return_value=None) - - with pytest.raises(RuntimeError, match="SelectSources timed out"): - await screencaster.start(str(tmp_path)) - - session_iface.call_close.assert_awaited_once() - assert bus.add_message_handler.call_count == bus.remove_message_handler.call_count - - -@pytest.mark.asyncio -async def test_start_times_out_method_call_and_removes_handler(monkeypatch, tmp_path): - monkeypatch.setattr(screencast_module, "PORTAL_CALL_TIMEOUT", 0.01) - monkeypatch.setattr(screencast_module, "PORTAL_INTERACTIVE_TIMEOUT", 0.01) - _patch_monitor_fallbacks(monkeypatch) - bus, screencast_iface, session_iface = _make_fake_portal_bus() - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - async def create_session(opts): - token = opts["handle_token"].value - _emit_portal_response( - bus, - token, - {"session_handle": Variant("o", "/org/freedesktop/portal/session/fake")}, - ) - - async def hang_forever(*_args): - await asyncio.Future() - - screencast_iface.call_create_session = AsyncMock(side_effect=create_session) - screencast_iface.call_select_sources = AsyncMock(side_effect=hang_forever) - - with pytest.raises(RuntimeError, match="SelectSources timed out"): - await screencaster.start(str(tmp_path)) - - session_iface.call_close.assert_awaited_once() - assert bus.add_message_handler.call_count == bus.remove_message_handler.call_count - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "restore_token,expected_response_timeout", - [ - (None, 22), - ("saved-token", 11), - ], -) -async def test_start_selects_response_timeout_from_restore_token( - monkeypatch, tmp_path, restore_token, expected_response_timeout -): - monkeypatch.setattr(screencast_module, "PORTAL_CALL_TIMEOUT", 11) - monkeypatch.setattr(screencast_module, "PORTAL_INTERACTIVE_TIMEOUT", 22) - _patch_monitor_fallbacks(monkeypatch) - if restore_token: - (tmp_path / "token").write_text(f"{restore_token}\n", encoding="utf-8") - - timeouts = [] - real_wait_for = asyncio.wait_for - - async def recording_wait_for(awaitable, timeout): - timeouts.append(timeout) - return await real_wait_for(awaitable, timeout) - - monkeypatch.setattr(screencast_module.asyncio, "wait_for", recording_wait_for) - - bus, screencast_iface, _session_iface = _make_fake_portal_bus() - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - async def create_session(opts): - token = opts["handle_token"].value - _emit_portal_response( - bus, - token, - {"session_handle": Variant("o", "/org/freedesktop/portal/session/fake")}, - ) - - async def select_sources(_session_handle, _opts): - token = _opts["handle_token"].value - _emit_portal_response(bus, token, {}) - - async def start_session(_session_handle, _parent_window, opts): - token = opts["handle_token"].value - _emit_portal_response(bus, token, {"streams": [(10, {})]}) - - fd_obj = MagicMock() - fd_obj.take.return_value = 42 - process = MagicMock() - process.poll.return_value = None - process.stderr = None - screencast_iface.call_create_session = AsyncMock(side_effect=create_session) - screencast_iface.call_select_sources = AsyncMock(side_effect=select_sources) - screencast_iface.call_start = AsyncMock(side_effect=start_session) - screencast_iface.call_open_pipe_wire_remote = AsyncMock(return_value=fd_obj) - - with patch("solstone_linux.screencast.subprocess.Popen", return_value=process): - streams = await screencaster.start(str(tmp_path)) - - screencaster.pw_fd = None - assert len(streams) == 1 - assert timeouts[4] == expected_response_timeout - assert timeouts[6] == expected_response_timeout - - -@pytest.mark.asyncio -async def test_wayland_immediate_exit_decodes_stderr_and_closes_fd( - monkeypatch, tmp_path -): - _patch_monitor_fallbacks(monkeypatch) - monkeypatch.setattr(screencast_module.asyncio, "sleep", AsyncMock()) - close_calls = [] - monkeypatch.setattr(screencast_module.os, "close", close_calls.append) - - bus, screencast_iface, session_iface = _make_fake_portal_bus() - _configure_successful_portal_start(bus, screencast_iface, fd=4242) - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - process = MagicMock() - process.poll.return_value = 1 - process.stderr = io.BytesIO(b"\xfffatal gst error\n") - - with patch("solstone_linux.screencast.subprocess.Popen", return_value=process): - with pytest.raises(RuntimeError, match="GStreamer exited immediately") as exc: - await screencaster.start(str(tmp_path)) - - assert "fatal gst error" in str(exc.value) - assert close_calls == [4242] - assert screencaster.pw_fd is None - assert screencaster._stderr_drain is None - session_iface.call_close.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_wayland_command_keeps_spaced_location_as_one_token( - monkeypatch, tmp_path -): - _patch_monitor_fallbacks(monkeypatch) - monkeypatch.setattr(screencast_module.asyncio, "sleep", AsyncMock()) - output_dir = tmp_path / "dir with space" - output_dir.mkdir() - captured_cmd = [] - - bus, screencast_iface, _session_iface = _make_fake_portal_bus() - _configure_successful_portal_start(bus, screencast_iface) - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - def fake_popen(cmd, **kwargs): - captured_cmd.extend(cmd) - return _make_running_process(stderr=None) - - with patch("solstone_linux.screencast.subprocess.Popen", side_effect=fake_popen): - await screencaster.start(str(output_dir)) - - expected_file_path = str(output_dir / "unknown_monitor-0_screen.webm") - assert " " in expected_file_path - assert f"location={expected_file_path}" in captured_cmd - assert "with" not in captured_cmd - assert "space" not in captured_cmd - assert not any( - token.startswith(f"space{screencast_module.os.sep}") for token in captured_cmd - ) - screencaster.pw_fd = None - - -@pytest.mark.asyncio -async def test_wayland_closes_pw_fd_on_spawn_failure_once(monkeypatch, tmp_path): - _patch_monitor_fallbacks(monkeypatch) - close_calls = [] - monkeypatch.setattr(screencast_module.os, "close", close_calls.append) - - bus, screencast_iface, session_iface = _make_fake_portal_bus() - _configure_successful_portal_start(bus, screencast_iface, fd=4242) - screencaster = Screencaster(restore_token_path=tmp_path / "token") - screencaster.bus = bus - - with patch( - "solstone_linux.screencast.subprocess.Popen", - side_effect=FileNotFoundError, - ): - with pytest.raises(RuntimeError, match="gst-launch-1.0 not found"): - await screencaster.start(str(tmp_path)) - - assert close_calls == [4242] - assert screencaster.pw_fd is None - session_iface.call_close.assert_awaited_once() - - await screencaster.stop() - - assert close_calls.count(4242) == 1 - - -class TestX11Screencaster: - """Tests for the X11 ximagesrc-based screencaster.""" - - TWO_MONITORS = [ - {"id": "DP-1", "box": [0, 0, 1920, 1080], "position": "left"}, - {"id": "DP-2", "box": [1920, 0, 3840, 1080], "position": "right"}, - ] - - @pytest.mark.asyncio - async def test_connect_fails_without_display(self, monkeypatch): - monkeypatch.delenv("DISPLAY", raising=False) - sc = X11Screencaster() - - result = await sc.connect() - - assert result is False - - @pytest.mark.asyncio - async def test_connect_fails_without_gst_launch(self, monkeypatch): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr("solstone_linux.screencast.shutil.which", lambda _: None) - sc = X11Screencaster() - - result = await sc.connect() - - assert result is False - - @pytest.mark.asyncio - async def test_connect_succeeds(self, monkeypatch): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr( - "solstone_linux.screencast.shutil.which", - lambda _: "/usr/bin/gst-launch-1.0", - ) - sc = X11Screencaster() - - result = await sc.connect() - - assert result is True - - @pytest.mark.asyncio - async def test_start_no_monitors_raises(self, monkeypatch, tmp_path): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr( - "solstone_linux.screencast.X11Screencaster.connect", - AsyncMock(return_value=True), - ) - with patch("solstone_linux.screencast.X11Screencaster.start") as mock_start: - mock_start.side_effect = RuntimeError("No monitors found for X11 capture") - sc = X11Screencaster() - with pytest.raises(RuntimeError, match="No monitors"): - await sc.start(str(tmp_path)) - - @pytest.mark.asyncio - async def test_start_builds_one_branch_per_monitor(self, monkeypatch, tmp_path): - monkeypatch.setenv("DISPLAY", ":0") - - with patch("solstone_linux.screencast.X11Screencaster") as MockClass: - instance = MagicMock() - left = MagicMock() - left.position = "left" - left.connector = "DP-1" - left.file_path = str(tmp_path / "left_DP-1_screen.webm") - right = MagicMock() - right.position = "right" - right.connector = "DP-2" - right.file_path = str(tmp_path / "right_DP-2_screen.webm") - instance.start = AsyncMock(return_value=[left, right]) - MockClass.return_value = instance - - sc = MockClass() - streams = await sc.start(str(tmp_path)) - - assert len(streams) == 2 - - @pytest.mark.asyncio - async def test_start_sets_correct_ximagesrc_region(self, monkeypatch, tmp_path): - """Verify pipeline strings use inclusive endx/endy (startx+width-1).""" - monkeypatch.setenv("DISPLAY", ":0") - - captured_cmd = [] - - def fake_popen(cmd, **kwargs): - captured_cmd.extend(cmd) - proc = MagicMock() - proc.poll.return_value = None - proc.stderr = None - return proc - - with patch( - "solstone_linux.screencast.subprocess.Popen", side_effect=fake_popen - ): - with patch( - "solstone_linux.screencast.X11Screencaster.connect", - new=AsyncMock(return_value=True), - ): - with patch( - "solstone_linux.activity.get_monitor_geometries_x11", - return_value=self.TWO_MONITORS, - ): - sc = X11Screencaster() - sc._started = False - # Manually call the real start to inspect the pipeline - - with patch("asyncio.sleep", new=AsyncMock()): - streams = await sc.start( - str(tmp_path), framerate=1, draw_cursor=False - ) - - pipeline = " ".join(captured_cmd) - # DP-1: 1920x1080 at (0,0) → endx=1919, endy=1079 - assert "startx=0" in pipeline - assert "starty=0" in pipeline - assert "endx=1919" in pipeline - assert "endy=1079" in pipeline - # DP-2: 1920x1080 at (1920,0) → endx=3839, endy=1079 - assert "startx=1920" in pipeline - assert "endx=3839" in pipeline - assert "show-pointer=false" in pipeline - assert len(streams) == 2 - - @pytest.mark.asyncio - async def test_immediate_exit_decodes_non_utf8_stderr(self, monkeypatch, tmp_path): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(screencast_module.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr( - "solstone_linux.activity.get_monitor_geometries_x11", - lambda: self.TWO_MONITORS, - ) - - process = MagicMock() - process.poll.return_value = 1 - process.stderr = io.BytesIO(b"\xfffatal gst error\n") - - with patch("solstone_linux.screencast.subprocess.Popen", return_value=process): - sc = X11Screencaster() - with pytest.raises( - RuntimeError, match="GStreamer \\(X11\\) exited immediately" - ) as exc: - await sc.start(str(tmp_path)) - - assert "fatal gst error" in str(exc.value) - assert sc._stderr_drain is None - - @pytest.mark.asyncio - async def test_command_keeps_spaced_location_as_one_token( - self, monkeypatch, tmp_path - ): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(screencast_module.asyncio, "sleep", AsyncMock()) - output_dir = tmp_path / "dir with space" - output_dir.mkdir() - captured_cmd = [] - - monkeypatch.setattr( - "solstone_linux.activity.get_monitor_geometries_x11", - lambda: self.TWO_MONITORS, - ) - - def fake_popen(cmd, **kwargs): - captured_cmd.extend(cmd) - return _make_running_process(stderr=None) - - with patch( - "solstone_linux.screencast.subprocess.Popen", side_effect=fake_popen - ): - sc = X11Screencaster() - await sc.start(str(output_dir)) - - expected_file_path = str(output_dir / "left_DP-1_screen.webm") - assert " " in expected_file_path - assert f"location={expected_file_path}" in captured_cmd - assert "with" not in captured_cmd - assert "space" not in captured_cmd - assert not any( - token.startswith(f"space{screencast_module.os.sep}") - for token in captured_cmd - ) - - @pytest.mark.asyncio - async def test_stderr_drain_threads_join_after_stop(self, monkeypatch, tmp_path): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(screencast_module.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr( - "solstone_linux.activity.get_monitor_geometries_x11", - lambda: self.TWO_MONITORS, - ) - - before = threading.active_count() - - for idx in range(3): - process = MagicMock() - process.poll.side_effect = [None, 1] - process.stderr = io.BytesIO(f"cycle {idx}\n".encode("utf-8")) - process.send_signal = MagicMock() - process.wait = MagicMock(return_value=0) - process.kill = MagicMock() - - with patch( - "solstone_linux.screencast.subprocess.Popen", return_value=process - ): - sc = X11Screencaster() - await sc.start(str(tmp_path / f"cycle-{idx}")) - await sc.stop() - - assert threading.active_count() == before - - @pytest.mark.asyncio - async def test_stop_filters_silent_streams(self, tmp_path): - """Small files are classified as silent and deleted.""" - sc = X11Screencaster() - sc._started = True - - webm_file = tmp_path / "left_DP-1_screen.webm" - webm_file.write_bytes(b"small") # < MIN_HEALTHY_WEBM_BYTES - - from solstone_linux.screencast import StreamInfo - - sc.streams = [ - StreamInfo( - node_id=0, - position="left", - connector="DP-1", - x=0, - y=0, - width=1920, - height=1080, - file_path=str(webm_file), - ) - ] - sc.gst_process = None - - healthy, silent = await sc.stop() - - assert healthy == [] - assert len(silent) == 1 - assert silent[0].connector == "DP-1" - assert not webm_file.exists() - - @pytest.mark.asyncio - async def test_stop_keeps_healthy_streams(self, tmp_path): - """Files >= MIN_HEALTHY_WEBM_BYTES are returned as healthy.""" - sc = X11Screencaster() - sc._started = True - - from solstone_linux.screencast import MIN_HEALTHY_WEBM_BYTES, StreamInfo - - webm_file = tmp_path / "left_DP-1_screen.webm" - webm_file.write_bytes(b"x" * MIN_HEALTHY_WEBM_BYTES) - - sc.streams = [ - StreamInfo( - node_id=0, - position="left", - connector="DP-1", - x=0, - y=0, - width=1920, - height=1080, - file_path=str(webm_file), - ) - ] - sc.gst_process = None - - healthy, silent = await sc.stop() - - assert len(healthy) == 1 - assert silent == [] - - def test_is_healthy_false_before_start(self): - sc = X11Screencaster() - assert sc.is_healthy() is False - - def test_is_healthy_false_when_process_exited(self): - sc = X11Screencaster() - sc._started = True - proc = MagicMock() - proc.poll.return_value = 1 # exited - sc.gst_process = proc - assert sc.is_healthy() is False - - def test_is_healthy_true_when_running(self): - sc = X11Screencaster() - sc._started = True - proc = MagicMock() - proc.poll.return_value = None # still running - sc.gst_process = proc - assert sc.is_healthy() is True diff --git a/tests/test_screencast_stop_filters_silent_streams.py b/tests/test_screencast_stop_filters_silent_streams.py deleted file mode 100644 index 7c4b078..0000000 --- a/tests/test_screencast_stop_filters_silent_streams.py +++ /dev/null @@ -1,123 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import logging -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from solstone_linux.screencast import Screencaster, SilentStream, StreamInfo - - -def _stream( - file_path: Path, - *, - node_id: int = 42, - connector: str = "HDMI-1", - position: str = "right", -) -> StreamInfo: - return StreamInfo( - node_id=node_id, - position=position, - connector=connector, - x=0, - y=0, - width=1920, - height=1080, - file_path=str(file_path), - ) - - -def _caster(tmp_path: Path, streams: list[StreamInfo]) -> Screencaster: - caster = Screencaster(restore_token_path=tmp_path / "fake") - caster.streams = streams - caster.gst_process = MagicMock() - caster.gst_process.poll = MagicMock(return_value=None) - caster.gst_process.send_signal = MagicMock() - caster.gst_process.wait = MagicMock(return_value=0) - caster.gst_process.kill = MagicMock() - caster.pw_fd = None - caster._close_session = AsyncMock() - return caster - - -@pytest.mark.asyncio -async def test_stop_partitions_healthy_and_silent(tmp_path: Path): - healthy_path = tmp_path / "healthy.webm" - silent_path = tmp_path / "silent.webm" - healthy_path.write_bytes(b"h" * 4096) - silent_path.write_bytes(b"s" * 418) - caster = _caster( - tmp_path, - [ - _stream(healthy_path, node_id=10, connector="DP-1", position="left"), - _stream(silent_path, node_id=42, connector="HDMI-1", position="right"), - ], - ) - - healthy_streams, silent_streams = await caster.stop() - - assert len(healthy_streams) == 1 - assert len(silent_streams) == 1 - assert healthy_path.exists() - assert not silent_path.exists() - silent = silent_streams[0] - assert isinstance(silent, SilentStream) - assert silent.file_bytes == 418 - assert silent.connector == "HDMI-1" - assert silent.position == "right" - assert silent.node_id == 42 - assert silent.file_path == silent_path - - -@pytest.mark.asyncio -async def test_stop_treats_missing_file_as_silent(tmp_path: Path): - missing_path = tmp_path / "missing.webm" - caster = _caster(tmp_path, [_stream(missing_path)]) - - healthy_streams, silent_streams = await caster.stop() - - assert healthy_streams == [] - assert len(silent_streams) == 1 - assert silent_streams[0].file_bytes == 0 - assert silent_streams[0].file_path == missing_path - - -@pytest.mark.asyncio -async def test_stop_logs_silent_stream_dropped_prefix(tmp_path: Path, caplog): - silent_path = tmp_path / "silent.webm" - silent_path.write_bytes(b"s" * 418) - caster = _caster(tmp_path, [_stream(silent_path)]) - - caplog.set_level(logging.WARNING) - await caster.stop() - - messages = [record.getMessage() for record in caplog.records] - assert any( - message.startswith("silent stream dropped:") - and "connector=HDMI-1" in message - and "position=right" in message - and "file_bytes=418" in message - and f"path={silent_path}" in message - for message in messages - ) - - -@pytest.mark.asyncio -async def test_stop_handles_unlink_oserror(tmp_path: Path, caplog, monkeypatch): - silent_path = tmp_path / "silent.webm" - silent_path.write_bytes(b"s" * 418) - caster = _caster(tmp_path, [_stream(silent_path)]) - - def raise_oserror(self, missing_ok=False): - raise OSError("disk error") - - monkeypatch.setattr(Path, "unlink", raise_oserror) - caplog.set_level(logging.WARNING) - - healthy_streams, silent_streams = await caster.stop() - - assert healthy_streams == [] - assert len(silent_streams) == 1 - assert any("could not unlink" in record.getMessage() for record in caplog.records) diff --git a/tests/test_session_env.py b/tests/test_session_env.py deleted file mode 100644 index 80111fa..0000000 --- a/tests/test_session_env.py +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for session environment checks.""" - -import os -from unittest.mock import patch - -from solstone_linux.session_env import check_session_ready - - -class TestCheckSessionReady: - """Test desktop session readiness checks.""" - - def test_no_display_server(self): - env = { - k: v - for k, v in os.environ.items() - if k not in ("DISPLAY", "WAYLAND_DISPLAY") - } - with patch.dict(os.environ, env, clear=True): - with patch("solstone_linux.session_env._recover_session_env"): - result = check_session_ready() - assert result is not None - assert "display server" in result - - def test_no_dbus(self): - env = {k: v for k, v in os.environ.items() if k != "DBUS_SESSION_BUS_ADDRESS"} - env["DISPLAY"] = ":0" - with patch.dict(os.environ, env, clear=True): - with patch("solstone_linux.session_env._recover_session_env"): - result = check_session_ready() - assert result is not None - assert "DBus" in result - - def test_ready_with_display_and_dbus(self): - env = dict(os.environ) - env["DISPLAY"] = ":0" - env["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=/run/user/1000/bus" - with patch.dict(os.environ, env, clear=True): - with patch("solstone_linux.session_env._recover_session_env"): - with patch("solstone_linux.session_env.shutil") as mock_shutil: - mock_shutil.which.return_value = None # No pactl - result = check_session_ready() - assert result is None # Ready diff --git a/tests/test_streams.py b/tests/test_streams.py deleted file mode 100644 index cae93fe..0000000 --- a/tests/test_streams.py +++ /dev/null @@ -1,46 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import pytest - -from solstone_linux.streams import _strip_hostname, stream_name - - -class TestStripHostname: - def test_simple(self): - assert _strip_hostname("archon") == "archon" - - def test_with_domain(self): - assert _strip_hostname("ja1r.local") == "ja1r" - - def test_ip_address(self): - assert _strip_hostname("192.168.1.1") == "192-168-1-1" - - def test_fqdn(self): - assert _strip_hostname("my.host.example.com") == "my" - - def test_empty(self): - assert _strip_hostname("") == "" - - -class TestStreamName: - def test_host_only(self): - assert stream_name(host="archon") == "archon" - - def test_host_with_qualifier(self): - assert stream_name(host="archon", qualifier="tmux") == "archon.tmux" - - def test_host_no_qualifier(self): - # Linux observer uses host without qualifier - assert stream_name(host="archon") == "archon" - - def test_observer(self): - assert stream_name(observer="desktop") == "desktop" - - def test_rejects_empty(self): - with pytest.raises(ValueError): - stream_name() - - def test_rejects_invalid_chars(self): - with pytest.raises(ValueError): - stream_name(host="!invalid") diff --git a/tests/test_sync.py b/tests/test_sync.py deleted file mode 100644 index 611d9a9..0000000 --- a/tests/test_sync.py +++ /dev/null @@ -1,1768 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import asyncio -import hashlib -import json -import os -import time -from datetime import datetime -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import numpy as np -import pytest -import soundfile as sf - -from solstone_linux.config import Config -from solstone_linux.recovery import ( - _mark_failed, - _recover_segment, - recover_incomplete_segments, -) -from solstone_linux.sync import ( - CIRCUIT_COOLDOWN_INITIAL, - CIRCUIT_COOLDOWN_MAX, - SyncService, - _segment_proven_held, -) -from solstone_linux.sync_health import ( - ErrorType, - HealthState, - SyncFacts, - load_facts, - save_facts, -) -from solstone_linux.upload import ( - MAX_IMMEDIATE_ATTEMPTS, - QueryResult, - UploadClient, - UploadResult, -) - - -def _sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _server_file( - path: Path, - *, - status: str = "present", - name: str | None = None, - submitted_name: str | None = None, - sha256: str | None = None, -) -> dict: - record = { - "name": name or path.name, - "size": path.stat().st_size, - "sha256": sha256 or _sha256(path), - "status": status, - } - if submitted_name is not None: - record["submitted_name"] = submitted_name - return record - - -def _server_item( - key: str, - segment_dir: Path, - *, - original_key: str | None = None, - files: list[dict] | None = None, - status: str = "present", -) -> dict: - item = { - "key": key, - "observed": False, - "files": files - if files is not None - else [ - _server_file(path, status=status) - for path in segment_dir.iterdir() - if path.is_file() and not path.name.startswith(".") - ], - } - if original_key is not None: - item["original_key"] = original_key - return item - - -class TestRecovery: - """Test crash recovery for incomplete segments.""" - - def _make_incomplete( - self, - captures_dir: Path, - day: str, - stream: str, - time_prefix: str, - age: int = 300, - ) -> Path: - """Create an incomplete segment directory with a dummy file.""" - seg_dir = captures_dir / day / stream / f"{time_prefix}.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "center_DP-3_screen.webm").write_bytes(b"\x00" * 100) - - # Set timestamps to simulate age - old_time = time.time() - age - os.utime(seg_dir, (old_time, old_time)) - return seg_dir - - def test_recovers_old_incomplete(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - self._make_incomplete(captures_dir, "20260403", "archon", "140000", age=300) - - recovered = recover_incomplete_segments(captures_dir) - assert recovered == 1 - - stream_dir = captures_dir / "20260403" / "archon" - dirs = [d.name for d in stream_dir.iterdir() if d.is_dir()] - assert len(dirs) == 1 - assert dirs[0].startswith("140000_") - assert not dirs[0].endswith(".incomplete") - - def test_recovers_with_metadata(self, tmp_path: Path): - """Recovery uses .metadata start_timestamp for accurate duration.""" - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "center_DP-3_screen.webm").write_bytes(b"\x00" * 100) - - # Write metadata with known start timestamp (60 seconds ago) - start_ts = time.time() - 60 - meta = {"start_timestamp": start_ts} - (seg_dir / ".metadata").write_text(json.dumps(meta)) - - # Age the directory - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recovered = recover_incomplete_segments(captures_dir) - assert recovered == 1 - - stream_dir = captures_dir / "20260403" / "archon" - dirs = [d.name for d in stream_dir.iterdir() if d.is_dir()] - assert len(dirs) == 1 - # Duration should be based on metadata start timestamp, not mtime-ctime - duration = int(dirs[0].split("_")[1]) - assert 55 <= duration <= 65 # ~60 seconds - - def test_recovery_metadata_duration_clamps_to_window_ceiling(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - (seg_dir / ".metadata").write_text( - json.dumps({"start_timestamp": time.time() - 1000}) - ) - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recovered = recover_incomplete_segments(captures_dir, window_ceiling=60) - - assert recovered == 1 - assert (captures_dir / "20260403" / "archon" / "140000_60").exists() - - def test_recovery_filesystem_fallback_duration_clamps_to_window_ceiling( - self, tmp_path: Path - ): - seg_dir = tmp_path / "captures" / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - original_stat = Path.stat - - def fake_stat(path: Path, *args, **kwargs): - if path == seg_dir: - return SimpleNamespace(st_mtime=1000, st_ctime=0) - return original_stat(path, *args, **kwargs) - - with patch.object(Path, "stat", fake_stat): - assert _recover_segment(seg_dir, window_ceiling=60) is True - - assert (tmp_path / "captures" / "20260403" / "archon" / "140000_60").exists() - - def test_recovery_bounds_duration_by_readable_flac(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - frames = np.zeros(4 * 16000, dtype=np.float32) - sf.write(seg_dir / "audio.flac", frames, 16000, format="FLAC") - (seg_dir / ".metadata").write_text( - json.dumps({"start_timestamp": time.time() - 1000}) - ) - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recovered = recover_incomplete_segments(captures_dir, window_ceiling=300) - - assert recovered == 1 - assert (captures_dir / "20260403" / "archon" / "140000_4").exists() - - def test_recovery_webm_only_uses_ceiling_clamped_elapsed(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - (seg_dir / ".metadata").write_text( - json.dumps({"start_timestamp": time.time() - 1000}) - ) - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recovered = recover_incomplete_segments(captures_dir, window_ceiling=60) - - assert recovered == 1 - assert (captures_dir / "20260403" / "archon" / "140000_60").exists() - - def test_skips_recent_incomplete(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "test.webm").write_bytes(b"\x00") - - recovered = recover_incomplete_segments(captures_dir) - assert recovered == 0 - assert seg_dir.exists() - - def test_marks_empty_as_failed(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - # No files inside — should fail - - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recovered = recover_incomplete_segments(captures_dir) - assert recovered == 0 - - failed_dir = captures_dir / "20260403" / "archon" / "140000.failed" - assert failed_dir.exists() - - def test_mark_failed_stamps_quarantine_mtime(self, tmp_path: Path): - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - old_time = time.time() - 100 * 86400 - os.utime(seg_dir, (old_time, old_time)) - - before = time.time() - _mark_failed(seg_dir) - after = time.time() - - failed_dir = captures_dir / "20260403" / "archon" / "140000.failed" - assert failed_dir.exists() - assert before - 5 <= failed_dir.stat().st_mtime <= after + 5 - - def test_metadata_removed_on_recovery(self, tmp_path: Path): - """The .metadata file should be removed during recovery.""" - captures_dir = tmp_path / "captures" - seg_dir = captures_dir / "20260403" / "archon" / "140000.incomplete" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"\x00") - (seg_dir / ".metadata").write_text('{"start_timestamp": 1000}') - - old_time = time.time() - 300 - os.utime(seg_dir, (old_time, old_time)) - - recover_incomplete_segments(captures_dir) - - stream_dir = captures_dir / "20260403" / "archon" - for d in stream_dir.iterdir(): - if d.is_dir() and not d.name.endswith((".incomplete", ".failed")): - # .metadata should not be in the recovered dir - assert not (d / ".metadata").exists() - - def test_no_captures_dir(self, tmp_path: Path): - assert recover_incomplete_segments(tmp_path / "nonexistent") == 0 - - -class TestSyncServiceCollect: - """Test segment collection logic.""" - - def test_skips_incomplete_and_failed(self, tmp_path: Path): - from solstone_linux.sync import SyncService - - config = Config(base_dir=tmp_path) - config.ensure_dirs() - - captures = config.captures_dir - stream_dir = captures / "20260403" / "archon" - stream_dir.mkdir(parents=True) - - (stream_dir / "140000_300").mkdir() - (stream_dir / "140000_300" / "screen.webm").write_bytes(b"\x00") - (stream_dir / "145000.incomplete").mkdir() - (stream_dir / "143000.failed").mkdir() - (stream_dir / "150000_300").mkdir() - (stream_dir / "150000_300" / "audio.flac").write_bytes(b"\x00") - - client = UploadClient(config) - sync = SyncService(config, client) - - segments = sync._collect_segments(captures) - assert "20260403" in segments - names = [s.name for s in segments["20260403"]] - assert "140000_300" in names - assert "150000_300" in names - assert "145000.incomplete" not in names - assert "143000.failed" not in names - - -class TestSyncedDaysPruning: - """Test that synced-days cache is pruned to 90 days.""" - - def test_prunes_old_entries(self, tmp_path: Path): - from solstone_linux.sync import SyncService - - config = Config(base_dir=tmp_path) - config.ensure_dirs() - - client = UploadClient(config) - sync = SyncService(config, client) - - # Add entries spanning 100 days - from datetime import datetime, timedelta - - today = datetime.now() - for i in range(100): - day = (today - timedelta(days=i)).strftime("%Y%m%d") - sync._synced_days.add(day) - - sync._prune_synced_days() - - # Should have ~90 entries (not 100) - assert len(sync._synced_days) <= 91 # Allow 1 day tolerance - - -class TestErrorClassification: - """Test HTTP error classification for circuit breaker tuning.""" - - def test_auth_errors(self): - assert UploadClient.classify_error(401) == ErrorType.AUTH - assert UploadClient.classify_error(403) == ErrorType.AUTH - - def test_client_errors(self): - assert UploadClient.classify_error(400) == ErrorType.CLIENT - - def test_incompatible_errors(self): - assert UploadClient.classify_error(404) == ErrorType.INCOMPATIBLE - - def test_transient_errors(self): - assert UploadClient.classify_error(500) == ErrorType.TRANSIENT - assert UploadClient.classify_error(502) == ErrorType.TRANSIENT - assert UploadClient.classify_error(503) == ErrorType.TRANSIENT - - def test_network_errors(self): - assert ( - UploadClient.classify_error(None, is_network_error=True) - == ErrorType.TRANSIENT - ) - - def test_unknown_status(self): - assert UploadClient.classify_error(418) == ErrorType.TRANSIENT - - -class TestCircuitBreakerThresholds: - """Test circuit breaker state transitions with error-type tuning.""" - - def test_auth_opens_immediately(self, tmp_path: Path): - from solstone_linux.sync import SyncService, CIRCUIT_THRESHOLD_AUTH - - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - sync = SyncService(config, client) - - sync._last_error_type = ErrorType.AUTH - assert sync._circuit_threshold() == CIRCUIT_THRESHOLD_AUTH - assert CIRCUIT_THRESHOLD_AUTH == 1 - - def test_transient_allows_more_failures(self, tmp_path: Path): - from solstone_linux.sync import SyncService, CIRCUIT_THRESHOLD_TRANSIENT - - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - sync = SyncService(config, client) - - sync._last_error_type = ErrorType.TRANSIENT - assert sync._circuit_threshold() == CIRCUIT_THRESHOLD_TRANSIENT - assert CIRCUIT_THRESHOLD_TRANSIENT >= 5 - - def test_incompatible_opens_immediately(self, tmp_path: Path): - from solstone_linux.sync import SyncService, CIRCUIT_THRESHOLD_AUTH - - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - sync = SyncService(config, client) - - sync._last_error_type = ErrorType.INCOMPATIBLE - assert sync._circuit_threshold() == CIRCUIT_THRESHOLD_AUTH - - -class TestCircuitBreakerRecovery: - """Test circuit breaker recovery for transient failures.""" - - def _make_sync(self, tmp_path: Path) -> SyncService: - """Create a SyncService with minimal config.""" - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - async def _run_briefly(self, sync: SyncService) -> None: - sync._trigger.set() - task = asyncio.create_task(sync.run()) - await asyncio.sleep(0.01) - sync.stop() - await asyncio.wait_for(task, timeout=1) - - @pytest.mark.asyncio - async def test_transient_circuit_recovers_after_cooldown(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = False - sync._circuit_open_since = time.monotonic() - 31 - sync._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - sync._consecutive_failures = 5 - sync._last_error_type = ErrorType.TRANSIENT - sync._sync = AsyncMock(side_effect=lambda force_full=False: sync.stop()) - sync._trigger.set() - - with patch( - "asyncio.to_thread", new_callable=AsyncMock, return_value=QueryResult([]) - ): - await sync.run() - - assert not sync._circuit_open - assert sync._consecutive_failures == 0 - assert sync._last_error_type is None - assert sync._circuit_cooldown == CIRCUIT_COOLDOWN_INITIAL - sync._sync.assert_awaited_once() - - @pytest.mark.asyncio - async def test_revoked_circuit_never_recovers(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = True - sync._circuit_open_since = time.monotonic() - 600 - sync._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - sync._sync = AsyncMock() - - with patch("asyncio.to_thread", new_callable=AsyncMock) as to_thread: - await self._run_briefly(sync) - - assert sync._circuit_open - assert sync._circuit_open_permanent - to_thread.assert_not_called() - sync._sync.assert_not_awaited() - - @pytest.mark.asyncio - async def test_backoff_increases_on_failed_probe(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = False - sync._circuit_open_since = 70.0 - sync._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - sync._sync = AsyncMock() - before_probe = time.monotonic() - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(None, ErrorType.TRANSIENT), - ): - await self._run_briefly(sync) - - assert sync._circuit_open - assert sync._circuit_cooldown == CIRCUIT_COOLDOWN_INITIAL * 2 - assert sync._circuit_open_since >= before_probe - sync._sync.assert_not_awaited() - - @pytest.mark.asyncio - async def test_full_reset_after_successful_probe(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = False - sync._circuit_open_since = time.monotonic() - 121 - sync._circuit_cooldown = 120 - sync._consecutive_failures = 5 - sync._last_error_type = ErrorType.TRANSIENT - sync._sync = AsyncMock(side_effect=lambda force_full=False: sync.stop()) - sync._trigger.set() - - with patch( - "asyncio.to_thread", new_callable=AsyncMock, return_value=QueryResult([]) - ): - await sync.run() - - assert not sync._circuit_open - assert not sync._circuit_open_permanent - assert sync._circuit_open_since == 0.0 - assert sync._circuit_cooldown == CIRCUIT_COOLDOWN_INITIAL - assert sync._consecutive_failures == 0 - assert sync._last_error_type is None - - @pytest.mark.asyncio - async def test_cooldown_caps_at_max(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = False - sync._circuit_open_since = 0.0 - sync._circuit_cooldown = CIRCUIT_COOLDOWN_MAX - sync._sync = AsyncMock() - before_probe = time.monotonic() - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(None, ErrorType.TRANSIENT), - ): - await self._run_briefly(sync) - - assert sync._circuit_open - assert sync._circuit_cooldown == CIRCUIT_COOLDOWN_MAX - assert sync._circuit_open_since >= before_probe - sync._sync.assert_not_awaited() - - @pytest.mark.asyncio - async def test_skips_probe_before_cooldown_elapses(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._circuit_open = True - sync._circuit_open_permanent = False - sync._circuit_open_since = time.monotonic() - 10 - sync._circuit_cooldown = CIRCUIT_COOLDOWN_INITIAL - sync._sync = AsyncMock() - - with patch("asyncio.to_thread", new_callable=AsyncMock) as to_thread: - await self._run_briefly(sync) - - assert sync._circuit_open - to_thread.assert_not_called() - sync._sync.assert_not_awaited() - - @pytest.mark.asyncio - async def test_query_failures_recover_to_connected(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._client.get_server_segments = MagicMock( - return_value=QueryResult(None, ErrorType.TRANSIENT) - ) - - for _ in range(5): - await sync._sync() - - assert sync._circuit_open - assert sync.health.state == HealthState.OFFLINE - - sync._circuit_open_since = time.monotonic() - 31 - sync._client.get_server_segments = MagicMock( - side_effect=[ - QueryResult([], None, 200), - QueryResult([], None, 200), - ] - ) - - await self._run_briefly(sync) - - assert not sync._circuit_open - assert sync.health.state == HealthState.CONNECTED - - -class TestRetryCapRespected: - """Test that upload bounds immediate attempts while honoring low caps.""" - - def test_high_config_bounded_to_immediate_cap(self, tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 10 - config.sync_retry_delays = [0] - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == MAX_IMMEDIATE_ATTEMPTS - assert result.error_type == ErrorType.TRANSIENT - - def test_low_config_single_attempt(self, tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 1 - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == 1 - assert result.error_type == ErrorType.TRANSIENT - - def test_sync_stop_signals_client_interrupt(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - sync = SyncService(config, client) - - assert client._stop_event.is_set() is False - - sync.stop() - - assert client._stop_event.is_set() is True - assert sync._running is False - - -class TestSyncHealthFacts: - """Test pass-level health fact aggregation.""" - - def _make_sync(self, tmp_path: Path) -> SyncService: - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - return seg_dir - - def test_startup_forces_in_progress_false(self, tmp_path: Path): - config = Config(base_dir=tmp_path) - config.ensure_dirs() - save_facts( - config.state_dir, - SyncFacts(in_progress=True, progress="uploading 120000_300"), - ) - - SyncService(config, UploadClient(config)) - facts = load_facts(config.state_dir) - - assert facts.in_progress is False - assert facts.progress == "" - - @pytest.mark.asyncio - async def test_today_success_and_older_404_is_update_needed(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - older_day = "20260101" - today = datetime.now().strftime("%Y%m%d") - self._create_segment(captures, older_day, "archon", "120000_300") - - def fake_query(day): - if day == today: - return QueryResult([], None, 200) - return QueryResult(None, ErrorType.INCOMPATIBLE, 404) - - sync._client.get_server_segments = MagicMock(side_effect=fake_query) - - await sync._sync() - - assert sync.health.state == HealthState.UPDATE_NEEDED - facts = load_facts(sync._config.state_dir) - assert facts.last_error_class == ErrorType.INCOMPATIBLE - assert facts.last_error_code == 404 - assert facts.pending_confirmed is None - - @pytest.mark.asyncio - async def test_failed_query_clears_prior_pending_zero(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - sync._facts.pending_confirmed = 0 - sync._save_health() - sync._client.get_server_segments = MagicMock( - return_value=QueryResult(None, ErrorType.TRANSIENT) - ) - - await sync._sync() - - assert sync.health.state == HealthState.OFFLINE - assert sync.health.pending_display == "pending unconfirmed" - assert "pending unconfirmed" in sync.health.cli - assert load_facts(sync._config.state_dir).pending_confirmed is None - - @pytest.mark.asyncio - async def test_successful_cleanup_after_clean_pass_keeps_connected( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - older_day = "20260101" - today = datetime.now().strftime("%Y%m%d") - segment = self._create_segment( - sync._config.captures_dir, older_day, "archon", "120000_300" - ) - sync._synced_days.add(older_day) - sync._client.get_server_segments = MagicMock( - side_effect=lambda day: QueryResult( - [_server_item("120000_300", segment)] if day == older_day else [], - None, - 200, - ) - ) - - await sync._sync() - - assert sync._client.get_server_segments.call_count == 2 - sync._client.get_server_segments.assert_any_call(today) - sync._client.get_server_segments.assert_any_call(older_day) - assert sync.health.state == HealthState.CONNECTED - assert load_facts(sync._config.state_dir).pending_confirmed == 0 - - -class TestQuarantineZeroByte: - """Test that segments with all zero-byte files are quarantined before upload.""" - - def _make_sync(self, tmp_path: Path) -> SyncService: - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_zero_byte_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"") - (seg_dir / "audio.flac").write_bytes(b"") - return seg_dir - - @pytest.mark.asyncio - async def test_zero_byte_segment_quarantined(self, tmp_path: Path): - """A segment with all zero-byte files is renamed to .failed before upload.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - fake_now = datetime(2026, 4, 11, 12, 0, 0) - - seg = self._create_zero_byte_segment( - captures, "20260410", "archon", "120000_300" - ) - server_response = [] - - with patch("solstone_linux.sync.datetime", wraps=datetime) as mock_datetime: - mock_datetime.now.return_value = fake_now - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._sync() - - assert not seg.exists() - assert seg.with_name("120000_300.failed").exists() - - @pytest.mark.asyncio - async def test_zero_byte_does_not_trigger_upload(self, tmp_path: Path): - """Zero-byte segments should never call upload_segment.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - self._create_zero_byte_segment(captures, "20260410", "archon", "120000_300") - server_response = [] - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock - ) as mock_upload: - await sync._sync() - mock_upload.assert_not_called() - - @pytest.mark.asyncio - async def test_mixed_files_not_quarantined(self, tmp_path: Path): - """A segment with some zero-byte and some non-zero files is NOT quarantined.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - seg_dir = captures / "20260410" / "archon" / "120000_300" - seg_dir.mkdir(parents=True) - (seg_dir / "screen.webm").write_bytes(b"") - (seg_dir / "audio.flac").write_bytes(b"\x00" * 100) - - server_response = [] - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as mock_upload: - await sync._sync() - mock_upload.assert_called_once() - - @pytest.mark.asyncio - async def test_zero_byte_day_marked_synced(self, tmp_path: Path): - """A past day with only zero-byte segments gets marked synced after quarantine.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - self._create_zero_byte_segment(captures, "20260101", "archon", "120000_300") - server_response = [] - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._sync() - - assert "20260101" in sync._synced_days - - -class TestQuarantineClientError: - """Test that CLIENT errors (HTTP 400) quarantine the segment.""" - - def _make_sync(self, tmp_path: Path) -> SyncService: - config = Config(base_dir=tmp_path) - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - return seg_dir - - @pytest.mark.asyncio - async def test_client_error_quarantines_segment(self, tmp_path: Path): - """HTTP 400 response quarantines the segment to .failed.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - seg = self._create_segment(captures, "20260410", "archon", "120000_300") - server_response = [] - - async def fake_upload(day, segment_dir): - sync._last_error_type = ErrorType.CLIENT - return False - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - with patch.object(sync, "_upload_segment", side_effect=fake_upload): - await sync._sync() - - assert not seg.exists() - assert seg.with_name("120000_300.failed").exists() - - def test_quarantine_segment_stamps_quarantine_mtime(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - seg = self._create_segment(captures, "20260410", "archon", "120000_300") - old_time = time.time() - 100 * 86400 - os.utime(seg, (old_time, old_time)) - - before = time.time() - assert sync._quarantine_segment(seg, "test") is True - after = time.time() - - failed_dir = seg.with_name("120000_300.failed") - assert failed_dir.exists() - assert before - 5 <= failed_dir.stat().st_mtime <= after + 5 - - @pytest.mark.asyncio - async def test_client_error_does_not_trip_circuit(self, tmp_path: Path): - """CLIENT errors should not increment consecutive_failures or open circuit.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - for i in range(10): - self._create_segment(captures, "20260410", "archon", f"12000{i}_300") - - server_response = [] - - async def fake_upload(day, segment_dir): - sync._last_error_type = ErrorType.CLIENT - return False - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - with patch.object(sync, "_upload_segment", side_effect=fake_upload): - await sync._sync() - - assert sync._consecutive_failures == 0 - assert not sync._circuit_open - - @pytest.mark.asyncio - async def test_transient_error_still_trips_circuit(self, tmp_path: Path): - """TRANSIENT errors should still increment failures and trip circuit.""" - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - - for i in range(6): - self._create_segment(captures, "20260410", "archon", f"12000{i}_300") - - server_response = [] - - async def fake_upload(day, segment_dir): - sync._last_error_type = ErrorType.TRANSIENT - return False - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - with patch.object(sync, "_upload_segment", side_effect=fake_upload): - await sync._sync() - - assert sync._circuit_open - assert sync._consecutive_failures >= 5 - - -class TestReconcilePredicate: - def _make_sync(self, tmp_path: Path, retention: int = 7) -> SyncService: - config = Config(base_dir=tmp_path) - config.cache_retention_days = retention - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"screen") - return seg_dir - - def _query_for(self, day: str, items: list[dict], **kwargs): - def fake_query(query_day: str): - if query_day == day: - return QueryResult(items, None, 200, **kwargs) - return QueryResult([], None, 200, **kwargs) - - return fake_query - - def test_present_status_with_mismatched_sha_is_not_proof(self, tmp_path: Path): - segment = self._create_segment( - tmp_path / "captures", "20260101", "archon", "120000_300" - ) - item = _server_item( - "120000_300", - segment, - files=[_server_file(segment / "screen.webm", sha256="0" * 64)], - ) - - assert not _segment_proven_held(segment, item) - - @pytest.mark.asyncio - async def test_present_status_with_mismatched_sha_uploads(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item( - "120000_300", - segment, - files=[_server_file(segment / "screen.webm", sha256="0" * 64)], - ) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - - def test_swapped_sha_by_filename_is_not_proof(self, tmp_path: Path): - segment = self._create_segment( - tmp_path / "captures", "20260101", "archon", "120000_300" - ) - audio = segment / "audio.flac" - audio.write_bytes(b"audio") - item = _server_item( - "120000_300", - segment, - files=[ - _server_file(segment / "screen.webm", sha256=_sha256(audio)), - _server_file(audio, sha256=_sha256(segment / "screen.webm")), - ], - ) - - assert not _segment_proven_held(segment, item) - - @pytest.mark.asyncio - async def test_relocated_status_uploads_and_cleanup_keeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment, status="relocated") - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert not _segment_proven_held(segment, item) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - def test_submitted_name_matches_local_filename(self, tmp_path: Path): - segment = tmp_path / "captures" / "20260101" / "archon" / "120000_300" - segment.mkdir(parents=True) - local_file = segment / "120000_300_audio.flac" - local_file.write_bytes(b"audio") - item = _server_item( - "120000_300", - segment, - files=[ - _server_file( - local_file, - name="audio.flac", - submitted_name="120000_300_audio.flac", - ) - ], - ) - - assert _segment_proven_held(segment, item) - - @pytest.mark.asyncio - async def test_processed_status_sha_match_skips_and_cleanup_deletes( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment, status="processed") - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert _segment_proven_held(segment, item) - - with patch.object(sync, "_upload_segment", new_callable=AsyncMock) as upload: - await sync._sync() - - upload.assert_not_called() - assert not segment.exists() - - @pytest.mark.asyncio - async def test_processed_status_with_mismatched_sha_uploads_and_cleanup_keeps( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item( - "120000_300", - segment, - files=[ - _server_file( - segment / "screen.webm", status="processed", sha256="0" * 64 - ) - ], - ) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert not _segment_proven_held(segment, item) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_processed_status_with_mismatched_name_uploads_and_cleanup_keeps( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item( - "120000_300", - segment, - files=[ - _server_file( - segment / "screen.webm", - name="something-else.webm", - status="processed", - ) - ], - ) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert not _segment_proven_held(segment, item) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_missing_status_uploads_and_cleanup_keeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment, status="missing") - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert not _segment_proven_held(segment, item) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_unknown_status_uploads_and_cleanup_keeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment, status="unknown") - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert not _segment_proven_held(segment, item) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_all_present_sha_match_skips_and_cleanup_deletes( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - with patch.object(sync, "_upload_segment", new_callable=AsyncMock) as upload: - await sync._sync() - - upload.assert_not_called() - assert not segment.exists() - - @pytest.mark.asyncio - async def test_mixed_present_and_processed_files_skip_and_cleanup_deletes( - self, tmp_path: Path - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - audio = segment / "audio.flac" - audio.write_bytes(b"audio") - item = _server_item( - "120000_300", - segment, - files=[ - _server_file(segment / "screen.webm", status="present"), - _server_file(audio, status="processed"), - ], - ) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - - assert _segment_proven_held(segment, item) - - with patch.object(sync, "_upload_segment", new_callable=AsyncMock) as upload: - await sync._sync() - - upload.assert_not_called() - assert not segment.exists() - - @pytest.mark.asyncio - async def test_unreadable_sha_cleanup_keeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment) - sync._synced_days.add(day) - sync._client.get_server_segments = MagicMock( - return_value=QueryResult([item], None, 200) - ) - - with patch("solstone_linux.sync._sha256_file", return_value=None): - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_truncated_envelope_uploads_and_cleanup_keeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - item = _server_item("120000_300", segment) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item], truncated=True) - ) - - with patch.object( - sync, "_upload_segment", new_callable=AsyncMock, return_value=True - ) as upload: - await sync._sync() - - upload.assert_called_once() - sync._synced_days.add(day) - await sync._cleanup_synced_segments() - - assert segment.exists() - - @pytest.mark.asyncio - async def test_legacy_listing_logs_once_and_cleanup_keeps( - self, tmp_path: Path, caplog - ): - sync = self._make_sync(tmp_path) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - failed = self._create_segment( - sync._config.captures_dir, day, "archon", "130000_300.failed" - ) - sync._client.get_server_segments = MagicMock( - return_value=QueryResult([{"key": "120000_300"}], None, 200, legacy=True) - ) - - with ( - caplog.at_level("WARNING", logger="solstone_linux.sync"), - patch.object(sync, "_upload_segment", new_callable=AsyncMock) as upload, - ): - await sync._sync() - - upload.assert_not_called() - assert segment.exists() - assert failed.exists() - degraded = [ - rec for rec in caplog.records if "pre-v2 bare array" in rec.getMessage() - ] - assert len(degraded) == 1 - - @pytest.mark.asyncio - async def test_duplicate_marker_stops_reupload(self, tmp_path: Path): - sync = self._make_sync(tmp_path, retention=-1) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - sync._client.get_server_segments = MagicMock( - return_value=QueryResult([], None, 200) - ) - sync._client.upload_segment = MagicMock( - return_value=UploadResult(True, duplicate=True, stored_key="existing_300") - ) - - await sync._sync() - - assert (segment / ".server_key").read_text().strip() == "existing_300" - item = _server_item("existing_300", segment) - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - sync._client.upload_segment.reset_mock() - - await sync._sync() - - sync._client.upload_segment.assert_not_called() - assert day in sync._synced_days - - @pytest.mark.asyncio - async def test_collision_marker_and_original_key_reconcile(self, tmp_path: Path): - sync = self._make_sync(tmp_path, retention=-1) - day = "20260101" - segment = self._create_segment( - sync._config.captures_dir, day, "archon", "120000_300" - ) - sync._client.get_server_segments = MagicMock( - return_value=QueryResult([], None, 200) - ) - sync._client.upload_segment = MagicMock( - return_value=UploadResult(True, stored_key="120000_301") - ) - - await sync._sync() - - assert (segment / ".server_key").read_text().strip() == "120000_301" - item = _server_item("120000_301", segment, original_key="120000_300") - sync._client.get_server_segments = MagicMock( - side_effect=self._query_for(day, [item]) - ) - sync._client.upload_segment.reset_mock() - - await sync._sync() - - sync._client.upload_segment.assert_not_called() - assert day in sync._synced_days - - -class TestCleanupSyncedSegments: - """Test cache retention cleanup of synced segments.""" - - def _make_sync(self, tmp_path: Path, retention: int = 7) -> SyncService: - config = Config(base_dir=tmp_path) - config.cache_retention_days = retention - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - return seg_dir - - @pytest.mark.asyncio - async def test_deletes_old_synced_confirmed(self, tmp_path: Path): - """Segments in synced_days + confirmed on server + old enough -> deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - segment = self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - server_response = [_server_item("120000_300", segment)] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert not (captures / "20260101" / "archon" / "120000_300").exists() - - @pytest.mark.asyncio - async def test_keeps_unconfirmed_on_server(self, tmp_path: Path): - """Segments in synced_days + NOT on server -> not deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - server_response = [ - _server_item("999999_300", captures / "20260101" / "archon" / "120000_300") - ] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000_300").exists() - - @pytest.mark.asyncio - async def test_keeps_segments_not_in_synced_days(self, tmp_path: Path): - """Segments NOT in synced_days -> not deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000_300") - - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000_300").exists() - mock_thread.assert_not_called() - - @pytest.mark.asyncio - async def test_keeps_when_server_unreachable(self, tmp_path: Path): - """Server unreachable (returns None) -> nothing deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(None, ErrorType.TRANSIENT), - ): - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000_300").exists() - - @pytest.mark.asyncio - async def test_never_touches_incomplete(self, tmp_path: Path): - """.incomplete segments are never deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000.incomplete") - complete = self._create_segment(captures, "20260101", "archon", "140000_300") - sync._synced_days.add("20260101") - - server_response = [_server_item("140000_300", complete)] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000.incomplete").exists() - assert not (captures / "20260101" / "archon" / "140000_300").exists() - - @pytest.mark.asyncio - async def test_retention_negative_one_keeps_forever(self, tmp_path: Path): - """cache_retention_days = -1 -> nothing deleted.""" - sync = self._make_sync(tmp_path, retention=-1) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000_300").exists() - mock_thread.assert_not_called() - - @pytest.mark.asyncio - async def test_retention_zero_deletes_immediately(self, tmp_path: Path): - """cache_retention_days = 0 -> deletes immediately (no age check).""" - sync = self._make_sync(tmp_path, retention=0) - captures = sync._config.captures_dir - - from datetime import datetime, timedelta - - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") - - segment = self._create_segment(captures, yesterday, "archon", "120000_300") - sync._synced_days.add(yesterday) - - server_response = [_server_item("120000_300", segment)] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert not (captures / yesterday / "archon" / "120000_300").exists() - - @pytest.mark.asyncio - async def test_never_cleans_today(self, tmp_path: Path): - """Today's segments are never cleaned, even with retention=0.""" - sync = self._make_sync(tmp_path, retention=0) - captures = sync._config.captures_dir - - from datetime import datetime - - today = datetime.now().strftime("%Y%m%d") - - self._create_segment(captures, today, "archon", "120000_300") - sync._synced_days.add(today) - - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await sync._cleanup_synced_segments() - - assert (captures / today / "archon" / "120000_300").exists() - mock_thread.assert_not_called() - - @pytest.mark.asyncio - async def test_cleans_empty_dirs(self, tmp_path: Path): - """Empty stream and day dirs are removed after segment deletion.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - segment = self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - server_response = [_server_item("120000_300", segment)] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert not (captures / "20260101" / "archon").exists() - assert not (captures / "20260101").exists() - - @pytest.mark.asyncio - async def test_original_key_lookup(self, tmp_path: Path): - """Server segment with original_key should match local segment.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - segment = self._create_segment(captures, "20260101", "archon", "120000_300") - sync._synced_days.add("20260101") - - server_response = [ - _server_item("renamed_key", segment, original_key="120000_300") - ] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert not (captures / "20260101" / "archon" / "120000_300").exists() - - -class TestCleanupFailedSegments: - """Test that retention cleanup skips .failed segments.""" - - def _make_sync(self, tmp_path: Path, retention: int = 7) -> SyncService: - config = Config(base_dir=tmp_path) - config.cache_retention_days = retention - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - return seg_dir - - @pytest.mark.asyncio - async def test_failed_segments_kept_if_day_not_synced(self, tmp_path: Path): - """.failed segments are kept if the day is not in synced_days.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000_300.failed") - - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000_300.failed").exists() - mock_thread.assert_not_called() - - @pytest.mark.asyncio - async def test_failed_segments_kept_within_retention(self, tmp_path: Path): - """.failed segments are kept when the synced day is still within retention.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - fake_now = datetime(2026, 1, 8, 12, 0, 0) - - seg = self._create_segment(captures, "20260107", "archon", "120000_300.failed") - sync._synced_days.add("20260107") - - server_response = [] - with patch("solstone_linux.sync.datetime", wraps=datetime) as mock_datetime: - mock_datetime.now.return_value = fake_now - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert seg.exists() - - @pytest.mark.asyncio - async def test_incomplete_still_skipped(self, tmp_path: Path): - """.incomplete segments are still never deleted.""" - sync = self._make_sync(tmp_path, retention=7) - captures = sync._config.captures_dir - - self._create_segment(captures, "20260101", "archon", "120000.incomplete") - sync._synced_days.add("20260101") - - server_response = [] - with patch( - "asyncio.to_thread", - new_callable=AsyncMock, - return_value=QueryResult(server_response), - ): - await sync._cleanup_synced_segments() - - assert (captures / "20260101" / "archon" / "120000.incomplete").exists() - - -class TestSweepExpiredQuarantine: - """Test local TTL sweep for quarantined .failed segments.""" - - def _make_sync(self, tmp_path: Path, retention: int = 7) -> SyncService: - config = Config(base_dir=tmp_path) - config.cache_retention_days = retention - config.ensure_dirs() - client = UploadClient(config) - return SyncService(config, client) - - def _create_segment( - self, captures_dir: Path, day: str, stream: str, name: str - ) -> Path: - seg_dir = captures_dir / day / stream / name - seg_dir.mkdir(parents=True, exist_ok=True) - (seg_dir / "screen.webm").write_bytes(b"\x00" * 100) - return seg_dir - - def test_fresh_quarantine_survives_repeated_sweeps(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - seg = self._create_segment(captures, "20260101", "archon", "120000_300.failed") - now = time.time() - os.utime(seg, (now, now)) - - sync._sweep_expired_quarantine() - sync._sweep_expired_quarantine() - - assert seg.exists() - - def test_fresh_dir_mtime_beats_ancient_day_and_file_mtimes(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - seg = self._create_segment(captures, "20200101", "archon", "120000_300.failed") - ancient = time.time() - 100 * 86400 - os.utime(seg / "screen.webm", (ancient, ancient)) - now = time.time() - os.utime(seg, (now, now)) - - sync._sweep_expired_quarantine() - - assert seg.exists() - - def test_aged_quarantine_deleted_without_server_query(self, tmp_path: Path, caplog): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - seg = self._create_segment(captures, "20200101", "archon", "120000_300.failed") - old_time = time.time() - 40 * 86400 - os.utime(seg, (old_time, old_time)) - assert "20200101" not in sync._synced_days - - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - with caplog.at_level("WARNING", logger="solstone_linux.sync"): - sync._sweep_expired_quarantine() - - assert not seg.exists() - mock_thread.assert_not_called() - warnings = [ - rec - for rec in caplog.records - if rec.name == "solstone_linux.sync" and rec.levelname == "WARNING" - ] - assert len(warnings) == 1 - assert "120000_300.failed" in warnings[0].getMessage() - - def test_aged_quarantine_deleted_with_retention_disabled(self, tmp_path: Path): - sync = self._make_sync(tmp_path, retention=-1) - captures = sync._config.captures_dir - seg = self._create_segment(captures, "20260101", "archon", "120000_300.failed") - old_time = time.time() - 40 * 86400 - os.utime(seg, (old_time, old_time)) - - sync._sweep_expired_quarantine() - - assert not seg.exists() - - def test_aged_quarantine_deletes_both_name_shapes(self, tmp_path: Path): - sync = self._make_sync(tmp_path) - captures = sync._config.captures_dir - duration_shape = self._create_segment( - captures, "20260101", "archon", "120000_300.failed" - ) - bare_shape = self._create_segment( - captures, "20260101", "archon", "130000.failed" - ) - old_time = time.time() - 40 * 86400 - os.utime(duration_shape, (old_time, old_time)) - os.utime(bare_shape, (old_time, old_time)) - - sync._sweep_expired_quarantine() - - assert not duration_shape.exists() - assert not bare_shape.exists() diff --git a/tests/test_sync_health.py b/tests/test_sync_health.py deleted file mode 100644 index 3a3bde5..0000000 --- a/tests/test_sync_health.py +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from solstone_linux.config import DEFAULT_SYNC_STALE_THRESHOLD -from solstone_linux.sync_health import ( - SURFACE_BY_STATE, - ErrorType, - HealthState, - SyncFacts, - derive_health, - load_facts, - save_facts, -) - - -def test_empty_facts_derive_unknown(): - health = derive_health(SyncFacts(), now=1000.0) - - assert health.state == HealthState.UNKNOWN - assert health.sni_status == "Active" - assert health.pending_display == "pending unconfirmed" - - -def test_error_precedence_states(): - assert ( - derive_health(SyncFacts(last_error_class=ErrorType.AUTH), now=1000.0).state - == HealthState.REVOKED - ) - assert ( - derive_health( - SyncFacts(last_error_class=ErrorType.INCOMPATIBLE), now=1000.0 - ).state - == HealthState.UPDATE_NEEDED - ) - assert ( - derive_health(SyncFacts(last_error_class=ErrorType.TRANSIENT), now=1000.0).state - == HealthState.OFFLINE - ) - - -def test_stale_uses_last_successful_contact(): - health = derive_health( - SyncFacts( - last_successful_sync=900.0, - last_successful_contact=100.0, - in_progress=True, - ), - now=1000.0, - stale_threshold=DEFAULT_SYNC_STALE_THRESHOLD, - ) - - assert health.state == HealthState.STALE - assert health.sni_status == "NeedsAttention" - assert "last contact" in health.tooltip - - -def test_pending_confirmed_zero_is_only_connected_gate(): - connected = derive_health(SyncFacts(pending_confirmed=0), now=1000.0) - unknown = derive_health(SyncFacts(pending_confirmed=None), now=1000.0) - - assert connected.state == HealthState.CONNECTED - assert connected.pending_display == "0 pending" - assert unknown.state == HealthState.UNKNOWN - assert unknown.pending_display == "pending unconfirmed" - - -def test_fresh_contact_prevents_stale_when_sync_timestamp_is_old(): - health = derive_health( - SyncFacts( - last_successful_sync=100.0, - last_successful_contact=990.0, - in_progress=True, - ), - now=1000.0, - stale_threshold=DEFAULT_SYNC_STALE_THRESHOLD, - ) - - assert health.state == HealthState.SYNCING - - -def test_save_and_load_facts_round_trip(tmp_path): - facts = SyncFacts( - last_successful_sync=100.5, - last_successful_contact=200.5, - last_error_class=ErrorType.INCOMPATIBLE, - last_error_code=404, - pending_confirmed=None, - in_progress=True, - progress="uploading 120000_300", - ) - - save_facts(tmp_path, facts) - loaded = load_facts(tmp_path) - - assert loaded == facts - - -def test_load_facts_missing_or_invalid_returns_empty(tmp_path): - assert load_facts(tmp_path) == SyncFacts() - - path = tmp_path / "sync_health.json" - path.write_text("{not-json", encoding="utf-8") - - assert load_facts(tmp_path) == SyncFacts() - - -def test_every_health_state_has_complete_surface(): - assert set(SURFACE_BY_STATE) == set(HealthState) - for surface in SURFACE_BY_STATE.values(): - assert surface.header_recording - assert surface.header_idle - assert surface.sync_line - assert surface.tooltip - assert surface.accessible_recording - assert surface.accessible_idle - assert surface.icon - assert surface.sni - assert surface.cli - assert surface.doctor_severity - assert surface.doctor_detail - assert surface.dbus diff --git a/tests/test_sync_health_surfaces.py b/tests/test_sync_health_surfaces.py deleted file mode 100644 index 403b498..0000000 --- a/tests/test_sync_health_surfaces.py +++ /dev/null @@ -1,190 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import argparse -import time -from unittest.mock import MagicMock - -import pytest -from dbus_fast.service import ServiceInterface - -from solstone_linux import cli as cli_module -from solstone_linux import doctor -from solstone_linux.cli import cmd_status -from solstone_linux.config import Config -from solstone_linux.dbus_service import ObserverService -from solstone_linux.sync import SyncService -from solstone_linux.sync_health import ( - ErrorType, - HealthState, - SyncFacts, - derive_health, - save_facts, -) -from solstone_linux.tray import TrayApp -from solstone_linux.upload import QueryResult, UploadClient - - -class _FakeSync: - def __init__(self, health): - self.health = health - self.progress = health.progress - - -def _get_prop(service, name): - for prop in ServiceInterface._get_properties(service): - if prop.name == name: - return prop.prop_getter(service) - raise KeyError(name) - - -def _make_observer(config: Config, health): - observer = MagicMock() - observer.config = config - observer._paused = False - observer._pause_until = 0.0 - observer.current_mode = "screencast" - observer.segment_dir = None - observer.interval = 300 - observer.start_at_mono = time.monotonic() - observer._start_mono = time.monotonic() - observer.stream = "test-stream" - observer._sync = _FakeSync(health) - observer._dbus_service = None - observer.capture_stats = {"captures_today": 0, "total_size_mb": 0} - return observer - - -@pytest.mark.parametrize( - "facts,expected_header,expected_sni,expected_dbus,expected_cli,expected_doctor", - [ - ( - SyncFacts( - pending_confirmed=0, - last_successful_sync=1_800_000_000.0, - last_successful_contact=1_800_000_000.0, - ), - "on — connected", - "Active", - "connected", - "Sync: connected — up to date (0 pending)", - "ok", - ), - ( - SyncFacts(last_error_class=ErrorType.INCOMPATIBLE, last_error_code=404), - "on — update needed", - "NeedsAttention", - "update-needed", - "Sync: update needed — update solstone-linux; pending unconfirmed", - "fail", - ), - ], -) -def test_health_facts_drive_all_surfaces_consistently( - tmp_path, - monkeypatch, - capsys, - facts, - expected_header, - expected_sni, - expected_dbus, - expected_cli, - expected_doctor, -): - config = Config( - base_dir=tmp_path, - server_url="https://test.example.com", - key="K123456789", - stream="test-stream", - ) - config.ensure_dirs() - save_facts(config.state_dir, facts) - monkeypatch.setattr(cli_module, "load_config", lambda: config) - monkeypatch.setattr(doctor, "load_config", lambda: config) - monkeypatch.setattr( - cli_module.subprocess, - "run", - MagicMock(return_value=MagicMock(stdout="active\n")), - ) - - health = derive_health(facts, time.time(), config.sync_stale_threshold) - observer = _make_observer(config, health) - - app = TrayApp(observer, MagicMock()) - app._build_menu() - app.update() - - assert app._status_header.label == expected_header - assert app._sync_item.label == health.sync_line - assert health.tooltip in app.sni._tooltip_body - assert app.sni._icon_accessible_desc in ( - health.accessible_recording, - health.accessible_idle, - ) - assert app.sni._status == expected_sni - - service = ObserverService(observer) - assert _get_prop(service, "SyncStatus") == expected_dbus - - assert cmd_status(argparse.Namespace()) == 0 - assert expected_cli in capsys.readouterr().out - - doctor_result = doctor.check_sync_health() - assert doctor_result.severity == expected_doctor - - -@pytest.mark.asyncio -async def test_404_query_cycle_drives_failing_state_on_all_surfaces( - tmp_path, monkeypatch, capsys -): - config = Config( - base_dir=tmp_path, - server_url="https://test.example.com", - key="K123456789", - stream="test-stream", - ) - config.ensure_dirs() - client = UploadClient(config) - client.get_server_segments = MagicMock( - return_value=QueryResult(None, ErrorType.INCOMPATIBLE, 404) - ) - client.upload_segment = MagicMock() - sync = SyncService(config, client) - - await sync._sync() - - assert sync.health.state == HealthState.UPDATE_NEEDED - assert sync.health.pending_display == "pending unconfirmed" - - monkeypatch.setattr(cli_module, "load_config", lambda: config) - monkeypatch.setattr(doctor, "load_config", lambda: config) - monkeypatch.setattr( - cli_module.subprocess, - "run", - MagicMock(return_value=MagicMock(stdout="active\n")), - ) - observer = _make_observer(config, sync.health) - - app = TrayApp(observer, MagicMock()) - app._build_menu() - app.update() - - assert app._status_header.label == "on — update needed" - assert app._status_header.label != "on — connected" - assert app._sync_item.label == "sync: update solstone-linux" - assert "sync: update needed; update solstone-linux" in app.sni._tooltip_body - assert app.sni._icon_accessible_desc == "sol — on, update needed" - assert app.sni._status == "NeedsAttention" - - service = ObserverService(observer) - assert _get_prop(service, "SyncStatus") == "update-needed" - - assert cmd_status(argparse.Namespace()) == 0 - assert ( - "Sync: update needed — update solstone-linux; pending unconfirmed" - in capsys.readouterr().out - ) - - doctor_result = doctor.check_sync_health() - assert doctor_result.severity == "fail" - assert "update needed" in doctor_result.detail diff --git a/tests/test_tray.py b/tests/test_tray.py deleted file mode 100644 index 8e787a2..0000000 --- a/tests/test_tray.py +++ /dev/null @@ -1,566 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import time -from pathlib import Path -from unittest.mock import call -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest - -from solstone_linux.config import Config -from solstone_linux.dbusmenu import DBusMenu, MenuItem, separator -from solstone_linux.sni import StatusNotifierItem -from solstone_linux.sync_health import ErrorType, HealthState, SyncFacts, derive_health -from solstone_linux.tray import ( - AGENT_INSTRUCTIONS, - ICONS, - SOURCE_DIR, - TrayApp, - _compute_header_label, - resolve_icon_theme_path, -) - - -def _make_app(tmp_path=None): - config = Config() - if tmp_path: - config.base_dir = tmp_path - config.config_dir = tmp_path / "config" - config.server_url = "https://test.example.com" - observer = MagicMock() - observer.config = config - observer._paused = False - observer._pause_until = 0.0 - observer.current_mode = "screencast" - observer.segment_dir = None - observer.interval = 300 - observer.start_at_mono = time.monotonic() - observer._start_mono = time.monotonic() - observer._sync = None - observer._dbus_service = None - observer.capture_stats = {"captures_today": 0, "total_size_mb": 0} - bus = MagicMock() - app = TrayApp(observer, bus) - return app - - -def _health(facts=None): - return derive_health(facts or SyncFacts(), time.time()) - - -def _connected_health(): - return _health(SyncFacts(pending_confirmed=0, last_successful_sync=time.time())) - - -def _syncing_health(progress="3/10 segments"): - return _health(SyncFacts(in_progress=True, progress=progress)) - - -def _offline_health(): - return _health(SyncFacts(last_error_class=ErrorType.TRANSIENT)) - - -def _prepare_open_refresh_state(app, now): - segment_dir = Path("/tmp/test.incomplete") - app._observer.current_mode = "screencast" - app._observer._paused = False - app._observer.segment_dir = segment_dir - app._observer.start_at_mono = now - 75 - app._observer._start_mono = now - 3661 - app._observer.interval = 300 - app._observer._sync = MagicMock() - app._observer._sync.health = _connected_health() - app._observer.capture_stats = {"captures_today": 1, "total_size_mb": 1} - return segment_dir - - -class TestResolveIconThemePath: - def test_resolve_icon_theme_path_prefers_installed(self, tmp_path): - installed_icon = ( - tmp_path - / ".local/share/icons/hicolor/scalable/status/solstone-recording.svg" - ) - installed_icon.parent.mkdir(parents=True) - installed_icon.touch() - - with patch("solstone_linux.tray.Path.home", return_value=tmp_path): - assert resolve_icon_theme_path() == str(tmp_path / ".local/share/icons") - - def test_resolve_icon_theme_path_contrib_fallback(self, tmp_path): - with patch("solstone_linux.tray.Path.home", return_value=tmp_path): - result = resolve_icon_theme_path() - - assert result.endswith("contrib/icons") - assert (Path(result) / "hicolor").is_dir() is True - - -class TestTrayInit: - def test_make_app_uses_observer_config(self): - app = _make_app() - - assert isinstance(app, TrayApp) - assert app.config.server_url == "https://test.example.com" - assert app.sni is not None - assert app.menu is not None - - -class TestBuildMenu: - def test_build_menu_creates_expected_items(self): - app = _make_app() - - app._build_menu() - - assert isinstance(app._status_item, MenuItem) - assert app._status_item.label == "on" - assert app._status_item.enabled is False - assert app._sync_item.label == "sync: checking..." - assert app._pause_submenu.children_display == "submenu" - assert len(app._pause_submenu.children) == 4 - assert app._resume_item.visible is False - assert app.menu._root.children[0] is app._status_header - assert app.menu._root.children[1].item_type == separator().item_type - assert len(app.menu._root.children) == 11 - - -class TestUpdateStatus: - def test_update_status_paused(self): - app = _make_app() - app._build_menu() - app.menu.update_properties = MagicMock() - - app._update_status("paused", app.health) - - assert app.status == "paused" - assert app._pause_submenu.visible is False - assert app._resume_item.visible is True - assert app.menu.update_properties.call_count >= 2 - assert ( - call(app._pause_submenu, "visible") - in app.menu.update_properties.call_args_list - ) - assert ( - call(app._resume_item, "visible", "label") - in app.menu.update_properties.call_args_list - ) - - def test_update_status_idle(self): - app = _make_app() - app._build_menu() - - app._update_status("idle", app.health) - - assert app.status == "idle" - - def test_update_status_stopped_sets_attention(self): - app = _make_app() - app._build_menu() - - app._update_status("stopped", app.health) - - assert app.status == "stopped" - assert app.sni._status == "NeedsAttention" - - def test_update_status_recording_uses_error_icon_when_error_set(self): - app = _make_app() - app._build_menu() - app._update_status("paused", app.health) - app.error = "Auth failed" - - app._update_status("recording", app.health) - - assert app.sni._icon_name == ICONS["error"] - - -class TestUpdateSync: - def test_update_sync_signals_label_change_only_once(self): - app = _make_app() - app._build_menu() - app.menu.update_properties = MagicMock() - health = _connected_health() - - app._update_sync(health) - - app.menu.update_properties.assert_called_once_with(app._sync_item, "label") - - app.menu.update_properties.reset_mock() - - app._update_sync(health) - - app.menu.update_properties.assert_not_called() - - def test_update_sync_synced(self): - app = _make_app() - app._build_menu() - - app._update_sync(_connected_health()) - - assert app._sync_item.label == "sync: up to date" - - def test_update_sync_syncing(self): - app = _make_app() - app._build_menu() - - app._update_sync(_syncing_health("3/10 segments")) - - assert app._sync_item.label == "sync: 3/10 segments" - - def test_update_sync_offline(self): - app = _make_app() - app._build_menu() - - app._update_sync(_offline_health()) - - assert app._sync_item.label == "sync: offline; will retry" - - def test_update_sync_update_needed_sets_attention(self): - app = _make_app() - app._build_menu() - health = _health(SyncFacts(last_error_class=ErrorType.INCOMPATIBLE)) - - app._update_status("recording", health) - app._update_sync(health) - - assert health.state == HealthState.UPDATE_NEEDED - assert app.sni._status == "NeedsAttention" - assert app.sni._icon_name == ICONS["error"] - - -class TestUpdateLiveStats: - def test_update_live_stats_updates_labels(self): - app = _make_app() - app._build_menu() - app.stats = { - "captures_today": 5, - "total_size_mb": 42, - "uptime_seconds": 7260, - } - - app._update_live_stats(245, 0) - - assert app._segment_item.label == "segment: 4:05 remaining" - assert app._cache_item.label == "cache: 42 MB" - assert app._captures_item.label == "today: 5 segments" - assert app._uptime_item.label == "uptime: 2h 1m" - - def test_update_live_stats_skips_unchanged_menu_updates(self): - app = _make_app() - app._build_menu() - app.menu.update_properties = MagicMock() - app.stats = { - "captures_today": 5, - "total_size_mb": 42, - "uptime_seconds": 7260, - } - - app._update_live_stats(245, 0) - - assert app.menu.update_properties.call_args_list == [ - call(app._segment_item, "label"), - call(app._cache_item, "label"), - call(app._captures_item, "label"), - call(app._uptime_item, "label"), - ] - - app.menu.update_properties.reset_mock() - - app._update_live_stats(245, 0) - - app.menu.update_properties.assert_not_called() - - def test_update_live_stats_signals_resume_countdown_change_only_once(self): - app = _make_app() - app._build_menu() - app.status = "paused" - app._segment_item.label = "segment: 0:00 remaining" - app.menu.update_properties = MagicMock() - - app._update_live_stats(0, 600) - - app.menu.update_properties.assert_called_once_with( - app._resume_item, - "label", - ) - - app.menu.update_properties.reset_mock() - - app._update_live_stats(0, 600) - - app.menu.update_properties.assert_not_called() - - -class TestHeaderLabel: - def test_update_header_emits_label_property_update(self): - app = _make_app() - app._build_menu() - app.status = "recording" - app.menu.update_properties = MagicMock() - offline = _offline_health() - - app._update_header(0, offline) - app.menu.update_properties.reset_mock() - - app._update_header(0, _connected_health()) - - assert ( - call(app._status_header, "label") - in app.menu.update_properties.call_args_list - ) - assert ( - call(app._status_item, "label") in app.menu.update_properties.call_args_list - ) - assert app._status_header.label == "on — connected" - assert app._status_item.label == "on — connected" - - app.menu.update_properties.reset_mock() - - app._update_header(0, _connected_health()) - - app.menu.update_properties.assert_not_called() - - def test_header_recording_connected(self): - app = _make_app() - app._build_menu() - app._observer._sync = MagicMock() - app._observer._sync.health = _connected_health() - - app.update() - - assert app._status_header.label == "on — connected" - assert app._status_item.label == "on — connected" - - def test_header_paused_with_timer(self): - app = _make_app() - app._build_menu() - app._observer._paused = True - app._observer._pause_until = 1000.0 - - with patch("solstone_linux.tray.time.monotonic", return_value=100.0): - app.update() - - assert app._status_header.label == "paused (15m remaining)" - assert app._status_item.label == "paused (15m remaining)" - - def test_header_recording_offline(self): - app = _make_app() - app._build_menu() - app._observer._sync = MagicMock() - app._observer._sync.health = _offline_health() - - app.update() - - assert app._status_header.label == "on — offline (saving locally)" - assert app._status_item.label == "on — offline (saving locally)" - - -class TestComputeHeaderLabel: - @pytest.mark.parametrize( - "status,health_key,pause_remaining,expected", - [ - ("recording", "connected", 0, "on — connected"), - ("recording", "syncing", 0, "on — syncing"), - ("recording", "offline", 0, "on — offline (saving locally)"), - ("idle", "connected", 0, "idle — connected"), - ("idle", "syncing", 0, "idle — syncing"), - ("idle", "offline", 0, "idle — offline (saving locally)"), - ("paused", "connected", 0, "paused"), - ("paused", "connected", 900, "paused (15m remaining)"), - ("paused", "offline", 59, "paused (0m remaining)"), - ("stopped", "connected", 0, "not running"), - ("weird", "connected", 0, "weird"), - ], - ) - def test_compute_header_label(self, status, health_key, pause_remaining, expected): - health = { - "connected": _connected_health(), - "syncing": _syncing_health(), - "offline": _offline_health(), - }[health_key] - assert _compute_header_label(status, health, pause_remaining) == expected - - -class TestBuildTooltip: - def test_build_tooltip_default(self): - app = _make_app() - app.status = "recording" - - tooltip = app._build_tooltip() - - assert tooltip.startswith("on") - assert "sync: not confirmed yet" in tooltip - - def test_build_tooltip_stopped(self): - app = _make_app() - app.status = "stopped" - - tooltip = app._build_tooltip() - - assert "not running" in tooltip - - def test_build_tooltip_error(self): - app = _make_app() - app.error = "Auth failed" - - tooltip = app._build_tooltip() - - assert "Auth failed" in tooltip - - def test_build_tooltip_sync_progress(self): - app = _make_app() - app.health = _syncing_health("2/5") - - tooltip = app._build_tooltip() - - assert "sync: 2/5" in tooltip - - -class TestStatusNotifierItem: - def test_accessible_desc_properties(self): - sni = StatusNotifierItem() - - sni.set_icon_accessible_desc("sol — on") - sni.set_attention_accessible_desc("sol — on") - - assert sni.IconAccessibleDesc == "sol — on" - assert sni.AttentionAccessibleDesc == "sol — on" - - -class TestUpdate: - def test_on_about_to_show_forces_recompute(self, tmp_path): - app = _make_app(tmp_path) - app._build_menu() - now = 10_000.0 - _prepare_open_refresh_state(app, now) - - with patch("solstone_linux.tray.time.monotonic", return_value=now): - changed = app._on_about_to_show() - - assert changed is True - assert app.stats == { - "captures_today": 1, - "total_size_mb": 1, - "uptime_seconds": 3661, - } - assert app._segment_item.label == "segment: 3:45 remaining" - assert app._cache_item.label == "cache: 1 MB" - assert app._captures_item.label == "today: 1 segments" - assert app._uptime_item.label == "uptime: 1h 1m" - assert app._sync_item.label == "sync: up to date" - assert app._status_item.label == "on — connected" - - def test_about_to_show_returns_true_and_layout_has_refreshed_labels(self, tmp_path): - app = _make_app(tmp_path) - app._build_menu() - now = 10_000.0 - _prepare_open_refresh_state(app, now) - - with patch("solstone_linux.tray.time.monotonic", return_value=now): - assert DBusMenu.AboutToShow.__wrapped__(app.menu, 0) is True - - row_items = [ - app._status_item, - app._sync_item, - app._segment_item, - app._cache_item, - app._captures_item, - app._uptime_item, - ] - props_by_id = { - item_id: props - for item_id, props in DBusMenu.GetGroupProperties.__wrapped__( - app.menu, - [item.id for item in row_items], - [], - ) - } - - for item in row_items: - assert props_by_id[item.id]["label"].value == item.label - - def test_on_about_to_show_failure_keeps_tray_and_last_known_layout(self): - app = _make_app() - app._build_menu() - app._observer._tray = app - app.update = MagicMock(side_effect=RuntimeError("boom")) - - assert app._on_about_to_show() is False - assert app._observer._tray is app - - props = DBusMenu.GetGroupProperties.__wrapped__( - app.menu, - [app._status_item.id], - [], - ) - assert props[0][1]["label"].value == "on" - - def test_first_update_clears_starting_tooltip(self): - """Tray tooltip must not stay on 'starting…' after first update.""" - app = _make_app() - app._build_menu() - # Simulate what TrayApp.start() sets before any update - app.sni.set_tooltip("sol", "starting…") - app._observer.current_mode = "screencast" - app._observer._paused = False - - app.update() - - # Tooltip body should no longer be "starting…" - assert app.sni._tooltip_body != "starting…" - - def test_update_reads_observer_state(self): - app = _make_app() - app._build_menu() - app._observer.current_mode = "screencast" - app._observer._paused = False - app._observer.segment_dir = Path("/tmp/test.incomplete") - app._observer.start_at_mono = time.monotonic() - 60 - app._observer.interval = 300 - - app.update() - - assert app.status == "recording" - assert app._segment_item.label.startswith(("segment: 4:", "segment: 3:")) - - def test_update_shows_paused(self): - app = _make_app() - app._build_menu() - app._observer._paused = True - app._observer._pause_until = time.monotonic() + 600 - - app.update() - - assert app.status == "paused" - assert app._resume_item.visible is True - - -class TestConfigIntegration: - def test_config_paths_use_base_dir(self, tmp_path): - app = _make_app(tmp_path) - - assert str(app.config.captures_dir).startswith(str(tmp_path)) - assert str(app.config.config_path).startswith(str(tmp_path)) - - def test_agent_instructions_template_uses_config_values(self, tmp_path): - app = _make_app(tmp_path) - - text = AGENT_INSTRUCTIONS.format( - source_dir=SOURCE_DIR, - config_path=str(app.config.config_path), - captures_dir=str(app.config.captures_dir), - ) - - assert SOURCE_DIR in text - assert str(app.config.config_path) in text - assert str(app.config.captures_dir) in text - assert "https://github.com/solpbc/solstone-linux/blob/main/INSTALL.md" in text - assert "in the source directory" not in text - - def test_open_journal_uses_public_site_when_server_url_empty(self): - app = _make_app() - app.config.server_url = "" - app._open_url = MagicMock() - - app._open_journal() - - app._open_url.assert_called_once_with("https://solstone.app") diff --git a/tests/test_upload.py b/tests/test_upload.py deleted file mode 100644 index d8266ef..0000000 --- a/tests/test_upload.py +++ /dev/null @@ -1,366 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from solstone_linux.config import Config, load_config -from solstone_linux.sync_health import ErrorType -from solstone_linux.upload import ( - MAX_IMMEDIATE_ATTEMPTS, - OBSERVER_PROTOCOL_VERSION_HEADER, - UploadClient, -) - - -def test_ensure_registered_posts_descriptor_and_persists(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - stream="host-a", - ) - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=200, - json=lambda: { - "key": "K123456789", - "prefix": "K1234567", - "name": "fedora", - "ingest_url": "/app/observer/ingest", - "protocol_version": 2, - }, - ) - - assert client.ensure_registered(config) is True - - client._session.post.assert_called_once() - call = client._session.post.call_args - assert call.args[0].endswith("/app/observer/register") - descriptor = call.kwargs["json"] - assert descriptor["stream_type"] == "desktop" - assert descriptor["platform"] - assert descriptor["hostname"] - assert descriptor["version"] - assert descriptor["label"] == "host-a" - assert config.key == "K123456789" - assert config.stream == "fedora" - assert client._key == "K123456789" - - reloaded = load_config(base_dir=tmp_path) - assert reloaded.key == "K123456789" - assert reloaded.stream == "fedora" - - -def test_ensure_registered_skips_when_key_present(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="existing", - ) - client = UploadClient(config) - client._session = MagicMock() - - assert client.ensure_registered(config) is True - client._session.post.assert_not_called() - - -def test_upload_segment_uses_bearer_and_keyless_route(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=200, - json=lambda: {"status": "ok"}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert result.success - call = client._session.post.call_args - url = call.args[0] - assert url.endswith("/app/observer/ingest") - assert "/ingest/K" not in url - assert call.kwargs["headers"] == {"Authorization": "Bearer K"} - assert call.kwargs["data"] == {"day": "20260101", "segment": "120000_005"} - assert "stream" not in call.kwargs["data"] - assert "meta" not in call.kwargs["data"] - assert "files" in call.kwargs - - -def test_upload_segment_declares_content_types(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=200, - json=lambda: {"status": "ok", "segment": "120000_005"}, - ) - flac = tmp_path / "audio.flac" - webm = tmp_path / "screen.webm" - unknown = tmp_path / "notes.bin" - for path in (flac, webm, unknown): - path.write_bytes(b"x") - - result = client.upload_segment("20260101", "120000_005", [flac, webm, unknown]) - - assert result.success - files = client._session.post.call_args.kwargs["files"] - assert [entry[1][2] for entry in files] == [ - "audio/flac", - "video/webm", - "application/octet-stream", - ] - - -@pytest.mark.parametrize( - ("body", "expected_duplicate", "expected_key"), - [ - ({"status": "ok", "segment": "120000_005"}, False, "120000_005"), - ({"status": "collision", "segment": "120000_006"}, False, "120000_006"), - ( - { - "status": "duplicate", - "existing_segment": "115959_300", - "message": "All files already received", - }, - True, - "115959_300", - ), - ], -) -def test_upload_segment_returns_stored_key( - tmp_path: Path, body: dict, expected_duplicate: bool, expected_key: str -): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=200, - json=lambda: body, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert result.success - assert result.duplicate is expected_duplicate - assert result.stored_key == expected_key - - -def test_upload_bounds_immediate_attempts(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 10 - config.sync_retry_delays = [0] - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == MAX_IMMEDIATE_ATTEMPTS - assert result.success is False - assert result.error_type == ErrorType.TRANSIENT - - -def test_upload_low_cap_makes_single_attempt(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 1 - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == 1 - assert result.success is False - assert result.error_type == ErrorType.TRANSIENT - - -def test_upload_zero_retries_makes_single_attempt(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 0 - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == 1 - assert result.success is False - assert result.error_type == ErrorType.TRANSIENT - - -def test_upload_negative_retries_makes_single_attempt(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = -1 - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == 1 - assert result.success is False - assert result.error_type == ErrorType.TRANSIENT - - -def test_upload_interrupt_during_wait_returns_transient(tmp_path: Path): - config = Config( - base_dir=tmp_path, - server_url="http://localhost:9999", - key="K", - ) - config.sync_max_retries = 10 - client = UploadClient(config) - client._session = MagicMock() - client._session.post.return_value = MagicMock( - status_code=500, - text="boom", - json=lambda: {}, - ) - media = tmp_path / "audio.flac" - media.write_bytes(b"audio") - client.request_stop() - - result = client.upload_segment("20260101", "120000_005", [media]) - - assert client._session.post.call_count == 1 - assert result.success is False - assert result.error_type == ErrorType.TRANSIENT - assert client.is_revoked is False - - -def test_relay_event_uses_bearer_and_keyless_route(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._event_session = MagicMock() - client._event_session.post.return_value = MagicMock(status_code=200) - - assert client.relay_event("observe", "status", mode="idle") is True - - call = client._event_session.post.call_args - assert call.args[0].endswith("/app/observer/ingest/event") - assert call.kwargs["headers"] == {"Authorization": "Bearer K"} - assert call.kwargs["json"] == { - "tract": "observe", - "event": "status", - "mode": "idle", - } - assert "stream" not in call.kwargs["json"] - - -def test_get_server_segments_uses_bearer_and_keyless_route(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - client._session.get.return_value = MagicMock(status_code=200, json=lambda: []) - - result = client.get_server_segments("20260101") - - assert result.segments == [] - assert result.error_type is None - assert result.status_code == 200 - assert result.legacy is True - assert result.truncated is False - - call = client._session.get.call_args - assert call.args[0].endswith("/app/observer/ingest/segments/20260101") - assert call.kwargs["headers"] == { - "Authorization": "Bearer K", - OBSERVER_PROTOCOL_VERSION_HEADER: "2", - } - params = call.kwargs.get("params") - assert params is None or "stream" not in params - - -def test_get_server_segments_parses_envelope(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - items = [{"key": "120000_300", "files": []}] - client._session.get.return_value = MagicMock( - status_code=200, - json=lambda: {"items": items, "total": 1, "protocol_version": 2}, - ) - - result = client.get_server_segments("20260101") - - assert result.segments == items - assert result.legacy is False - assert result.truncated is False - - -def test_get_server_segments_marks_truncated_envelope(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - items = [{"key": "120000_300", "files": []}] - client._session.get.return_value = MagicMock( - status_code=200, - json=lambda: {"items": items, "total": 2, "protocol_version": 2}, - ) - - result = client.get_server_segments("20260101") - - assert result.segments == items - assert result.legacy is False - assert result.truncated is True - - -def test_get_server_segments_classifies_404_as_incompatible(tmp_path: Path): - config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K") - client = UploadClient(config) - client._session = MagicMock() - client._session.get.return_value = MagicMock(status_code=404) - - result = client.get_server_segments("20260101") - - assert result.segments is None - assert result.error_type == ErrorType.INCOMPATIBLE - assert result.status_code == 404 diff --git a/tests/test_version_match.py b/tests/test_version_match.py deleted file mode 100644 index 0f0a362..0000000 --- a/tests/test_version_match.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import re -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent - - -def _read_assignment(path, name): - pattern = re.compile(rf'^{re.escape(name)}\s*=\s*"([^"]+)"\s*$') - for line in path.read_text().splitlines(): - match = pattern.match(line) - if match: - return match.group(1) - raise AssertionError(f"{name} assignment not found in {path}") - - -def test_package_version_matches_project_version(): - project_version = _read_assignment(ROOT / "pyproject.toml", "version") - package_version = _read_assignment( - ROOT / "src" / "solstone_linux" / "__init__.py", - "__version__", - ) - - assert package_version == project_version