From 1451cf7d58fd9457301c80c92b9d4e3a26ffbb33 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 3 Jul 2026 07:37:39 -0700 Subject: [PATCH] Add pystray tray applet wiring and [tray] extra Co-Authored-By: Claude Fable 5 --- enodia_sentinel/tray/__main__.py | 8 +++ enodia_sentinel/tray/app.py | 109 +++++++++++++++++++++++++++++++ pyproject.toml | 2 + tests/test_tray_imports.py | 30 +++++++++ 4 files changed, 149 insertions(+) create mode 100644 enodia_sentinel/tray/__main__.py create mode 100644 enodia_sentinel/tray/app.py create mode 100644 tests/test_tray_imports.py diff --git a/enodia_sentinel/tray/__main__.py b/enodia_sentinel/tray/__main__.py new file mode 100644 index 0000000..92093ce --- /dev/null +++ b/enodia_sentinel/tray/__main__.py @@ -0,0 +1,8 @@ +# 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()) diff --git a/enodia_sentinel/tray/app.py b/enodia_sentinel/tray/app.py new file mode 100644 index 0000000..f1bd3e9 --- /dev/null +++ b/enodia_sentinel/tray/app.py @@ -0,0 +1,109 @@ +# 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 diff --git a/pyproject.toml b/pyproject.toml index 1c89d5f..0de2d5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,11 @@ dependencies = [] [project.scripts] enodia-sentinel = "enodia_sentinel.cli:main" +enodia-sentinel-tray = "enodia_sentinel.tray.app:main" [project.optional-dependencies] dev = ["pytest"] +tray = ["pystray", "Pillow"] [tool.setuptools.packages.find] include = ["enodia_sentinel*"] diff --git a/tests/test_tray_imports.py b/tests/test_tray_imports.py new file mode 100644 index 0000000..10dc5de --- /dev/null +++ b/tests/test_tray_imports.py @@ -0,0 +1,30 @@ +# 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"], [])