diff --git a/src/cursor_flasher/daemon.py b/src/cursor_flasher/daemon.py index 5700f7e..4c4d132 100644 --- a/src/cursor_flasher/daemon.py +++ b/src/cursor_flasher/daemon.py @@ -6,7 +6,10 @@ import signal import socket import time -from Cocoa import NSApplication, NSRunLoop, NSDate +from Cocoa import ( + NSApplication, NSRunLoop, NSDate, + NSNotificationCenter, NSObject, NSScreen, NSWorkspace, +) from Quartz import ( CGEventSourceSecondsSinceLastEventType, kCGEventSourceStateHIDSystemState, @@ -34,6 +37,26 @@ SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock") INPUT_DISMISS_GRACE = 0.5 +class _DisplayObserver(NSObject): + """Listens for macOS display-change and wake notifications. + + Registering for NSApplicationDidChangeScreenParametersNotification forces + AppKit to keep NSScreen.screens() current in long-running daemon processes. + Without this, the screen list can go stale after sleep/wake cycles, causing + modes like "allscreens" to miss external displays. + """ + + def screenParametersChanged_(self, notification): + screens = NSScreen.screens() + count = len(screens) if screens else 0 + logger.info("Display configuration changed — %d screen(s) detected", count) + + def workspaceDidWake_(self, notification): + screens = NSScreen.screens() + count = len(screens) if screens else 0 + logger.info("System woke from sleep — %d screen(s) detected", count) + + def _get_system_appearance() -> str: """Return "dark" or "light" based on the current macOS appearance.""" app = NSApplication.sharedApplication() @@ -71,11 +94,13 @@ class FlasherDaemon: self._pending_approvals: dict[str, _PendingApproval] = {} self._active_pulses: dict[str, _ActivePulse] = {} self._cursor_was_frontmost: bool = False + self._display_observer: NSObject | None = None def run(self) -> None: NSApplication.sharedApplication() self._running = True self._setup_socket() + self._setup_display_notifications() signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal) @@ -111,6 +136,34 @@ class FlasherDaemon: self._server.listen(5) self._server.setblocking(False) + def _setup_display_notifications(self) -> None: + """Subscribe to macOS display-change and wake events. + + This is required for NSScreen.screens() to stay current in a + long-running daemon. Without these observers, AppKit may not process + screen-configuration changes after sleep/wake, leaving the screen + list stale until the process is restarted. + """ + self._display_observer = _DisplayObserver.alloc().init() + + NSNotificationCenter.defaultCenter().addObserver_selector_name_object_( + self._display_observer, + "screenParametersChanged:", + "NSApplicationDidChangeScreenParametersNotification", + None, + ) + + NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_( + self._display_observer, + "workspaceDidWake:", + "NSWorkspaceDidWakeNotification", + None, + ) + + screens = NSScreen.screens() + count = len(screens) if screens else 0 + logger.info("Display notifications registered — %d screen(s) currently", count) + def _check_socket(self) -> None: if self._server is None: return @@ -313,6 +366,12 @@ class FlasherDaemon: return [window_frame] def _cleanup(self) -> None: + if self._display_observer is not None: + NSNotificationCenter.defaultCenter().removeObserver_(self._display_observer) + NSWorkspace.sharedWorkspace().notificationCenter().removeObserver_( + self._display_observer + ) + self._display_observer = None self.overlay.hide() if self._server is not None: self._server.close()