Add tray applet implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2e7bf8b248
commit
5e560f4307
1 changed files with 986 additions and 0 deletions
986
docs/superpowers/plans/2026-06-30-tray-applet.md
Normal file
986
docs/superpowers/plans/2026-06-30-tray-applet.md
Normal file
|
|
@ -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_<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**
|
||||||
|
|
||||||
|
```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 <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**
|
||||||
|
|
||||||
|
```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 <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**
|
||||||
|
|
||||||
|
```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 <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**
|
||||||
|
|
||||||
|
```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 <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`**
|
||||||
|
|
||||||
|
```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 <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`:
|
||||||
|
|
||||||
|
```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 <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:
|
||||||
|
|
||||||
|
```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 <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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue