From 5e560f4307a4747cd2d727c67bb905b1bf32f50d Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 30 Jun 2026 05:49:06 -0700 Subject: [PATCH] Add tray applet implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-30-tray-applet.md | 986 ++++++++++++++++++ 1 file changed, 986 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-tray-applet.md diff --git a/docs/superpowers/plans/2026-06-30-tray-applet.md b/docs/superpowers/plans/2026-06-30-tray-applet.md new file mode 100644 index 0000000..638d401 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-tray-applet.md @@ -0,0 +1,986 @@ +# Tray Applet Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an optional desktop system-tray applet (`enodia-sentinel-tray`) that opens the dashboard, controls the daemon via systemd, shows status at a glance, and runs a quick check — without adding any runtime dependency to the core agent. + +**Architecture:** A new `enodia_sentinel/tray/` subpackage that is a pure *frontend*. All testable logic lives in GUI-free modules (`state.py`, `actions.py`, `notify.py`) that shell out to the existing `enodia-sentinel` CLI and `systemctl`. Only `app.py` imports the GUI libraries (`pystray`, `Pillow`), shipped as an optional `[tray]` extra. The daemon's import graph and zero-dependency posture are untouched. + +**Tech Stack:** Python 3.11+ stdlib for all logic; `pystray` + `Pillow` for the tray UI (optional extra only); `systemctl` + `notify-send` as external processes. + +## Global Constraints + +- Core `enodia_sentinel` runtime dependencies stay `[]`. GUI libs are an optional extra only, never imported by the daemon. +- `state.py`, `actions.py`, `notify.py` MUST NOT import `pystray` or `PIL` (keeps them testable headlessly). Only `app.py` may. +- All files start with `# SPDX-License-Identifier: GPL-3.0-or-later`. +- Tests are stdlib `unittest`, run via `python3 -m unittest tests.test_ -v`. No GUI/X/Wayland server in tests. +- All subprocess calls are timeout-bounded and `check=False`; failures degrade gracefully, never crash the applet. +- No new config keys. Reuse `Config` (`web_port` default `8787`, `log_dir`). Build URLs against loopback `127.0.0.1`. +- Process model is systemd: units `enodia-sentinel.service` (daemon) and `enodia-sentinel-web.service` (web). + +--- + +### Task 1: Add `--json` to the `check` CLI command + +The applet's "Run quick check" needs machine-readable output. `check` currently only prints text. Add a `--json` flag that emits the alert list using the existing `Alert.to_dict()`. + +**Files:** +- Modify: `enodia_sentinel/cli.py` (`_cmd_check` at ~149, parser registration at ~175, dispatch at ~261) +- Test: `tests/test_check_json.py` (create) + +**Interfaces:** +- Produces: `enodia-sentinel check --json` prints a JSON array of alert dicts (each from `Alert.to_dict()`) to stdout and returns 0. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_check_json.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""The `check --json` flag emits a parseable JSON alert array.""" +import io +import json +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from enodia_sentinel import cli +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.config import Config + + +class _FakeSentinel: + def __init__(self, cfg): + self.start_time = 1.0 + self.listener_baseline = {"x"} + + def load_baselines(self): + pass + + def build_baselines(self): + pass + + def sweep(self, force_suid=False): + return [Alert(Severity.HIGH, "rsh", "rsh:1", "reverse shell", sid=1001)] + + +class TestCheckJson(unittest.TestCase): + def test_check_json_emits_alert_array(self): + cfg = Config() + with mock.patch.object(cli, "Sentinel", _FakeSentinel): + buf = io.StringIO() + with redirect_stdout(buf): + rc = cli._cmd_check(cfg, True) + self.assertEqual(rc, 0) + data = json.loads(buf.getvalue()) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["signature"], "rsh") + self.assertEqual(data[0]["sid"], 1001) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest tests.test_check_json -v` +Expected: FAIL — `_cmd_check()` takes 1 positional arg / TypeError. + +- [ ] **Step 3: Update `_cmd_check` to accept `as_json`** + +Replace the body of `_cmd_check` in `enodia_sentinel/cli.py`: + +```python +def _cmd_check(cfg: Config, as_json: bool = False) -> int: + # One-shot: arm everything immediately, force the SUID scan. + sentinel = Sentinel(cfg) + sentinel.start_time = 0.0 # past the grace window + sentinel.load_baselines() + if not sentinel.listener_baseline: + sentinel.build_baselines() + alerts = sentinel.sweep(force_suid=True) + ordered = sorted(alerts, key=lambda x: -x.severity) + if as_json: + import json + print(json.dumps([a.to_dict() for a in ordered], indent=2)) + return 0 + if not alerts: + print("No alerts.") + return 0 + for a in ordered: + print(f"[{a.severity}] {a.signature:<14} {a.detail}") + return 0 +``` + +- [ ] **Step 4: Register the flag and pass it through** + +Change the parser registration (was `sub.add_parser("check", help=...)`): + +```python + chk = sub.add_parser("check", help="run detectors once and print alerts") + chk.add_argument("--json", action="store_true", help="emit alerts as JSON") +``` + +Change the dispatch (was `return _cmd_check(cfg)`): + +```python + if args.cmd == "check": + return _cmd_check(cfg, args.json) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `python3 -m unittest tests.test_check_json -v` +Expected: PASS. + +- [ ] **Step 6: Run the full suite (no regressions)** + +Run: `python3 -m unittest discover -s tests -v` +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add enodia_sentinel/cli.py tests/test_check_json.py +git commit -m "Add --json output to the check command + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Tray status model (`state.py`) + +Pure mapping from `status --json` dict to a `TrayState` (running/alerts/color/summary). No GUI, no subprocess. + +**Files:** +- Create: `enodia_sentinel/tray/__init__.py` +- Create: `enodia_sentinel/tray/state.py` +- Test: `tests/test_tray_state.py` + +**Interfaces:** +- Produces: `TrayState` dataclass with fields `running: bool`, `total_alerts: int`, `high_alerts: int`, `last_alert: str | None`, `color: str` (`"green"|"amber"|"grey"`), `summary: str`; property `tooltip: str`. Function `parse_status(status: dict | None) -> TrayState`. + +- [ ] **Step 1: Create the empty package marker** + +```python +# enodia_sentinel/tray/__init__.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Optional desktop tray applet. GUI deps live behind the [tray] extra.""" +``` + +- [ ] **Step 2: Write the failing test** + +```python +# tests/test_tray_state.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tray status model: maps `status --json` to a TrayState.""" +import unittest + +from enodia_sentinel.tray.state import TrayState, parse_status + + +class TestParseStatus(unittest.TestCase): + def test_none_is_unknown_grey(self): + s = parse_status(None) + self.assertFalse(s.running) + self.assertEqual(s.color, "grey") + self.assertEqual(s.summary, "Unknown") + + def test_stopped_is_grey(self): + s = parse_status({"running": False, "total_alerts": 3, + "counts": {"high": 1}}) + self.assertFalse(s.running) + self.assertEqual(s.color, "grey") + self.assertEqual(s.summary, "Stopped") + + def test_running_clean_is_green(self): + s = parse_status({"running": True, "total_alerts": 0, "counts": {}}) + self.assertEqual(s.color, "green") + self.assertIn("no alerts", s.summary) + + def test_running_with_medium_only_is_green(self): + s = parse_status({"running": True, "total_alerts": 2, + "counts": {"MEDIUM": 2}}) + self.assertEqual(s.color, "green") + self.assertIn("2 alerts", s.summary) + + def test_running_with_high_is_amber(self): + s = parse_status({"running": True, "total_alerts": 4, + "counts": {"HIGH": 1, "CRITICAL": 1, "MEDIUM": 2}}) + self.assertEqual(s.high_alerts, 2) + self.assertEqual(s.color, "amber") + self.assertIn("high/critical", s.summary) + + def test_tooltip_includes_summary(self): + s = parse_status({"running": True, "total_alerts": 0, "counts": {}}) + self.assertIn(s.summary, s.tooltip) + self.assertIn("Enodia Sentinel", s.tooltip) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `python3 -m unittest tests.test_tray_state -v` +Expected: FAIL — `ModuleNotFoundError: enodia_sentinel.tray.state`. + +- [ ] **Step 4: Implement `state.py`** + +```python +# enodia_sentinel/tray/state.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Pure status model for the tray applet. No GUI or subprocess imports.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TrayState: + running: bool + total_alerts: int + high_alerts: int + last_alert: str | None + color: str # "green" | "amber" | "grey" + summary: str + + @property + def tooltip(self) -> str: + return f"Enodia Sentinel — {self.summary}" + + +def _high_count(counts: dict) -> int: + # Severity keys are stringified Severity names ("HIGH"/"CRITICAL"); be + # tolerant of case in case a producer lowercases them. + total = 0 + for key, value in counts.items(): + if str(key).upper() in ("HIGH", "CRITICAL"): + total += int(value) + return total + + +def parse_status(status: dict | None) -> TrayState: + if not status: + return TrayState(False, 0, 0, None, "grey", "Unknown") + running = bool(status.get("running")) + total = int(status.get("total_alerts", 0)) + high = _high_count(status.get("counts") or {}) + last = status.get("last_alert") + if not running: + return TrayState(False, total, high, last, "grey", "Stopped") + if high: + return TrayState( + True, total, high, last, "amber", + f"Running — {high} high/critical of {total} alerts", + ) + if total: + return TrayState(True, total, high, last, "green", + f"Running — {total} alerts") + return TrayState(True, total, high, last, "green", "Running — no alerts") +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `python3 -m unittest tests.test_tray_state -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add enodia_sentinel/tray/__init__.py enodia_sentinel/tray/state.py tests/test_tray_state.py +git commit -m "Add tray status model + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: Action layer (`actions.py`) + +Drives the CLI and systemctl behind named functions with an injectable runner. No GUI imports. + +**Files:** +- Create: `enodia_sentinel/tray/actions.py` +- Test: `tests/test_tray_actions.py` + +**Interfaces:** +- Consumes: `enodia-sentinel status --json` (Task 1 added `check --json`). +- Produces: + - `ActionResult` dataclass: `ok: bool`, `message: str`. + - `dashboard_url(cfg) -> str` + - `fetch_status(runner=...) -> dict | None` + - `start_daemon(runner=...) -> ActionResult`, `stop_daemon(...)`, `restart_daemon(...)` + - `is_active(unit, runner=...) -> bool` + - `open_dashboard(cfg, runner=..., opener=...) -> ActionResult` + - `run_check(runner=...) -> ActionResult` + - Module constants `DAEMON_UNIT = "enodia-sentinel.service"`, `WEB_UNIT = "enodia-sentinel-web.service"`. + - Runner signature: `Callable[[Sequence[str], int], subprocess.CompletedProcess]` (args, timeout). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_tray_actions.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tray action layer: drives CLI + systemctl through an injectable runner.""" +import json +import subprocess +import unittest + +from enodia_sentinel.config import Config +from enodia_sentinel.tray import actions + + +def _proc(rc=0, out="", err=""): + return subprocess.CompletedProcess(args=[], returncode=rc, + stdout=out, stderr=err) + + +class _Recorder: + """Runner that returns scripted results and records the commands seen.""" + def __init__(self, results): + self._results = list(results) + self.calls = [] + + def __call__(self, cmd, timeout): + self.calls.append(list(cmd)) + return self._results.pop(0) + + +class TestActions(unittest.TestCase): + def test_dashboard_url_is_loopback_with_port(self): + cfg = Config() + cfg.web_port = 9999 + self.assertEqual(actions.dashboard_url(cfg), "https://127.0.0.1:9999/") + + def test_fetch_status_parses_json_even_on_nonzero_exit(self): + runner = _Recorder([_proc(rc=1, out=json.dumps({"running": True}))]) + self.assertEqual(actions.fetch_status(runner), {"running": True}) + + def test_fetch_status_returns_none_on_garbage(self): + runner = _Recorder([_proc(out="not json")]) + self.assertIsNone(actions.fetch_status(runner)) + + def test_start_daemon_ok(self): + runner = _Recorder([_proc(rc=0)]) + res = actions.start_daemon(runner) + self.assertTrue(res.ok) + self.assertEqual(runner.calls[0], + ["systemctl", "start", "enodia-sentinel.service"]) + + def test_stop_daemon_failure_carries_stderr_tail(self): + runner = _Recorder([_proc(rc=1, err="line1\nAccess denied")]) + res = actions.stop_daemon(runner) + self.assertFalse(res.ok) + self.assertIn("Access denied", res.message) + + def test_open_dashboard_starts_web_if_inactive(self): + # is-active -> inactive, then start -> ok + runner = _Recorder([_proc(out="inactive"), _proc(rc=0)]) + opened = [] + res = actions.open_dashboard(Config(), runner, opener=opened.append) + self.assertTrue(res.ok) + self.assertEqual(runner.calls[0], + ["systemctl", "is-active", "enodia-sentinel-web.service"]) + self.assertEqual(runner.calls[1], + ["systemctl", "start", "enodia-sentinel-web.service"]) + self.assertEqual(opened, ["https://127.0.0.1:8787/"]) + + def test_open_dashboard_skips_start_when_active(self): + runner = _Recorder([_proc(out="active")]) + opened = [] + res = actions.open_dashboard(Config(), runner, opener=opened.append) + self.assertTrue(res.ok) + self.assertEqual(len(runner.calls), 1) + self.assertEqual(opened, ["https://127.0.0.1:8787/"]) + + def test_run_check_counts_findings(self): + runner = _Recorder([_proc(out=json.dumps([{"signature": "rsh"}, + {"signature": "suid"}]))]) + res = actions.run_check(runner) + self.assertTrue(res.ok) + self.assertIn("2", res.message) + + def test_run_check_no_findings(self): + runner = _Recorder([_proc(out="[]")]) + res = actions.run_check(runner) + self.assertTrue(res.ok) + self.assertIn("no findings", res.message.lower()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest tests.test_tray_actions -v` +Expected: FAIL — `ModuleNotFoundError: enodia_sentinel.tray.actions`. + +- [ ] **Step 3: Implement `actions.py`** + +```python +# enodia_sentinel/tray/actions.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Action layer for the tray applet: drives the CLI and systemctl. + +No GUI imports. Every subprocess call is timeout-bounded and check=False so a +failed command degrades to an ActionResult rather than raising. +""" +from __future__ import annotations + +import json +import subprocess +import webbrowser +from dataclasses import dataclass +from typing import Callable, Sequence + +from ..config import Config + +Runner = Callable[[Sequence[str], int], "subprocess.CompletedProcess"] + +DAEMON_UNIT = "enodia-sentinel.service" +WEB_UNIT = "enodia-sentinel-web.service" + + +def _default_runner(cmd: Sequence[str], timeout: int) -> "subprocess.CompletedProcess": + try: + return subprocess.run(list(cmd), capture_output=True, text=True, + timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + return subprocess.CompletedProcess(list(cmd), 127, "", str(exc)) + + +@dataclass(frozen=True) +class ActionResult: + ok: bool + message: str + + +def dashboard_url(cfg: Config) -> str: + return f"https://127.0.0.1:{cfg.web_port}/" + + +def fetch_status(runner: Runner = _default_runner) -> "dict | None": + proc = runner(["enodia-sentinel", "status", "--json"], 10) + try: + data = json.loads(proc.stdout) + return data if isinstance(data, dict) else None + except (ValueError, TypeError): + return None + + +def _systemctl(action: str, unit: str, runner: Runner) -> ActionResult: + proc = runner(["systemctl", action, unit], 30) + if proc.returncode == 0: + return ActionResult(True, f"{unit}: {action} ok") + lines = (proc.stderr or proc.stdout or "").strip().splitlines() + detail = lines[-1] if lines else f"exit {proc.returncode}" + return ActionResult(False, f"{unit}: {action} failed — {detail}") + + +def start_daemon(runner: Runner = _default_runner) -> ActionResult: + return _systemctl("start", DAEMON_UNIT, runner) + + +def stop_daemon(runner: Runner = _default_runner) -> ActionResult: + return _systemctl("stop", DAEMON_UNIT, runner) + + +def restart_daemon(runner: Runner = _default_runner) -> ActionResult: + return _systemctl("restart", DAEMON_UNIT, runner) + + +def is_active(unit: str, runner: Runner = _default_runner) -> bool: + proc = runner(["systemctl", "is-active", unit], 10) + return proc.stdout.strip() == "active" + + +def open_dashboard(cfg: Config, runner: Runner = _default_runner, + opener: Callable[[str], object] = webbrowser.open) -> ActionResult: + if not is_active(WEB_UNIT, runner): + started = _systemctl("start", WEB_UNIT, runner) + if not started.ok: + return started + url = dashboard_url(cfg) + opener(url) + return ActionResult(True, f"Opened {url}") + + +def run_check(runner: Runner = _default_runner) -> ActionResult: + proc = runner(["enodia-sentinel", "check", "--json"], 120) + try: + alerts = json.loads(proc.stdout) + except (ValueError, TypeError): + return ActionResult(False, "Quick check failed to produce JSON") + count = len(alerts) if isinstance(alerts, list) else 0 + if count == 0: + return ActionResult(True, "Quick check: no findings") + return ActionResult(True, f"Quick check: {count} finding(s)") +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest tests.test_tray_actions -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add enodia_sentinel/tray/actions.py tests/test_tray_actions.py +git commit -m "Add tray action layer over CLI and systemctl + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: Desktop notification helper (`notify.py`) + +Show freedesktop notifications via `notify-send` when present. No GUI imports. + +**Files:** +- Create: `enodia_sentinel/tray/notify.py` +- Test: `tests/test_tray_notify.py` + +**Interfaces:** +- Produces: `notify(title: str, body: str, *, runner=None, which=shutil.which) -> bool` — returns `True` if `notify-send` was invoked, `False` if unavailable. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_tray_notify.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Desktop notification helper: notify-send when available, else no-op.""" +import subprocess +import unittest + +from enodia_sentinel.tray import notify + + +class TestNotify(unittest.TestCase): + def test_no_notify_send_is_noop(self): + calls = [] + + def runner(cmd): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0) + + used = notify.notify("t", "b", runner=runner, which=lambda _n: None) + self.assertFalse(used) + self.assertEqual(calls, []) + + def test_uses_notify_send_with_app_name(self): + calls = [] + + def runner(cmd): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0) + + used = notify.notify("Title", "Body", runner=runner, + which=lambda _n: "/usr/bin/notify-send") + self.assertTrue(used) + self.assertEqual(len(calls), 1) + self.assertIn("notify-send", calls[0][0]) + self.assertIn("Title", calls[0]) + self.assertIn("Body", calls[0]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest tests.test_tray_notify -v` +Expected: FAIL — `ModuleNotFoundError: enodia_sentinel.tray.notify`. + +- [ ] **Step 3: Implement `notify.py`** + +```python +# enodia_sentinel/tray/notify.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""Desktop notification helper. Prefers notify-send; no GUI imports.""" +from __future__ import annotations + +import shutil +import subprocess +from typing import Callable, Optional, Sequence + +Runner = Callable[[Sequence[str]], "subprocess.CompletedProcess"] + + +def _default_runner(cmd: Sequence[str]) -> "subprocess.CompletedProcess": + try: + return subprocess.run(list(cmd), capture_output=True, text=True, + timeout=10, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + return subprocess.CompletedProcess(list(cmd), 127, "", str(exc)) + + +def notify(title: str, body: str, *, runner: Optional[Runner] = None, + which: Callable[[str], object] = shutil.which) -> bool: + """Show a desktop notification. Returns True if notify-send was invoked.""" + if which("notify-send") is None: + return False + run = runner or _default_runner + run(["notify-send", "-a", "Enodia Sentinel", title, body]) + return True +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest tests.test_tray_notify -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add enodia_sentinel/tray/notify.py tests/test_tray_notify.py +git commit -m "Add tray desktop notification helper + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: pystray wiring + entry point + optional extra (`app.py`) + +The GUI shell. Thin — binds menu items to the action layer and runs a background poll. Not unit-tested (no headless GUI loop); verified by a manual smoke run and an import-guard test. + +**Files:** +- Create: `enodia_sentinel/tray/app.py` +- Create: `enodia_sentinel/tray/__main__.py` +- Modify: `pyproject.toml` (`[project.scripts]`, `[project.optional-dependencies]`) +- Test: `tests/test_tray_imports.py` + +**Interfaces:** +- Consumes: `parse_status` (Task 2); `actions.*` and `ActionResult` (Task 3); `notify.notify` (Task 4). +- Produces: `TrayApp(cfg)` with `.run()`; `main(argv=None) -> int`; console script `enodia-sentinel-tray = enodia_sentinel.tray.app:main`. + +- [ ] **Step 1: Implement `app.py`** + +```python +# enodia_sentinel/tray/app.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""System-tray applet wiring. Imports pystray/PIL (the [tray] extra). + +This module is the GUI shell only. All testable logic lives in state.py, +actions.py, and notify.py, which import no GUI libraries. +""" +from __future__ import annotations + +import threading +import time + +import pystray +from PIL import Image, ImageDraw + +from ..config import Config +from . import actions, notify +from .state import TrayState, parse_status + +REFRESH_SECONDS = 15 + +_COLORS = { + "green": (46, 160, 67), + "amber": (210, 153, 34), + "grey": (110, 110, 110), +} + + +def _make_image(color: str) -> "Image.Image": + rgb = _COLORS.get(color, _COLORS["grey"]) + img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.ellipse((8, 8, 56, 56), fill=rgb) + return img + + +class TrayApp: + def __init__(self, cfg: Config): + self.cfg = cfg + self.state: TrayState = parse_status(None) + self.icon = pystray.Icon( + "enodia-sentinel", + icon=_make_image(self.state.color), + title=self.state.tooltip, + menu=self._menu(), + ) + + def _menu(self) -> "pystray.Menu": + return pystray.Menu( + pystray.MenuItem(lambda _i: self.state.summary, None, enabled=False), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Open dashboard", self._on_open), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Start daemon", self._on_start), + pystray.MenuItem("Stop daemon", self._on_stop), + pystray.MenuItem("Restart daemon", self._on_restart), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Run quick check", self._on_check), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Quit", self._on_quit), + ) + + def _async(self, fn) -> None: + threading.Thread(target=fn, daemon=True).start() + + def _do(self, action) -> None: + res = action() + notify.notify("Enodia Sentinel", res.message) + self.refresh() + + def _on_open(self, _icon=None, _item=None): + self._async(lambda: self._do(lambda: actions.open_dashboard(self.cfg))) + + def _on_start(self, _icon=None, _item=None): + self._async(lambda: self._do(actions.start_daemon)) + + def _on_stop(self, _icon=None, _item=None): + self._async(lambda: self._do(actions.stop_daemon)) + + def _on_restart(self, _icon=None, _item=None): + self._async(lambda: self._do(actions.restart_daemon)) + + def _on_check(self, _icon=None, _item=None): + self._async(lambda: self._do(actions.run_check)) + + def _on_quit(self, _icon=None, _item=None): + self.icon.stop() + + def refresh(self) -> None: + self.state = parse_status(actions.fetch_status()) + self.icon.icon = _make_image(self.state.color) + self.icon.title = self.state.tooltip + self.icon.update_menu() + + def _poll_loop(self) -> None: + while True: + self.refresh() + time.sleep(REFRESH_SECONDS) + + def run(self) -> None: + def _setup(icon): + icon.visible = True + threading.Thread(target=self._poll_loop, daemon=True).start() + self.icon.run(setup=_setup) + + +def main(argv=None) -> int: + cfg = Config.load() + TrayApp(cfg).run() + return 0 +``` + +- [ ] **Step 2: Implement `__main__.py`** + +```python +# enodia_sentinel/tray/__main__.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""`python -m enodia_sentinel.tray` entry point.""" +import sys + +from .app import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 3: Add the console script and optional extra to `pyproject.toml`** + +Under `[project.scripts]` (add the second line): + +```toml +[project.scripts] +enodia-sentinel = "enodia_sentinel.cli:main" +enodia-sentinel-tray = "enodia_sentinel.tray.app:main" +``` + +Under `[project.optional-dependencies]` (add the `tray` line, keep `dev`): + +```toml +[project.optional-dependencies] +dev = ["pytest"] +tray = ["pystray", "Pillow"] +``` + +- [ ] **Step 4: Write the import-guard test** + +This proves the logic modules stay GUI-free (so the daemon can never accidentally pull GUI deps) and that `pyproject` wires the extra. + +```python +# tests/test_tray_imports.py +# SPDX-License-Identifier: GPL-3.0-or-later +"""The tray logic modules must not import GUI libs; pyproject wires the extra.""" +import tomllib +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TRAY = ROOT / "enodia_sentinel" / "tray" + + +class TestTrayBoundaries(unittest.TestCase): + def test_logic_modules_have_no_gui_imports(self): + for name in ("state.py", "actions.py", "notify.py"): + text = (TRAY / name).read_text() + self.assertNotIn("pystray", text, f"{name} must not import pystray") + self.assertNotIn("PIL", text, f"{name} must not import PIL") + + def test_logic_modules_import_without_gui_deps(self): + # These imports must succeed even though pystray/Pillow may be absent. + from enodia_sentinel.tray import actions, notify, state # noqa: F401 + + def test_pyproject_declares_tray_extra_and_script(self): + data = tomllib.loads((ROOT / "pyproject.toml").read_text()) + self.assertIn("tray", data["project"]["optional-dependencies"]) + self.assertEqual( + data["project"]["scripts"]["enodia-sentinel-tray"], + "enodia_sentinel.tray.app:main", + ) + # Core runtime deps stay empty. + self.assertEqual(data["project"]["dependencies"], []) +``` + +- [ ] **Step 5: Run the import-guard test** + +Run: `python3 -m unittest tests.test_tray_imports -v` +Expected: PASS. + +- [ ] **Step 6: Manual smoke run (human-in-the-loop)** + +Ensure GUI deps are present, then launch and eyeball the tray icon/menu: + +```bash +python3 -c "import pystray, PIL; print('gui deps present')" +python3 -m enodia_sentinel.tray +``` + +Expected: a tray icon appears (KDE StatusNotifier). Menu shows the status line, Open dashboard, Start/Stop/Restart, Run quick check, Quit. "Quit" removes the icon. If GUI deps are missing, `pip install -e '.[tray]'` first. This step is manual — record the result in the task notes; do not block the commit on a headless CI. + +- [ ] **Step 7: Run the full suite** + +Run: `python3 -m unittest discover -s tests -v` +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add enodia_sentinel/tray/app.py enodia_sentinel/tray/__main__.py pyproject.toml tests/test_tray_imports.py +git commit -m "Add pystray tray applet wiring and [tray] extra + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: Autostart `.desktop` file + packaging test + +Ship an opt-in autostart entry and assert its presence/correctness. + +**Files:** +- Create: `packaging/enodia-sentinel-tray.desktop` +- Test: `tests/test_packaging.py` (add a method to `TestArchPackaging` or a new test class) + +**Interfaces:** +- Produces: a freedesktop `.desktop` launcher with `Exec=enodia-sentinel-tray`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_packaging.py`: + +```python +class TestTrayDesktopEntry(unittest.TestCase): + def test_desktop_file_launches_tray_entry_point(self): + text = _read("packaging/enodia-sentinel-tray.desktop") + self.assertIn("[Desktop Entry]", text) + self.assertIn("Type=Application", text) + self.assertIn("Exec=enodia-sentinel-tray", text) + self.assertRegex(text, r"(?m)^Name=.*Sentinel") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest tests.test_packaging.TestTrayDesktopEntry -v` +Expected: FAIL — file not found. + +- [ ] **Step 3: Create the `.desktop` file** + +```ini +# packaging/enodia-sentinel-tray.desktop +[Desktop Entry] +Type=Application +Name=Enodia Sentinel Tray +Comment=Tray launcher for the Enodia Sentinel security agent +Exec=enodia-sentinel-tray +Icon=security-high +Terminal=false +Categories=System;Security;Utility; +X-GNOME-Autostart-enabled=true +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest tests.test_packaging.TestTrayDesktopEntry -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packaging/enodia-sentinel-tray.desktop tests/test_packaging.py +git commit -m "Add opt-in autostart desktop entry for the tray applet + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 7: Documentation + +Document the applet across the required doc set. No code; verified by review and the full suite still passing. + +**Files:** +- Modify: `README.md`, `docs/COMMAND_REFERENCE.md`, `docs/OPERATIONS.md`, `docs/PACKAGING.md` + +- [ ] **Step 1: README — add a "Desktop tray" subsection** + +Under the usage/quick-start area, add concise prose: + +```markdown +### Desktop tray (optional) + +A lightweight system-tray applet is available for desktop installs. It is an +optional frontend — the daemon and core agent remain stdlib-only. Install the +extra and launch it: + + pip install 'enodia-sentinel[tray]' + enodia-sentinel-tray + +The tray menu opens the local dashboard, starts/stops/restarts the daemon via +systemd, shows daemon status and alert count at a glance, and runs an on-demand +quick check (results shown as a desktop notification). Copy +`packaging/enodia-sentinel-tray.desktop` into `~/.config/autostart/` to launch +it on login. +``` + +- [ ] **Step 2: COMMAND_REFERENCE — document the entry point and `check --json`** + +Add an entry for `enodia-sentinel-tray` (no subcommands; GUI applet; requires the `[tray]` extra) and note that `enodia-sentinel check` now accepts `--json` (emits a JSON array of alert dicts). Match the file's existing formatting. + +- [ ] **Step 3: OPERATIONS — note the tray as a desktop convenience** + +Add a short paragraph: the tray drives the same `systemctl` units operators use directly; daemon control prompts for polkit auth; the applet is purely a convenience and is not required for the daemon to run. + +- [ ] **Step 4: PACKAGING — document the extra and autostart file** + +Note the optional `[tray]` extra (`pystray`, `Pillow`), the `enodia-sentinel-tray` console script, and the `packaging/enodia-sentinel-tray.desktop` autostart file (opt-in; not installed to a system autostart path by default). + +- [ ] **Step 5: Run the full suite** + +Run: `python3 -m unittest discover -s tests -v` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add README.md docs/COMMAND_REFERENCE.md docs/OPERATIONS.md docs/PACKAGING.md +git commit -m "Document the desktop tray applet and check --json + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Notes for the implementer + +- Run the whole suite after each task: `python3 -m unittest discover -s tests -v`. +- The CLAUDE.md documentation checklist is satisfied by Task 7; if you touch behavior beyond this plan, update the corresponding docs too. +- Keep `state.py`/`actions.py`/`notify.py` GUI-free — Task 5's import-guard test enforces it. +- The pystray GUI loop is intentionally not unit-tested; rely on the Task 5 manual smoke step.