From bcd8d4da1a21354ba46284ac83c7e10fd0ada01d Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 02:45:17 -0400 Subject: [PATCH] feat: add main daemon loop wiring detection, overlay, and sound Made-with: Cursor --- src/cursor_flasher/daemon.py | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/cursor_flasher/daemon.py diff --git a/src/cursor_flasher/daemon.py b/src/cursor_flasher/daemon.py new file mode 100644 index 0000000..39c5620 --- /dev/null +++ b/src/cursor_flasher/daemon.py @@ -0,0 +1,99 @@ +"""Main daemon loop that ties detection, state machine, overlay, and sound together.""" +import logging +import signal +import time + +from Cocoa import NSApplication, NSRunLoop, NSDate + +from cursor_flasher.config import Config, load_config +from cursor_flasher.detector import CursorDetector +from cursor_flasher.overlay import OverlayManager +from cursor_flasher.sound import play_alert +from cursor_flasher.state import FlasherState, StateMachine + +logger = logging.getLogger("cursor_flasher") + + +class FlasherDaemon: + def __init__(self, config: Config): + self.config = config + self.state_machine = StateMachine(cooldown=config.cooldown) + self.detector = CursorDetector() + self.overlay = OverlayManager(config) + self._running = False + self._waiting_since: float | None = None + + def run(self) -> None: + """Run the main loop. Blocks until stopped.""" + NSApplication.sharedApplication() + self._running = True + + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + + logger.info("Cursor Flasher daemon started") + + while self._running: + self._tick() + NSRunLoop.currentRunLoop().runUntilDate_( + NSDate.dateWithTimeIntervalSinceNow_(self.config.poll_interval) + ) + + self.overlay.hide() + logger.info("Cursor Flasher daemon stopped") + + def stop(self) -> None: + self._running = False + + def _tick(self) -> None: + signals = self.detector.poll() + + if signals is None: + if self.state_machine.state == FlasherState.WAITING_FOR_USER: + self.state_machine.dismiss() + self.overlay.hide() + self._waiting_since = None + return + + changed = self.state_machine.update( + agent_working=signals.agent_working, + approval_needed=signals.approval_needed, + ) + + if not changed: + if ( + self.state_machine.state == FlasherState.WAITING_FOR_USER + and self._waiting_since is not None + ): + elapsed = time.monotonic() - self._waiting_since + if elapsed > self.config.auto_dismiss: + self.state_machine.dismiss() + self.overlay.hide() + self._waiting_since = None + return + + match self.state_machine.state: + case FlasherState.WAITING_FOR_USER: + pid = self.detector._pid + if pid is not None: + self.overlay.show(pid) + play_alert(self.config) + self._waiting_since = time.monotonic() + case FlasherState.AGENT_WORKING | FlasherState.IDLE: + self.overlay.hide() + self._waiting_since = None + + def _handle_signal(self, signum, frame): + logger.info(f"Received signal {signum}, shutting down") + self.stop() + + +def run_daemon() -> None: + """Entry point for the daemon.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + config = load_config() + daemon = FlasherDaemon(config) + daemon.run()