diff --git a/docs/superpowers/specs/2026-06-30-tray-applet-design.md b/docs/superpowers/specs/2026-06-30-tray-applet-design.md new file mode 100644 index 0000000..52d2132 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-tray-applet-design.md @@ -0,0 +1,129 @@ +# Tray Applet Design + +Date: 2026-06-30 +Status: Approved (pending spec review) +Topic: Optional desktop system-tray applet for ease-of-use. + +## Goal + +Provide a lightweight, optional desktop tray icon that lets an operator open +the dashboard, control the daemon, see status at a glance, and run a quick +check — without compromising the core agent's stdlib-only, zero runtime +dependency posture. + +## Constraints and Non-Negotiables + +- The core `enodia_sentinel` package and the daemon stay **stdlib-only**. Their + runtime dependency list remains empty. The tray dependency is an *optional + extra* only. +- The applet is a **frontend**. It must not import daemon internals or add GUI + libraries to the daemon's import graph. It drives the existing + `enodia-sentinel` CLI and `systemctl`. This keeps the trust boundary clean + and the applet auditable. +- No new config keys. The applet reuses the existing `Config` values + (`web_port` default `8787`, `web_bind`, `log_dir`). +- No stealth, persistence tricks, or destructive remediation (project rule). + +## Approach + +Approach A — thin launcher (chosen over an embedded in-process controller, +which would drag GUI deps into the daemon's import graph and blur the +stdlib-only boundary). + +Library: `pystray` + `Pillow`, shipped as an optional extra. On the target KDE +Plasma Wayland host `pystray` already resolves to the AppIndicator (Ayatana) +backend — the correct StatusNotifierItem path — and it is cross-desktop, so the +applet is not KDE-locked. PySide6/Qt would be the most KDE-native option but is +too heavy for a launcher menu. + +Process model: systemd. Start/Stop/Restart go through `systemctl` against the +packaged system units (`enodia-sentinel.service`, `enodia-sentinel-web.service`). +On KDE the polkit agent prompts for auth, which is expected and correct. + +## Architecture + +New, separate optional component: + +- `enodia_sentinel/tray/__init__.py` +- `enodia_sentinel/tray/app.py` — applet wiring (pystray icon, menu, run loop). +- `enodia_sentinel/tray/__main__.py` — `python -m enodia_sentinel.tray`. +- New console entry point `enodia-sentinel-tray` (separate from + `enodia-sentinel`). +- `pyproject.toml`: `[project.optional-dependencies] tray = ["pystray", "Pillow"]`. + Core `dependencies` stays `[]`. + +Internal seams (kept pure and testable, no GUI/subprocess at import time): + +- A status reader that runs `enodia-sentinel status --json`, parses it, and maps + it to a small `TrayState` (running/stopped/unknown, alert count, derived icon + color). Subprocess runner is injectable for tests. +- A URL builder that computes the loopback dashboard URL from `Config`. +- An action layer that wraps `systemctl` / CLI calls behind named functions, all + timeout-bounded, returning a result object (ok, message, stderr tail). +- A notification helper that prefers `notify-send` and falls back to pystray's + `notify`. +- The pystray wiring layer that binds menu items to the action layer — a thin + shell, intentionally minimal and not unit-tested. + +## Components and Menu Behavior + +| Menu item | Behavior | +|---|---| +| Status line (disabled label) | `Running — N alerts today` / `Stopped` / `Unknown`. Sourced from `enodia-sentinel status --json`. Refreshed on a timer (default 15s) and on menu open. | +| Open dashboard | Compute loopback URL `https://127.0.0.1:/`; ensure `enodia-sentinel-web.service` is active (start if not); open via `webbrowser`. Loopback needs no token. Self-signed cert triggers the existing first-visit browser warning. | +| Start / Stop / Restart daemon | `systemctl start/stop/restart enodia-sentinel.service`. Polkit prompts on KDE. Status line reflects the new state after the action. | +| Run quick check | Run `enodia-sentinel check --json` in a worker thread; summarize findings as a desktop notification. (Optionally also `rootcheck`.) | +| Quit | Exit the applet only. Does not touch the daemon. | + +## Icon State + +The tray icon reflects daemon health: + +- green — running, no recent alerts +- amber — running with recent alerts +- grey — stopped or unknown + +Icons are generated in-memory with Pillow to avoid shipping and managing binary +asset files. + +## Error Handling + +- `systemctl` or `enodia-sentinel` missing / non-zero exit → status line shows + `Unknown`; the triggering action raises a desktop notification with the + stderr tail. The applet never crashes on a failed subprocess. +- Polkit auth cancelled → notification "action cancelled"; no state change. +- All subprocess calls are timeout-bounded and run off the UI thread so the menu + never freezes. + +## Packaging and Docs + +- `packaging/enodia-sentinel-tray.desktop` — opt-in autostart entry so the tray + can launch on login. +- Update `README.md`, `docs/COMMAND_REFERENCE.md` (new `enodia-sentinel-tray` + entry and `[tray]` extra), `docs/OPERATIONS.md`, and `docs/PACKAGING.md`. +- No `config/enodia-sentinel.toml` changes (no new keys). + +## Testing + +- Pure-logic units tested with stdlib `unittest`, injecting fake subprocess + runners: status JSON parsing, `TrayState` mapping, loopback URL building, + notification formatting, action-result handling. No GUI or X/Wayland server + required in CI. +- The pystray wiring layer is a thin shell and is not unit-tested, consistent + with how the web GUI layer is treated. + +## Scope Guardrails (YAGNI) + +Out of scope: in-applet config editor, live event stream, alert-history browser +(all the dashboard's job). The applet is a launcher plus a status light, nothing +more. + +## Open Considerations + +- Non-loopback web binds (e.g. Tailscale IP with a required token): the applet + targets the loopback URL for local desktop use, which avoids token handling. + If a deployment binds web off-loopback only, "Open dashboard" still opens the + loopback URL; surfacing a token-bearing remote URL is explicitly out of scope + for this iteration. +- System units require privilege; the polkit prompt is the intended UX. A future + `systemctl --user` variant is out of scope here.