"""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: result = self.detector.poll() if result 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=result.signals.agent_working, approval_needed=result.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: logger.info("Auto-dismissing after timeout") self.state_machine.dismiss() self.overlay.hide() self._waiting_since = None return logger.info("State → %s", self.state_machine.state.value) match self.state_machine.state: case FlasherState.WAITING_FOR_USER: if result.active_windows: logger.info( "Showing overlay on %d window(s)", len(result.active_windows) ) self.overlay.show(result.active_windows) 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): if not self._running: return logger.info("Received signal %d, shutting down", signum) 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()