# 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