enodia-sentinal/docs/superpowers/specs/2026-06-30-tray-applet-design.md
Luna 2e7bf8b248 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>
2026-06-30 05:12:16 -07:00

5.9 KiB

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__.pypython -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.