From 7c1ba02aaa4cdca05387a2e01d43015e90e264b3 Mon Sep 17 00:00:00 2001 From: imaznation Date: Thu, 21 May 2026 12:42:34 -0700 Subject: [PATCH] WindowPlacement: opt-in strictMonitorAffinity (default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use case: kiosk dashboards driven by a secondary display (e.g. a touchscreen panel) that intermittently sleeps or re-handshakes USB-C. Today's placement chain falls back to "first non-main screen" and then to NSScreen.main when the configured target isn't enumerated — useful in the common case, but on a multi-monitor desk it means the kiosk window briefly lands on the user's main work display every time the secondary panel sleeps. Once it's there, macOS often keeps it there across the next wake. New: GlobalSettings.strictMonitorAffinity (default false → upstream behavior preserved). When set to true: - WindowPlacement.configure only accepts non-main screens. A named display must match a non-main screen exactly; if no name is set, the first non-main screen wins. - If no eligible non-main screen exists (target asleep / unplugged / single-monitor system), configure returns false without modifying the window. The caller (EdgeControlApp) interprets that as "park off-screen" via orderOut, so the kiosk window never surfaces on the user's main display. API changes: - configure now @discardableResult Bool. Existing call sites that ignore the return value compile unchanged. - strictMonitorAffinity is a defaulted parameter (false), so callers that don't care about the new behavior keep the same signature. - GlobalSettings gains a custom Decodable + Encodable so existing layout.json files (no strictMonitorAffinity key) keep decoding cleanly with the field defaulting to false. No behavior change for users who don't flip the toggle. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/EdgeControl/App/EdgeControlApp.swift | 16 ++++-- Sources/EdgeControl/App/WindowPlacement.swift | 55 +++++++++++++++---- Sources/EdgeControl/Models/LayoutConfig.swift | 39 +++++++++++++ 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/Sources/EdgeControl/App/EdgeControlApp.swift b/Sources/EdgeControl/App/EdgeControlApp.swift index 2af62ed..6a62362 100644 --- a/Sources/EdgeControl/App/EdgeControlApp.swift +++ b/Sources/EdgeControl/App/EdgeControlApp.swift @@ -45,14 +45,22 @@ final class DashboardWindowController { ) } - WindowPlacement.configure( + let placed = WindowPlacement.configure( dashboardWindow, display: model.selectedDisplay, - kioskMode: layoutEngine.document.globalSettings.kioskMode + kioskMode: layoutEngine.document.globalSettings.kioskMode, + strictMonitorAffinity: layoutEngine.document.globalSettings.strictMonitorAffinity ) - dashboardWindow.orderFrontRegardless() - dashboardWindow.makeKeyAndOrderFront(nil) + if placed { + dashboardWindow.orderFrontRegardless() + dashboardWindow.makeKeyAndOrderFront(nil) + } else { + // strictMonitorAffinity is on AND no eligible non-main screen + // exists — keep the window parked off-screen rather than + // surfacing it on the main display. + dashboardWindow.orderOut(nil) + } } } diff --git a/Sources/EdgeControl/App/WindowPlacement.swift b/Sources/EdgeControl/App/WindowPlacement.swift index fa211e5..d6846e9 100644 --- a/Sources/EdgeControl/App/WindowPlacement.swift +++ b/Sources/EdgeControl/App/WindowPlacement.swift @@ -2,34 +2,65 @@ import AppKit import SwiftUI public enum WindowPlacement { - @MainActor public static func configure( + /// Place a window per the kiosk-mode / display configuration. + /// + /// Returns true if a target screen was found and the window was + /// placed; false if no eligible target exists (caller can decide + /// whether to hide the window or accept the no-op). + /// + /// `strictMonitorAffinity` (default false → preserves existing + /// behavior): when true and kiosk mode is on, the placement + /// pipeline NEVER falls back to NSScreen.main and only accepts a + /// non-main screen. If a target display is named, it must match a + /// non-main screen exactly; if no name is set, the first non-main + /// screen wins. If neither yields a hit (the configured target is + /// asleep / unplugged, or the system is single-monitor), this + /// returns false WITHOUT touching the window — useful when the + /// caller wants to keep the window parked off-screen rather than + /// surfacing it on the main display. + @MainActor + @discardableResult + public static func configure( _ window: NSWindow?, display: DisplayDescriptor?, - kioskMode: Bool - ) { - guard let window else { return } + kioskMode: Bool, + strictMonitorAffinity: Bool = false + ) -> Bool { + guard let window else { return false } if !kioskMode { - // Window mode: standard resizable window + // Window mode: standard resizable window, no screen affinity. window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.level = .normal window.collectionBehavior = [] - return + return true } - // Kiosk mode: full-screen borderless on selected display - let targetScreen = NSScreen.screens.first { screen in - guard let displayName = display?.name else { return false } - return screen.localizedName == displayName - } ?? NSScreen.screens.first { $0 != NSScreen.main } ?? NSScreen.main + let targetScreen: NSScreen? = { + if strictMonitorAffinity { + // Strict: only non-main screens are eligible, full stop. + // No silent fallback to main. + let nonMain = NSScreen.screens.filter { $0 != NSScreen.main } + if let displayName = display?.name { + return nonMain.first { $0.localizedName == displayName } + } + return nonMain.first + } + // Non-strict (default): named match → first non-main → main. + return NSScreen.screens.first { screen in + guard let displayName = display?.name else { return false } + return screen.localizedName == displayName + } ?? NSScreen.screens.first { $0 != NSScreen.main } ?? NSScreen.main + }() - guard let screen = targetScreen else { return } + guard let screen = targetScreen else { return false } window.styleMask = [.borderless] window.level = .statusBar window.collectionBehavior = [.canJoinAllSpaces, .stationary] window.setFrame(screen.frame, display: true) window.orderFrontRegardless() + return true } } diff --git a/Sources/EdgeControl/Models/LayoutConfig.swift b/Sources/EdgeControl/Models/LayoutConfig.swift index a3c7ef4..947f0d0 100644 --- a/Sources/EdgeControl/Models/LayoutConfig.swift +++ b/Sources/EdgeControl/Models/LayoutConfig.swift @@ -119,6 +119,14 @@ public struct GlobalSettings: Codable, Sendable { public var kioskMode: Bool public var launchAtLogin: Bool public var debugMode: Bool + /// When true, the kiosk window NEVER falls back to NSScreen.main if + /// the configured selectedDisplayName isn't currently enumerated + /// (e.g. the secondary display is asleep, unplugged, or still + /// re-handshaking after wake). Instead the window stays parked + /// off-screen until the target reappears. Default false preserves + /// the existing fallback-to-non-main-then-main chain in + /// WindowPlacement.configure. + public var strictMonitorAffinity: Bool public var theme: ThemeSettings public init( @@ -126,12 +134,43 @@ public struct GlobalSettings: Codable, Sendable { kioskMode: Bool = true, launchAtLogin: Bool = false, debugMode: Bool = false, + strictMonitorAffinity: Bool = false, theme: ThemeSettings = ThemeSettings() ) { self.selectedDisplayName = selectedDisplayName self.kioskMode = kioskMode self.launchAtLogin = launchAtLogin self.debugMode = debugMode + self.strictMonitorAffinity = strictMonitorAffinity self.theme = theme } + + enum CodingKeys: String, CodingKey { + case selectedDisplayName, kioskMode, launchAtLogin, debugMode + case strictMonitorAffinity, theme + } + + // Custom Decodable so existing layout.json files (which don't carry + // strictMonitorAffinity) keep decoding. Synthesized Decodable would + // throw on a missing key even with a struct-field default. Other + // fields keep their normal decoding behavior. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + selectedDisplayName = try c.decodeIfPresent(String.self, forKey: .selectedDisplayName) + kioskMode = try c.decodeIfPresent(Bool.self, forKey: .kioskMode) ?? true + launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false + debugMode = try c.decodeIfPresent(Bool.self, forKey: .debugMode) ?? false + strictMonitorAffinity = try c.decodeIfPresent(Bool.self, forKey: .strictMonitorAffinity) ?? false + theme = try c.decodeIfPresent(ThemeSettings.self, forKey: .theme) ?? ThemeSettings() + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(selectedDisplayName, forKey: .selectedDisplayName) + try c.encode(kioskMode, forKey: .kioskMode) + try c.encode(launchAtLogin, forKey: .launchAtLogin) + try c.encode(debugMode, forKey: .debugMode) + try c.encode(strictMonitorAffinity, forKey: .strictMonitorAffinity) + try c.encode(theme, forKey: .theme) + } }