From d95ee18f7e8f025f4efd751f1726cb86e7b61688 Mon Sep 17 00:00:00 2001 From: imaznation Date: Thu, 21 May 2026 12:36:19 -0700 Subject: [PATCH] display pinning: park on sleep, retry-on-wake with backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom on a multi-monitor kiosk setup: after sleep/wake or screen-lock/unlock, the kiosk window could end up on the main display instead of the configured secondary target. macOS surfaces the wake notifications *before* NSScreen.screens reflects the secondary display being re-enumerated (USB-C / DisplayPort handshakes take a few seconds), so a single repin call right at wake-time finds no target and falls back to whatever screen exists, typically main. This change makes window pinning robust across the wake transition: Wake side: - Observe NSApplication.didChangeScreenParameters plus NSWorkspace.didWake, screensDidWake, sessionDidBecomeActive. Any of those triggers `repinDashboard`. - `repinDashboard` schedules `retryRepin` with a backoff [0, 0.5, 1.5, 3, 6, 12, 30, 60, 60, 60s] — try right away, then progressively wait for the target to re-enumerate. - Each attempt: if the configured target screen exists (or no specific target is configured), `show()` the window; otherwise `hide()` (orderOut) so it stays parked off-screen and retry. Sleep / lock side: - Observe NSWorkspace.willSleep, screensDidSleep, sessionDidResignActive — any of those calls `parkDashboard` which orderOuts the window. That way the wake transition starts with the window already off-screen instead of macOS relocating it to main before our wake handler fires. DashboardWindowController gains a `hide()` method to support both sides of this cycle without releasing the window. The backoff hard ceiling (10 attempts, ~ 4 minutes) means a target that doesn't come back leaves the window parked rather than the user finding it on the wrong screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/EdgeControl/App/EdgeControlApp.swift | 108 +++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/Sources/EdgeControl/App/EdgeControlApp.swift b/Sources/EdgeControl/App/EdgeControlApp.swift index 2af62ed..935bdb4 100644 --- a/Sources/EdgeControl/App/EdgeControlApp.swift +++ b/Sources/EdgeControl/App/EdgeControlApp.swift @@ -54,6 +54,17 @@ final class DashboardWindowController { dashboardWindow.orderFrontRegardless() dashboardWindow.makeKeyAndOrderFront(nil) } + + /// Hide the dashboard window without releasing it — used when the + /// target display isn't currently enumerated (wake-from-idle race, + /// monitor unplug, etc.) or before the system is about to sleep / + /// the session locks. Keeps the window parked off-screen so it + /// doesn't flash onto the main display while waiting for the + /// configured target to come back. + func hide() { + guard let window else { return } + window.orderOut(nil) + } } @MainActor @@ -91,6 +102,103 @@ final class EdgeControlAppDelegate: NSObject, NSApplicationDelegate { pluginManager: pluginManager ) NSApp.activate(ignoringOtherApps: true) + + // Re-pin to the configured display whenever the screen topology + // changes (target display sleep/wake, monitor unplug, dock change), + // the Mac wakes from idle, or the user session unlocks. Without + // this the kiosk window migrates to the main display when the + // target sleeps and never comes back when it wakes. + let nc = NotificationCenter.default + nc.addObserver( + self, + selector: #selector(repinDashboard), + name: NSApplication.didChangeScreenParametersNotification, + object: nil, + ) + let wsNc = NSWorkspace.shared.notificationCenter + for name in [ + NSWorkspace.didWakeNotification, + NSWorkspace.screensDidWakeNotification, + NSWorkspace.sessionDidBecomeActiveNotification, + ] { + wsNc.addObserver( + self, + selector: #selector(repinDashboard), + name: name, + object: nil, + ) + } + + // Pre-emptively park the window off-screen BEFORE sleep / lock, + // so when the system wakes macOS can't punt the window to the + // main display while we wait for the target screen to re-enumerate. + // The wake-side repin (above) brings it back when ready. + for name in [ + NSWorkspace.willSleepNotification, + NSWorkspace.screensDidSleepNotification, + NSWorkspace.sessionDidResignActiveNotification, + ] { + wsNc.addObserver( + self, + selector: #selector(parkDashboard), + name: name, + object: nil, + ) + } + } + + /// On wake / session-active, the target display often isn't yet in + /// NSScreen.screens — macOS takes a few seconds to re-enumerate after + /// USB-C / DisplayPort handshakes complete. A single repin call here + /// would find no target in the screen list and fall back to the main + /// display. Instead we retry on a short backoff until the target + /// screen appears or we've burned a reasonable budget. + @objc private func repinDashboard() { + retryRepin(attempt: 0) + } + + /// Hide the window pre-emptively when the screen is about to sleep + /// or the session resigns active (lock). This prevents macOS from + /// briefly relocating the window to the main display before our + /// wake handler fires. + @objc private func parkDashboard() { + dashboardWindowController.hide() + } + + // Backoff schedule: try right away, then doubling out to 60s. Once the + // target screen appears, the next attempt places + stops retrying. + private static let repinAttemptDelays: [TimeInterval] = [ + 0, 0.5, 1.5, 3, 6, 12, 30, 60, 60, 60, + ] + + private func retryRepin(attempt: Int) { + // Hard ceiling at 10 attempts (~ 4 minutes) — beyond that something + // is wrong with the target display that won't resolve without user + // action. The window stays hidden meanwhile rather than parking on + // the wrong display. + guard attempt < Self.repinAttemptDelays.count else { return } + let delay = Self.repinAttemptDelays[attempt] + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + let targetName = self.model.selectedDisplay?.name + let targetPresent = NSScreen.screens.contains { $0.localizedName == targetName } + if targetPresent || targetName == nil { + // Target available (or no specific target configured) → + // place + done. + self.dashboardWindowController.show( + model: self.model, + layoutEngine: self.layoutEngine, + registry: self.registry, + history: self.history, + pluginManager: self.pluginManager, + ) + } else { + // Target not enumerated yet — hide the window so it doesn't + // flash onto the main display, and try again later. + self.dashboardWindowController.hide() + self.retryRepin(attempt: attempt + 1) + } + } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {