Add tray applet design spec
Optional desktop system-tray applet as a thin launcher over the existing CLI and systemd. Core agent stays stdlib-only; tray ships as an optional [tray] extra (pystray + Pillow). Covers menu actions, icon state, error handling, packaging, and testing seams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6a9e94c9fa
commit
2e7bf8b248
1 changed files with 129 additions and 0 deletions
129
docs/superpowers/specs/2026-06-30-tray-applet-design.md
Normal file
129
docs/superpowers/specs/2026-06-30-tray-applet-design.md
Normal file
|
|
@ -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:<web_port>/`; 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue