Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions Sources/EdgeControl/App/EdgeControlApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
55 changes: 43 additions & 12 deletions Sources/EdgeControl/App/WindowPlacement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
39 changes: 39 additions & 0 deletions Sources/EdgeControl/Models/LayoutConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,58 @@ 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(
selectedDisplayName: String? = nil,
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)
}
}