enodia-sentinal/docs/superpowers/plans/2026-06-30-tray-applet.md
Luna 5e560f4307 Add tray applet implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 05:49:06 -07:00

33 KiB

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_<name> -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

# 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:

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=...)):

    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)):

    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
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 <noreply@anthropic.com>"

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

# 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
# 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
# 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
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 <noreply@anthropic.com>"

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

# 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
# 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
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 <noreply@anthropic.com>"

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

# 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
# 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
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 <noreply@anthropic.com>"

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

# 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
# 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):

[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):

[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.

# 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:

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
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 <noreply@anthropic.com>"

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:

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
# 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
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 <noreply@anthropic.com>"

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:

### 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
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 <noreply@anthropic.com>"

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.