Add pystray tray applet wiring and [tray] extra

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-03 07:37:39 -07:00
parent 2fce7d412a
commit 1451cf7d58
4 changed files with 149 additions and 0 deletions

View file

@ -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())

109
enodia_sentinel/tray/app.py Normal file
View file

@ -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

View file

@ -23,9 +23,11 @@ dependencies = []
[project.scripts] [project.scripts]
enodia-sentinel = "enodia_sentinel.cli:main" enodia-sentinel = "enodia_sentinel.cli:main"
enodia-sentinel-tray = "enodia_sentinel.tray.app:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest"] dev = ["pytest"]
tray = ["pystray", "Pillow"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["enodia_sentinel*"] include = ["enodia_sentinel*"]

View file

@ -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"], [])