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) + } }