diff --git a/Crisp/App/AppDelegate.swift b/Crisp/App/AppDelegate.swift
index 89c5638..e124780 100644
--- a/Crisp/App/AppDelegate.swift
+++ b/Crisp/App/AppDelegate.swift
@@ -697,6 +697,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
EdgeCrossingRow()
DisconnectBuiltinRow()
+ BatchDisplayConnectionView()
ExpandableRowStateful(icon: "display.2", iconActive: false,
label: "Virtual Displays", state: state, key: \.showVirtualDisplays)
}
diff --git a/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/Contents.json b/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/Contents.json
new file mode 100644
index 0000000..34e0d6d
--- /dev/null
+++ b/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "ExternalDisplayConnection.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/ExternalDisplayConnection.svg b/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/ExternalDisplayConnection.svg
new file mode 100644
index 0000000..b273a0d
--- /dev/null
+++ b/Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/ExternalDisplayConnection.svg
@@ -0,0 +1,15 @@
+
diff --git a/Crisp/Models/DDCOperationQueuePool.swift b/Crisp/Models/DDCOperationQueuePool.swift
index 6659ae4..aa28662 100644
--- a/Crisp/Models/DDCOperationQueuePool.swift
+++ b/Crisp/Models/DDCOperationQueuePool.swift
@@ -44,7 +44,7 @@ final class DDCOperationQueuePool: @unchecked Sendable {
/// Parks every per-display queue, present and future, until the returned closure
/// runs; `onIdle` fires once queued work drains, so the caller knows I2C is free.
/// Each queue gives up after `timeout` so a lost release cannot wedge DDC.
- func hold(timeout: TimeInterval, onIdle: @escaping () -> Void) -> () -> Void {
+ func hold(timeout: TimeInterval, onIdle: @escaping () -> Void) -> @Sendable () -> Void {
let hold = Hold(timeout: timeout)
lock.lock()
activeHolds.append(hold)
diff --git a/Crisp/Models/DisplayConfigurationGate.swift b/Crisp/Models/DisplayConfigurationGate.swift
new file mode 100644
index 0000000..e53de47
--- /dev/null
+++ b/Crisp/Models/DisplayConfigurationGate.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+/// Unlike the UI timeout, this lease ends only when the blocking transaction finishes.
+final class DisplayConfigurationGate: @unchecked Sendable {
+ private let lock = NSLock()
+ private var pending = false
+
+ var isPending: Bool { lock.withLock { pending } }
+
+ func begin() -> Bool {
+ lock.withLock {
+ guard !pending else { return false }
+ pending = true
+ return true
+ }
+ }
+
+ func finish() {
+ lock.withLock { pending = false }
+ }
+}
diff --git a/Crisp/Models/DisplayConnectionPlan.swift b/Crisp/Models/DisplayConnectionPlan.swift
new file mode 100644
index 0000000..03ed7c0
--- /dev/null
+++ b/Crisp/Models/DisplayConnectionPlan.swift
@@ -0,0 +1,36 @@
+struct DisplayConnectionPlan: Equatable {
+ enum Action: Equatable {
+ case toggleAll
+ case reconnectAll
+ case disconnectAll
+ }
+
+ struct Display {
+ let uuid: String
+ var isBuiltin = false
+ var isMain = false
+ var isVirtual = false
+ var isActive = true
+ var name = ""
+ var isAvailable = true
+ }
+
+ let reconnectUUIDs: [String]
+ let disconnectUUIDs: [String]
+
+ static func make(action: Action, connected: [Display], disconnected: [String], builtinUUIDs: Set = []) -> Self {
+ var seen: Set = []
+ let held = disconnected.filter { seen.insert($0).inserted }
+ if action == .reconnectAll || (action == .toggleAll && !held.isEmpty) {
+ let builtins = builtinUUIDs.union(connected.filter(\.isBuiltin).map(\.uuid))
+ return Self(reconnectUUIDs: held.filter { !builtins.contains($0) }, disconnectUUIDs: [])
+ }
+ seen = Set(held)
+ let physical = connected.filter { !$0.isVirtual && seen.insert($0.uuid).inserted }
+ let active = physical.filter(\.isActive)
+ guard let keeper = active.first(where: { $0.isBuiltin }) ?? active.first(where: { $0.isMain }) ?? active.first else {
+ return Self(reconnectUUIDs: [], disconnectUUIDs: [])
+ }
+ return Self(reconnectUUIDs: [], disconnectUUIDs: physical.filter { !$0.isBuiltin && $0.uuid != keeper.uuid }.map(\.uuid))
+ }
+}
diff --git a/Crisp/Models/DisplayConnectionQueue.swift b/Crisp/Models/DisplayConnectionQueue.swift
new file mode 100644
index 0000000..7453979
--- /dev/null
+++ b/Crisp/Models/DisplayConnectionQueue.swift
@@ -0,0 +1,19 @@
+@MainActor
+final class DisplayConnectionQueue {
+ private var tail: Task?
+ private var generation: UInt64 = 0
+
+ func run(_ operation: @escaping @MainActor () async -> Output) async -> Output {
+ let previous = tail
+ let task = Task { @MainActor in
+ await previous?.value
+ return await operation()
+ }
+ generation &+= 1
+ let requestGeneration = generation
+ tail = Task { @MainActor in _ = await task.value }
+ let result = await task.value
+ if generation == requestGeneration { tail = nil }
+ return result
+ }
+}
diff --git a/Crisp/Models/ExternalDisplayConnectionState.swift b/Crisp/Models/ExternalDisplayConnectionState.swift
new file mode 100644
index 0000000..da4df92
--- /dev/null
+++ b/Crisp/Models/ExternalDisplayConnectionState.swift
@@ -0,0 +1,39 @@
+struct ExternalDisplayConnectionState {
+ struct Entry: Identifiable, Equatable {
+ let id: String
+ let name: String
+ let isConnected: Bool
+ let isAvailable: Bool
+ let isProtected: Bool
+ }
+
+ let entries: [Entry]
+ var columnCount: Int { min(4, max(1, entries.count)) }
+ var rowCount: Int { (entries.count + columnCount - 1) / columnCount }
+ var hasProtectedDisplay: Bool { entries.contains { $0.isProtected } }
+ var isOn: Bool { entries.contains { $0.isConnected && !$0.isProtected } }
+ var canReconnect: Bool { entries.contains { !$0.isConnected && $0.isAvailable } }
+ var canDisconnect: Bool { entries.contains { $0.isConnected && !$0.isProtected } }
+
+ static func make(connected: [DisplayConnectionPlan.Display], disconnected: [DisplayConnectionPlan.Display],
+ canRestoreBuiltin: Bool = false) -> Self {
+ let held = Set(disconnected.map(\.uuid))
+ let physical = connected.filter { !$0.isVirtual && !held.contains($0.uuid) }
+ let active = physical.filter(\.isActive)
+ let keeper = active.first(where: { $0.isBuiltin }) ?? active.first(where: { $0.isMain }) ?? active.first
+ let protectedUUID = keeper?.isBuiltin == false && !canRestoreBuiltin ? keeper?.uuid : nil
+ var seen: Set = []
+ let external = (physical + disconnected).filter {
+ !$0.isBuiltin && !$0.isVirtual && seen.insert($0.uuid).inserted
+ }.sorted { $0.uuid < $1.uuid }
+ // UUID ordering keeps names in place when WindowServer changes the online list.
+ let counts = Dictionary(grouping: external, by: \.name).mapValues(\.count)
+ var occurrence: [String: Int] = [:]
+ return Self(entries: external.map { display in
+ occurrence[display.name, default: 0] += 1
+ let suffix = counts[display.name, default: 0] > 1 ? " (\(occurrence[display.name, default: 1]))" : ""
+ return Entry(id: display.uuid, name: display.name + suffix, isConnected: !held.contains(display.uuid),
+ isAvailable: display.isAvailable, isProtected: display.uuid == protectedUUID)
+ })
+ }
+}
diff --git a/Crisp/Resources/Localizable.xcstrings b/Crisp/Resources/Localizable.xcstrings
index 8d7c3ab..07fc9c8 100644
--- a/Crisp/Resources/Localizable.xcstrings
+++ b/Crisp/Resources/Localizable.xcstrings
@@ -579,6 +579,22 @@
}
}
},
+ "All Displays Connected" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "连接所有显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "連線所有顯示器"
+ }
+ }
+ }
+ },
"All connected displays" : {
"localizations" : {
"zh-Hans" : {
@@ -1196,6 +1212,38 @@
}
}
},
+ "Connect All External Displays" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "连接所有外接显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "連線所有外接顯示器"
+ }
+ }
+ }
+ },
+ "Connected" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已连接"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已連線"
+ }
+ }
+ }
+ },
"Contrast" : {
"localizations" : {
"zh-Hans" : {
@@ -1543,6 +1591,22 @@
}
}
},
+ "Disconnect All Displays" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "断开所有显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "中斷所有顯示器連線"
+ }
+ }
+ }
+ },
"Disconnect Display" : {
"localizations" : {
"zh-Hans" : {
@@ -1640,6 +1704,22 @@
}
}
},
+ "Display configuration is still in progress. Wait for it to finish." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "显示器配置仍在进行,请等待完成。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "顯示器設定仍在進行,請等待完成。"
+ }
+ }
+ }
+ },
"Display configuration timed out." : {
"localizations" : {
"zh-Hans" : {
@@ -1706,6 +1786,22 @@
}
}
},
+ "Display Unavailable" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "显示器不可用"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "顯示器無法使用"
+ }
+ }
+ }
+ },
"Display: %@" : {
"localizations" : {
"zh-Hans" : {
@@ -1755,6 +1851,22 @@
}
}
},
+ "External Displays" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "外接显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "外接顯示器"
+ }
+ }
+ }
+ },
"External displays follow the built-in but keep the difference you set" : {
"localizations" : {
"zh-Hans" : {
@@ -2415,6 +2527,22 @@
}
}
},
+ "Keeps one physical display connected." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保留一块实体显示器连接。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保留一台實體顯示器連線。"
+ }
+ }
+ }
+ },
"Links crispctl into /usr/local/bin" : {
"localizations" : {
"zh-Hans" : {
@@ -2463,6 +2591,22 @@
}
}
},
+ "Main Display Kept Connected" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "主屏保留连接"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "主螢幕保持連線"
+ }
+ }
+ }
+ },
"Main" : {
"extractionState" : "stale",
"localizations" : {
@@ -2792,6 +2936,22 @@
}
}
},
+ "Other External Displays" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "其他外接显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "其他外接顯示器"
+ }
+ }
+ }
+ },
"Options" : {
"localizations" : {
"zh-Hans" : {
@@ -3117,6 +3277,22 @@
}
}
},
+ "Reconnect All Displays" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新连接所有显示器"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新連線所有顯示器"
+ }
+ }
+ }
+ },
"Reconnect Display" : {
"localizations" : {
"zh-Hans" : {
@@ -4303,4 +4479,4 @@
}
},
"version" : "1.0"
-}
\ No newline at end of file
+}
diff --git a/Crisp/Services/BatchDisplayConnectionService.swift b/Crisp/Services/BatchDisplayConnectionService.swift
new file mode 100644
index 0000000..c73b813
--- /dev/null
+++ b/Crisp/Services/BatchDisplayConnectionService.swift
@@ -0,0 +1,118 @@
+import Foundation
+import CoreGraphics
+import Combine
+
+@MainActor
+final class BatchDisplayConnectionService: ObservableObject {
+ static let shared = BatchDisplayConnectionService()
+ private init() {}
+
+ @Published private(set) var isBusy = false
+ @Published private(set) var failures: [Failure] = []
+
+ struct Failure: Identifiable {
+ let id: String
+ let name: String
+ let error: PhysicalDisplayToggleService.ToggleError
+ var expectedConnected: Bool? = nil
+ }
+
+ func connectedDisplays(using manager: DisplayManager) -> [DisplayConnectionPlan.Display] {
+ manager.displays.map {
+ .init(uuid: $0.displayUUID, isBuiltin: $0.isBuiltin, isMain: $0.isMain,
+ isVirtual: VirtualDisplayService.shared.isVirtualDisplay($0.displayID),
+ isActive: CGDisplayIsActive($0.displayID) != 0, name: $0.name)
+ }
+ }
+
+ func state(using manager: DisplayManager) -> ExternalDisplayConnectionState {
+ let service = PhysicalDisplayToggleService.shared
+ let held: [DisplayConnectionPlan.Display] = service.disconnected.map {
+ .init(uuid: $0.uuid, isBuiltin: service.isBuiltinDisconnectedDisplay($0),
+ name: $0.name, isAvailable: service.isAvailableForReconnect($0))
+ }
+ return .make(connected: connectedDisplays(using: manager), disconnected: held,
+ canRestoreBuiltin: held.contains { $0.isBuiltin && $0.isAvailable })
+ }
+
+ func perform(_ action: DisplayConnectionPlan.Action, using manager: DisplayManager) async -> [Failure] {
+ guard !isBusy else { return failures }
+ isBusy = true
+ failures = []
+ defer { isBusy = false; manager.refreshDisplays() }
+
+ manager.refreshDisplays()
+ let shouldConnect = action == .reconnectAll || (action == .toggleAll && !state(using: manager).isOn)
+ if !shouldConnect, !(await restoreBuiltinIfNeeded(using: manager)) { return failures }
+ let service = PhysicalDisplayToggleService.shared
+ let records = service.disconnected.filter {
+ !service.isBuiltinDisconnectedDisplay($0) && service.isAvailableForReconnect($0)
+ }
+ let plan = DisplayConnectionPlan.make(
+ action: shouldConnect ? .reconnectAll : .disconnectAll,
+ connected: connectedDisplays(using: manager),
+ disconnected: service.disconnected.map(\.uuid),
+ builtinUUIDs: Set(service.disconnected.filter { service.isBuiltinDisconnectedDisplay($0) }.map(\.uuid))
+ )
+ let available = Set(records.map(\.uuid))
+ for uuid in shouldConnect ? plan.reconnectUUIDs.filter({ available.contains($0) }) : plan.disconnectUUIDs {
+ let name = manager.displays.first { $0.displayUUID == uuid }?.name ?? records.first { $0.uuid == uuid }?.name ?? uuid
+ if !(await change(uuid: uuid, connect: shouldConnect, name: name, using: manager)) { break }
+ }
+ return failures
+ }
+
+ func toggle(uuid: String, using manager: DisplayManager) async {
+ guard !isBusy, let entry = state(using: manager).entries.first(where: { $0.id == uuid }),
+ !entry.isProtected, entry.isConnected || entry.isAvailable else { return }
+ isBusy = true
+ failures = []
+ defer { isBusy = false; manager.refreshDisplays() }
+ if entry.isConnected, !(await restoreBuiltinIfNeeded(using: manager)) { return }
+ _ = await change(uuid: uuid, connect: !entry.isConnected, name: entry.name, using: manager)
+ }
+
+ func clearSettledTimeouts(using manager: DisplayManager) {
+ guard !PhysicalDisplayToggleService.shared.configurationInProgress else { return }
+ let online = Set(manager.displays.map(\.displayUUID))
+ failures.removeAll { failure in
+ guard case .timedOut = failure.error, let expected = failure.expectedConnected else { return false }
+ return online.contains(failure.id) == expected
+ }
+ }
+
+ private func restoreBuiltinIfNeeded(using manager: DisplayManager) async -> Bool {
+ let service = PhysicalDisplayToggleService.shared
+ service.holdBuiltinForExternalDisconnect()
+ guard let builtin = service.disconnected.first(where: { service.isBuiltinDisconnectedDisplay($0) }),
+ service.isAvailableForReconnect(builtin) else { return true }
+ return await change(uuid: builtin.uuid, connect: true, name: builtin.name, using: manager)
+ }
+
+ // API success alone is not proof of a connection change; wait for online enumeration.
+ private func change(uuid: String, connect: Bool, name: String, using manager: DisplayManager) async -> Bool {
+ let service = PhysicalDisplayToggleService.shared
+ let result: Result
+ if connect {
+ result = await InputSwitchService.shared.reconnect(uuid: uuid)
+ } else {
+ manager.refreshDisplays()
+ guard let display = manager.displays.first(where: { $0.displayUUID == uuid }), !display.isBuiltin,
+ !VirtualDisplayService.shared.isVirtualDisplay(display.displayID) else { return true }
+ result = await service.disconnect(display)
+ }
+ if case .failure(let error) = result {
+ failures.append(Failure(id: uuid, name: name, error: error, expectedConnected: connect))
+ if case .timedOut = error { return false }
+ if case .configurationInProgress = error { return false }
+ return true
+ }
+ for _ in 0..<20 {
+ manager.refreshDisplays()
+ if manager.displays.contains(where: { $0.displayUUID == uuid }) == connect { return true }
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ failures.append(Failure(id: uuid, name: name, error: .timedOut, expectedConnected: connect))
+ return false
+ }
+}
diff --git a/Crisp/Services/CGHelpers.swift b/Crisp/Services/CGHelpers.swift
index bb2b063..b05bd6e 100644
--- a/Crisp/Services/CGHelpers.swift
+++ b/Crisp/Services/CGHelpers.swift
@@ -8,6 +8,7 @@ enum CGHelpers {
static func runWithTimeout(
seconds: Double,
fallback: T,
+ onOperationFinished: (@Sendable (T, Bool) -> Void)? = nil,
operation: @escaping @Sendable () -> T
) async -> T {
await withCheckedContinuation { cont in
@@ -15,7 +16,9 @@ enum CGHelpers {
DispatchQueue.global(qos: .userInitiated).async {
let result = operation()
- if once.claim() { cont.resume(returning: result) }
+ let completedInTime = once.claim()
+ onOperationFinished?(result, !completedInTime)
+ if completedInTime { cont.resume(returning: result) }
}
DispatchQueue.global().asyncAfter(deadline: .now() + seconds) {
diff --git a/Crisp/Services/DDCService.swift b/Crisp/Services/DDCService.swift
index d630bfe..314620a 100644
--- a/Crisp/Services/DDCService.swift
+++ b/Crisp/Services/DDCService.swift
@@ -728,7 +728,7 @@ final class DDCService: ObservableObject, @unchecked Sendable {
/// cap). Do not skip this around a display enable/disable: WindowServer's enable waits
/// behind an in-flight I2C transaction and freezes the whole Mac with it. See
/// docs/ddc-notes.md (the hold around enable and disable).
- func hold() async -> () -> Void {
+ func hold() async -> @Sendable () -> Void {
let idle = DispatchSemaphore(value: 0)
let release = operationQueues.hold(timeout: 15) { idle.signal() }
await withCheckedContinuation { (continuation: CheckedContinuation) in
diff --git a/Crisp/Services/InputSwitchService.swift b/Crisp/Services/InputSwitchService.swift
index dc30a68..aef6093 100644
--- a/Crisp/Services/InputSwitchService.swift
+++ b/Crisp/Services/InputSwitchService.swift
@@ -150,13 +150,23 @@ final class InputSwitchService: ObservableObject {
let returnInput = toggle.disconnected.first { $0.uuid == uuid }?.returnInput
let result = await toggle.reconnect(uuid: uuid)
guard case .success = result, let returnInput else { return result }
- // One write: the monitor re-links to this Mac (a 1.5 s hot plug drop) when it lands.
- guard let displayID = Self.onlineDisplayID(uuid: uuid) else { return result }
- let written = await DDCService.shared.writeInputAfterReconnect(displayID: displayID, value: returnInput)
- Self.log.notice("display \(uuid, privacy: .public) reconnected, input \(returnInput, privacy: .public) written: \(written, privacy: .public)")
+ await restoreInputAfterReconnect(uuid: uuid, input: returnInput)
return result
}
+ func restoreInputAfterReconnect(uuid: String, input: UInt16) async {
+ var target: CGDirectDisplayID?
+ for _ in 0..<20 {
+ target = Self.onlineDisplayID(uuid: uuid)
+ if target != nil { break }
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ // One write: the monitor re-links to this Mac (a 1.5 s hot plug drop) when it lands.
+ guard let displayID = target else { return }
+ let written = await DDCService.shared.writeInputAfterReconnect(displayID: displayID, value: input)
+ Self.log.notice("display \(uuid, privacy: .public) reconnected, input \(input, privacy: .public) written: \(written, privacy: .public)")
+ }
+
// MARK: - Private
private static func onlineDisplayID(uuid: String) -> CGDirectDisplayID? {
diff --git a/Crisp/Services/PhysicalDisplayToggleService.swift b/Crisp/Services/PhysicalDisplayToggleService.swift
index d32a107..9172726 100644
--- a/Crisp/Services/PhysicalDisplayToggleService.swift
+++ b/Crisp/Services/PhysicalDisplayToggleService.swift
@@ -21,6 +21,10 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// Set by DisplayManager at launch. The restore below needs a DisplayInfo to read and put
/// back a display's HDR switch, and this service otherwise works from CGDirectDisplayIDs.
weak var displayManager: DisplayManager?
+ private let connectionQueue = DisplayConnectionQueue()
+ private let configurationGate = DisplayConfigurationGate()
+ @Published private(set) var configurationInProgress = false
+ private var reconcileWhenSettled = false
/// Snapshot of a display we disconnected, kept because a disconnected display no longer
/// appears in DisplayManager.displays, so we need its metadata to render a Reconnect row.
@@ -50,6 +54,7 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// The 10s wrapper only stops waiting; it can't cancel CGCompleteDisplayConfiguration,
/// so this is not proof the change didn't take.
case timedOut
+ case configurationInProgress
var description: String {
switch self {
@@ -63,6 +68,8 @@ final class PhysicalDisplayToggleService: ObservableObject {
return String(localized: "Display not found.")
case .timedOut:
return String(localized: "Display configuration timed out.")
+ case .configurationInProgress:
+ return String(localized: "Display configuration is still in progress. Wait for it to finish.")
}
}
}
@@ -135,10 +142,26 @@ final class PhysicalDisplayToggleService: ObservableObject {
disconnected.contains { $0.uuid == uuid }
}
+ func isBuiltinDisconnectedDisplay(_ record: DisconnectedDisplay) -> Bool {
+ wasBuiltin(record, id: resolveCurrentID(for: record) ?? record.displayID)
+ }
+
+ func isAvailableForReconnect(_ record: DisconnectedDisplay) -> Bool {
+ resolveCurrentID(for: record) != nil
+ }
+
+ /// An explicit external shutdown takes precedence over dock automation until undock.
+ func holdBuiltinForExternalDisconnect() {
+ guard Self.hasBattery else { return }
+ standDownUntilUndock = true
+ setParked(nil)
+ }
+
/// True if disconnecting `display` now would leave no *viewable* screen. Virtual displays
/// don't count: a headless virtual left alone still blacks out the physical machine.
func wouldLeaveNoActiveDisplay(_ displayID: CGDirectDisplayID) -> Bool {
- CGDisplayIsActive(displayID) != 0 && physicalActiveDisplayCount() <= 1
+ let active = viewableActiveDisplays().filter { !isDisconnected(uuid: uuid(for: $0)) }
+ return active.isEmpty || (CGDisplayIsActive(displayID) != 0 && active.count <= 1)
}
/// All display IDs known to the window server, INCLUDING ones disabled via
@@ -240,20 +263,30 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// would leave zero active displays, so the user can never black out their only screen.
@discardableResult
func disconnect(_ display: DisplayInfo, returnInput: UInt16? = nil) async -> Result {
+ let requestedUUID = display.displayUUID
+ return await connectionQueue.run { await self.performDisconnect(display, uuid: requestedUUID, returnInput: returnInput) }
+ }
+
+ private func performDisconnect(_ display: DisplayInfo, uuid requestedUUID: String,
+ returnInput: UInt16?) async -> Result {
guard isSupported else { return .failure(.unsupportedPlatform) }
- let displayID = display.displayID
+ guard !configurationGate.isPending else { return .failure(.configurationInProgress) }
+ guard let displayID = onlineDisplayIDs().first(where: { uuid(for: $0) == requestedUUID }),
+ display.isBuiltin == (CGDisplayIsBuiltin(displayID) != 0) else {
+ return .failure(.displayNotFound)
+ }
if wouldLeaveNoActiveDisplay(displayID) { return .failure(.wouldLeaveNoActiveDisplay) }
// A new click adds this dock's externals to the ones a parked record already knew.
var companions: [String]?
if display.isBuiltin {
let externals = viewableActiveDisplays().filter { $0 != displayID }.map { uuid(for: $0) }
- let known = parked?.uuid == display.displayUUID ? parked?.companions ?? [] : []
+ let known = parked?.uuid == requestedUUID ? parked?.companions ?? [] : []
companions = Array(Set(externals + known))
}
// Snapshot BEFORE disabling, afterwards the display is gone from the normal APIs.
let snapshot = DisconnectedDisplay(
- uuid: display.displayUUID,
+ uuid: requestedUUID,
displayID: displayID,
name: display.name,
width: display.pixelWidth,
@@ -263,19 +296,27 @@ final class PhysicalDisplayToggleService: ObservableObject {
companions: companions
)
- Self.log.notice("disconnect requested: \(display.displayUUID, privacy: .public) id \(displayID, privacy: .public)")
+ Self.log.notice("disconnect requested: \(requestedUUID, privacy: .public) id \(displayID, privacy: .public)")
let otherStates = currentStates(excluding: [displayID])
- let result = await setEnabled(false, displayID: displayID)
+ let result = await setEnabled(false, displayID: displayID, expectedUUID: requestedUUID) { [weak self] lateResult in
+ guard let self, case .success = lateResult else { return }
+ self.rememberDisconnected(snapshot)
+ Task { await self.restoreStates(otherStates) }
+ }
if case .success = result {
- disconnected.removeAll { $0.uuid == snapshot.uuid }
- disconnected.append(snapshot)
- saveDesired()
- if parked?.uuid == snapshot.uuid { setParked(nil) }
+ rememberDisconnected(snapshot)
Task { [weak self] in await self?.restoreStates(otherStates) }
}
return result
}
+ private func rememberDisconnected(_ snapshot: DisconnectedDisplay) {
+ disconnected.removeAll { $0.uuid == snapshot.uuid }
+ disconnected.append(snapshot)
+ saveDesired()
+ if parked?.uuid == snapshot.uuid { setParked(nil) }
+ }
+
/// What an arrangement decides for one display: its mode, its rotation, and whether it is
/// in HDR. `hdr` is nil for a display that has no HDR switch to put back.
struct DisplayState {
@@ -351,7 +392,12 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// the Tools switch off until the next undock.
@discardableResult
func reconnect(uuid: String, byUser: Bool = true) async -> Result {
+ await connectionQueue.run { await self.performReconnect(uuid: uuid, byUser: byUser) }
+ }
+
+ private func performReconnect(uuid: String, byUser: Bool) async -> Result {
guard isSupported else { return .failure(.unsupportedPlatform) }
+ guard !configurationGate.isPending else { return .failure(.configurationInProgress) }
guard let record = disconnected.first(where: { $0.uuid == uuid }) else {
return .failure(.displayNotFound)
}
@@ -363,7 +409,14 @@ final class PhysicalDisplayToggleService: ObservableObject {
if byUser, record.isBuiltin == true, SettingsService.shared.disconnectBuiltinWhenDocked {
standDownUntilUndock = true
}
- let result = await setEnabled(true, displayID: targetID)
+ let result = await setEnabled(true, displayID: targetID) { [weak self] lateResult in
+ guard let self, case .success = lateResult else { return }
+ self.disconnected.removeAll { $0.uuid == uuid }
+ self.saveDesired()
+ if byUser, let returnInput = record.returnInput {
+ Task { await InputSwitchService.shared.restoreInputAfterReconnect(uuid: uuid, input: returnInput) }
+ }
+ }
if case .success = result {
// Not proof of recovery (see verifyBackOnline); record drops either way, since
// keeping it would have reconcile switch the display back off the moment it appears.
@@ -388,6 +441,16 @@ final class PhysicalDisplayToggleService: ObservableObject {
allDisplaysIncludingDisabled().first { uuid(for: $0) == record.uuid }
}
+ private func preserveBlinkRecoveryIfUnresolved(_ result: Result, uuid: String,
+ sleepGuard: CGVirtualDisplay?) {
+ switch result {
+ case .failure(.timedOut), .failure(.configurationInProgress):
+ lingeringSleepGuard = sleepGuard
+ default:
+ removePendingSoftReconnect(uuid)
+ }
+ }
+
/// Disables then re-enables a display's framebuffer to force macOS to re-read a freshly
/// written HiDPI override and re-enumerate modes, without a physical unplug. Leaves
/// `disconnected` untouched (a re-enumeration blip, not a user disconnect); blinks even
@@ -395,7 +458,7 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// portables. See docs/display-notes.md (softReconnect).
@discardableResult
func softReconnect(_ display: DisplayInfo) async -> Bool {
- guard isSupported else { return false }
+ guard isSupported, !configurationGate.isPending else { return false }
let blinkUUID = display.displayUUID
// Two callers can race the same display; a fixed virtual-display identity can't be
// created twice, so the first blink wins and the rest adopt its result.
@@ -425,8 +488,9 @@ final class PhysicalDisplayToggleService: ObservableObject {
// Persisted before disabling: if the app dies mid-toggle, recoverStrandedSoftReconnect
// finds this at the next launch and finishes the job.
addPendingSoftReconnect(blinkUUID)
- guard case .success = await setEnabled(false, displayID: startID) else {
- removePendingSoftReconnect(blinkUUID)
+ let disableResult = await setEnabled(false, displayID: startID)
+ guard case .success = disableResult else {
+ preserveBlinkRecoveryIfUnresolved(disableResult, uuid: blinkUUID, sleepGuard: sleepGuard)
return false
}
// Wait for the framebuffer to actually drop before re-enabling (0.9s ceiling if the event never comes).
@@ -482,61 +546,6 @@ final class PhysicalDisplayToggleService: ObservableObject {
return true
}
- /// Runs SLSConfigureDisplayEnabled inside a CG transaction with `.permanently` (matching
- /// Lunar BlackOut, screen_tune, BetterDisplay), which is what makes the disconnect stick.
- private func setEnabled(_ enabled: Bool, displayID: CGDirectDisplayID) async -> Result {
- let action = enabled ? "enable" : "disable"
- let waited = DispatchTime.now()
- // No DDC traffic while this transaction runs: WindowServer's enable can wait behind an
- // in-flight I2C read and freeze the whole machine with it (issue #33).
- // See docs/display-notes.md (PhysicalDisplayToggleService).
- let releaseDDC = await DDCService.shared.hold()
- defer { releaseDDC() }
- let heldMs = Self.millisSince(waited)
- if heldMs > Self.slowOpThresholdMs {
- Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): waited \(Int(heldMs), privacy: .public) ms for DDC to go idle")
- }
- let result: Result = await CGHelpers.runWithTimeout(
- seconds: 10, fallback: .failure(.timedOut)
- ) {
- var config: CGDisplayConfigRef?
- guard CGBeginDisplayConfiguration(&config) == .success, let cfg = config else {
- Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): CGBeginDisplayConfiguration failed")
- return .failure(.configurationFailed(.failure))
- }
- let setErr = SLSConfigureDisplayEnabled(cfg, displayID, enabled)
- guard setErr == .success else {
- CGCancelDisplayConfiguration(cfg)
- Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): SLSConfigureDisplayEnabled failed \(setErr.rawValue, privacy: .public)")
- return .failure(.configurationFailed(setErr))
- }
- // The call that can block: keeps running after the 10s wrapper gives up (it can
- // only stop waiting, not cancel), and WindowServer holding it can stall the whole
- // machine, not just Crisp (issue #33). Timed unconditionally for captures.
- let committing = DispatchTime.now()
- let complete = CGCompleteDisplayConfiguration(cfg, .permanently)
- let commitMs = Self.millisSince(committing)
- guard complete == .success else {
- CGCancelDisplayConfiguration(cfg)
- Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): commit failed \(complete.rawValue, privacy: .public) after \(Int(commitMs), privacy: .public) ms")
- return .failure(.configurationFailed(complete))
- }
- if commitMs > Self.slowOpThresholdMs {
- Self.log.notice("slow \(action, privacy: .public) \(displayID, privacy: .public): commit took \(Int(commitMs), privacy: .public) ms")
- }
- return .success(())
- }
- // Not the same as what WindowServer will actually do: on a wrapper timeout this
- // reports failure at ~10000ms while the commit keeps running.
- let waitedMs = Self.millisSince(waited)
- if case .failure = result {
- Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): reported failure after \(Int(waitedMs), privacy: .public) ms")
- } else {
- Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): reported success after \(Int(waitedMs), privacy: .public) ms")
- }
- return result
- }
-
/// Safety net for softReconnect's re-enable retries all failing: sweeps every SLS-disabled
/// display Crisp didn't disconnect on purpose, so a transient failure can't leave a screen
/// stuck black. Leaves other apps' intentional disables alone.
@@ -632,6 +641,11 @@ final class PhysicalDisplayToggleService: ObservableObject {
/// drops the record if it can't take, so the list never claims a lit display is
/// disconnected. See docs/display-notes.md (reconcile).
func reconcile() {
+ if configurationGate.isPending {
+ reconcileWhenSettled = true
+ return
+ }
+ guard reapplyInFlight.isEmpty else { reconcileWhenSettled = true; return }
guard !disconnected.isEmpty else { return }
let onlineIDs = onlineDisplayIDs()
let onlineUUIDs = Set(onlineIDs.map { uuid(for: $0) })
@@ -666,6 +680,10 @@ final class PhysicalDisplayToggleService: ObservableObject {
guard let self else { return }
await self.reapplyRemembered(record.uuid)
self.reapplyInFlight.remove(record.uuid)
+ if self.reapplyInFlight.isEmpty, self.reconcileWhenSettled, !self.configurationGate.isPending {
+ self.reconcileWhenSettled = false
+ self.displayManager?.refreshDisplays()
+ }
}
}
}
@@ -697,16 +715,24 @@ final class PhysicalDisplayToggleService: ObservableObject {
guard !reconnectInFlight.contains(recordUUID) else { return }
guard let liveID = onlineDisplayIDs().first(where: { uuid(for: $0) == recordUUID })
else { return } // gone again by itself; the record still stands for next time
- let refused = wouldLeaveNoActiveDisplay(liveID)
+ var refused = wouldLeaveNoActiveDisplay(liveID)
Self.log.notice("remembered disconnect for \(recordUUID, privacy: .public) id \(liveID, privacy: .public): \(refused ? "refused, it is the last active display" : "re-applying", privacy: .public)")
var stillOnline = true
var timedOut = false
if !refused {
// Same arrangement-move risk as disconnect() (#108); restore targets the modes
// from before the display resurfaced (baselineModes), not the ones its own enable produced.
- if case .failure(.timedOut) = await setEnabled(false, displayID: liveID) {
+ let result = await setEnabled(false, displayID: liveID, expectedUUID: recordUUID)
+ if case .failure(.configurationInProgress) = result {
+ reconcileWhenSettled = true
+ return
+ }
+ if case .failure(.timedOut) = result {
timedOut = true
}
+ if case .failure(.wouldLeaveNoActiveDisplay) = result {
+ refused = true
+ }
// Started before the verify below (which can hold for seconds) so the visible flip
// stays short; a no-op if nothing actually moved.
startPassRestoreIfNeeded()
@@ -965,3 +991,92 @@ final class PhysicalDisplayToggleService: ObservableObject {
// refresh after launch re-applies it through reconcile(), where the safety rails are.
}
}
+
+// MARK: - Blocking Configuration Boundary
+
+extension PhysicalDisplayToggleService {
+ /// Runs SLSConfigureDisplayEnabled inside a CG transaction with `.permanently` (matching
+ /// Lunar BlackOut, screen_tune, BetterDisplay), which is what makes the disconnect stick.
+ private func setEnabled(_ enabled: Bool, displayID: CGDirectDisplayID, expectedUUID: String? = nil,
+ onLateCompletion: (@MainActor @Sendable (Result) -> Void)? = nil)
+ async -> Result {
+ guard configurationGate.begin() else { return .failure(.configurationInProgress) }
+ configurationInProgress = true
+ let gate = configurationGate
+ let requestedUUID = expectedUUID ?? uuid(for: displayID)
+ let action = enabled ? "enable" : "disable"
+ let waited = DispatchTime.now()
+ // No DDC traffic while this transaction runs: WindowServer's enable can wait behind an
+ // in-flight I2C read and freeze the whole machine with it (issue #33).
+ // See docs/display-notes.md (PhysicalDisplayToggleService).
+ let releaseDDC = await DDCService.shared.hold()
+ let targetID: CGDirectDisplayID
+ if enabled || expectedUUID == nil {
+ targetID = displayID
+ } else if let currentID = onlineDisplayIDs().first(where: { uuid(for: $0) == requestedUUID }),
+ !wouldLeaveNoActiveDisplay(currentID) {
+ targetID = currentID
+ } else {
+ releaseDDC()
+ gate.finish()
+ configurationInProgress = false
+ return .failure(.wouldLeaveNoActiveDisplay)
+ }
+ let heldMs = Self.millisSince(waited)
+ if heldMs > Self.slowOpThresholdMs {
+ Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): waited \(Int(heldMs), privacy: .public) ms for DDC to go idle")
+ }
+ let result: Result = await CGHelpers.runWithTimeout(
+ seconds: 10, fallback: .failure(.timedOut),
+ onOperationFinished: { [weak self] result, late in
+ releaseDDC()
+ if !late { gate.finish() }
+ Task { @MainActor in
+ if late { onLateCompletion?(result); gate.finish() }
+ guard let self else { return }
+ self.configurationInProgress = gate.isPending
+ if late || self.reconcileWhenSettled {
+ self.reconcileWhenSettled = false
+ self.displayManager?.refreshDisplays()
+ }
+ }
+ }
+ ) {
+ var config: CGDisplayConfigRef?
+ guard CGBeginDisplayConfiguration(&config) == .success, let cfg = config else {
+ Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): CGBeginDisplayConfiguration failed")
+ return .failure(.configurationFailed(.failure))
+ }
+ let setErr = SLSConfigureDisplayEnabled(cfg, targetID, enabled)
+ guard setErr == .success else {
+ CGCancelDisplayConfiguration(cfg)
+ Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): SLSConfigureDisplayEnabled failed \(setErr.rawValue, privacy: .public)")
+ return .failure(.configurationFailed(setErr))
+ }
+ // The call that can block: keeps running after the 10s wrapper gives up (it can
+ // only stop waiting, not cancel), and WindowServer holding it can stall the whole
+ // machine, not just Crisp (issue #33). Timed unconditionally for captures.
+ let committing = DispatchTime.now()
+ let complete = CGCompleteDisplayConfiguration(cfg, .permanently)
+ let commitMs = Self.millisSince(committing)
+ guard complete == .success else {
+ CGCancelDisplayConfiguration(cfg)
+ Self.log.error("\(action, privacy: .public) \(displayID, privacy: .public): commit failed \(complete.rawValue, privacy: .public) after \(Int(commitMs), privacy: .public) ms")
+ return .failure(.configurationFailed(complete))
+ }
+ if commitMs > Self.slowOpThresholdMs {
+ Self.log.notice("slow \(action, privacy: .public) \(displayID, privacy: .public): commit took \(Int(commitMs), privacy: .public) ms")
+ }
+ return .success(())
+ }
+ // Not the same as what WindowServer will actually do: on a wrapper timeout this
+ // reports failure at ~10000ms while the commit keeps running.
+ let waitedMs = Self.millisSince(waited)
+ if case .failure = result {
+ Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): reported failure after \(Int(waitedMs), privacy: .public) ms")
+ } else {
+ Self.log.notice("\(action, privacy: .public) \(displayID, privacy: .public): reported success after \(Int(waitedMs), privacy: .public) ms")
+ }
+ return result
+ }
+}
diff --git a/Crisp/Views/BatchDisplayConnectionView.swift b/Crisp/Views/BatchDisplayConnectionView.swift
new file mode 100644
index 0000000..3d0ca21
--- /dev/null
+++ b/Crisp/Views/BatchDisplayConnectionView.swift
@@ -0,0 +1,125 @@
+import SwiftUI
+
+struct BatchDisplayConnectionView: View {
+ @EnvironmentObject private var displayManager: DisplayManager
+ @ObservedObject private var service = PhysicalDisplayToggleService.shared
+ @ObservedObject private var batch = BatchDisplayConnectionService.shared
+
+ private var state: ExternalDisplayConnectionState { batch.state(using: displayManager) }
+
+ var body: some View {
+ if service.isSupported, !state.entries.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 8) {
+ Image("ExternalDisplayConnection")
+ .renderingMode(.template)
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(state.isOn ? Color.primary : Color.secondaryReadable)
+ .frame(width: 22, height: 22)
+ .frame(width: 26, height: 26)
+ .accessibilityHidden(true)
+ Text(state.hasProtectedDisplay ? "Other External Displays" : "External Displays").font(.body)
+ Spacer(minLength: 0)
+ if batch.isBusy || service.configurationInProgress {
+ ProgressView().controlSize(.small).frame(width: 18, height: 18)
+ } else if state.isOn && state.canReconnect {
+ Button { run(.reconnectAll) } label: {
+ Image(systemName: "arrow.clockwise").frame(width: 18, height: 18)
+ }
+ .buttonStyle(.plain)
+ .help("Connect All External Displays")
+ .accessibilityLabel(Text("Connect All External Displays"))
+ }
+ Toggle("External Displays", isOn: Binding(
+ get: { state.isOn },
+ set: { run($0 ? .reconnectAll : .disconnectAll) }
+ ))
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .controlSize(.small)
+ .disabled(batch.isBusy || service.configurationInProgress || (!state.canDisconnect && !state.canReconnect))
+ }
+
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 6), count: state.columnCount),
+ alignment: .leading, spacing: 4) {
+ ForEach(state.entries) { entry in
+ if entry.isProtected {
+ displayName(entry)
+ } else {
+ Button { toggle(entry.id) } label: {
+ displayName(entry)
+ }
+ .buttonStyle(.plain)
+ .disabled(batch.isBusy || service.configurationInProgress || (!entry.isConnected && !entry.isAvailable))
+ }
+ }
+ }
+ .padding(.leading, 32)
+
+ ForEach(batch.failures) { failure in
+ Text(verbatim: "\(failure.name): \(failure.error.description)")
+ .font(.caption)
+ .foregroundColor(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 3)
+ .onAppear { batch.clearSettledTimeouts(using: displayManager) }
+ .onChange(of: service.configurationInProgress) { _, pending in
+ if !pending { batch.clearSettledTimeouts(using: displayManager) }
+ }
+ .onChange(of: displayManager.displays.map(\.displayUUID)) { _, _ in
+ batch.clearSettledTimeouts(using: displayManager)
+ }
+ }
+ }
+
+ private func displayName(_ entry: ExternalDisplayConnectionState.Entry) -> some View {
+ HStack(spacing: 2) {
+ if entry.isProtected {
+ Image(systemName: "lock.fill").font(.system(size: 8)).accessibilityHidden(true)
+ }
+ Text(verbatim: entry.name)
+ .font(.caption)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ .foregroundStyle(entry.isConnected ? Color.primary : Color.secondaryReadable)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: 20)
+ .contentShape(Rectangle())
+ .help(tooltip(for: entry))
+ .accessibilityLabel(Text(verbatim: entry.name))
+ .accessibilityValue(Text(status(for: entry)))
+ }
+
+ private func status(for entry: ExternalDisplayConnectionState.Entry) -> LocalizedStringKey {
+ if entry.isProtected { return "Main Display Kept Connected" }
+ if !entry.isAvailable { return "Display Unavailable" }
+ return entry.isConnected ? "Connected" : "Disconnected"
+ }
+
+ private func tooltip(for entry: ExternalDisplayConnectionState.Entry) -> String {
+ let detail: String
+ if entry.isProtected {
+ detail = String(localized: "Main Display Kept Connected")
+ } else if !entry.isAvailable {
+ detail = String(localized: "Display Unavailable")
+ } else {
+ detail = entry.isConnected ? String(localized: "Disconnect Display") : String(localized: "Reconnect Display")
+ }
+ return "\(entry.name): \(detail)"
+ }
+
+ private func run(_ action: DisplayConnectionPlan.Action) {
+ guard PanelOpenGuard.allowsActivation, !batch.isBusy else { return }
+ Task { @MainActor in _ = await batch.perform(action, using: displayManager) }
+ }
+
+ private func toggle(_ uuid: String) {
+ guard PanelOpenGuard.allowsActivation, !batch.isBusy else { return }
+ Task { @MainActor in await batch.toggle(uuid: uuid, using: displayManager) }
+ }
+}
diff --git a/CrispTests/DisplayConfigurationGateTests.swift b/CrispTests/DisplayConfigurationGateTests.swift
new file mode 100644
index 0000000..e7a054b
--- /dev/null
+++ b/CrispTests/DisplayConfigurationGateTests.swift
@@ -0,0 +1,93 @@
+import XCTest
+import Foundation
+
+private final class SimulatedConfiguration: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 2
+ private var peak = 0
+ private var activeTransactions = 0
+ private var completedLate = false
+ let started = DispatchSemaphore(value: 0)
+ let release = DispatchSemaphore(value: 0)
+
+ var activeScreens: Int { lock.withLock { count } }
+ var peakTransactions: Int { lock.withLock { peak } }
+ var wasLate: Bool { lock.withLock { completedLate } }
+
+ func commit() -> Bool {
+ lock.withLock { activeTransactions += 1; peak = max(peak, activeTransactions) }
+ started.signal()
+ _ = release.wait(timeout: .now() + 2)
+ lock.withLock { count -= 1; activeTransactions -= 1 }
+ return true
+ }
+
+ func record(late: Bool) { lock.withLock { completedLate = late } }
+}
+
+final class DisplayConfigurationGateTests: XCTestCase {
+ func testOnlyOneLeaseCanBeActive() {
+ let gate = DisplayConfigurationGate()
+ XCTAssertTrue(gate.begin())
+ XCTAssertTrue(gate.isPending)
+ XCTAssertFalse(gate.begin())
+ gate.finish()
+ XCTAssertFalse(gate.isPending)
+ XCTAssertTrue(gate.begin())
+ gate.finish()
+ }
+
+ @MainActor
+ func testTimeoutCannotReleaseTheGuardForAnotherDisconnect() async {
+ let queue = DisplayConnectionQueue()
+ let gate = DisplayConfigurationGate()
+ let state = SimulatedConfiguration()
+ let disconnect: @MainActor @Sendable () async -> Bool = {
+ guard state.activeScreens > 1, gate.begin() else { return false }
+ return await CGHelpers.runWithTimeout(seconds: 0.02, fallback: false, onOperationFinished: { _, late in
+ Task { @MainActor in state.record(late: late); gate.finish() }
+ }) { state.commit() }
+ }
+ let first = await queue.run(disconnect)
+ XCTAssertFalse(first)
+ XCTAssertTrue(state.started.wait(timeout: .now() + 1) == .success)
+ XCTAssertTrue(gate.isPending)
+ let second = await queue.run(disconnect)
+ XCTAssertFalse(second)
+ XCTAssertEqual(state.peakTransactions, 1)
+ state.release.signal()
+ for _ in 0..<100 where gate.isPending { try? await Task.sleep(nanoseconds: 10_000_000) }
+ XCTAssertFalse(gate.isPending)
+ XCTAssertTrue(state.wasLate)
+ XCTAssertEqual(state.activeScreens, 1)
+ XCTAssertTrue(gate.begin(), "Real completion must unblock later requests")
+ gate.finish()
+ }
+
+ @MainActor
+ func testSuccessfulOperationCompletesBeforeItsReturn() async {
+ let gate = DisplayConfigurationGate()
+ XCTAssertTrue(gate.begin())
+ let result = await CGHelpers.runWithTimeout(seconds: 1, fallback: false,
+ onOperationFinished: { _, late in XCTAssertFalse(late); gate.finish() }) { true }
+ XCTAssertTrue(result)
+ XCTAssertFalse(gate.isPending)
+ }
+
+ @MainActor
+ func testQueuedOperationPinsIdentityAtSubmission() async {
+ let queue = DisplayConnectionQueue()
+ var liveIdentity = "requested-display"
+ let blocker = Task { @MainActor in
+ await queue.run { try? await Task.sleep(nanoseconds: 20_000_000) }
+ }
+ await Task.yield()
+ let requestedIdentity = liveIdentity
+ let request = Task { @MainActor in await queue.run { requestedIdentity } }
+ liveIdentity = "reassigned-display"
+ await blocker.value
+ let identity = await request.value
+ XCTAssertEqual(identity, "requested-display")
+ XCTAssertEqual(liveIdentity, "reassigned-display")
+ }
+}
diff --git a/CrispTests/DisplayConnectionPlanTests.swift b/CrispTests/DisplayConnectionPlanTests.swift
new file mode 100644
index 0000000..3db0b59
--- /dev/null
+++ b/CrispTests/DisplayConnectionPlanTests.swift
@@ -0,0 +1,87 @@
+import XCTest
+
+final class DisplayConnectionPlanTests: XCTestCase {
+ private let displays: [DisplayConnectionPlan.Display] = [
+ .init(uuid: "external-main", isMain: true),
+ .init(uuid: "builtin", isBuiltin: true),
+ .init(uuid: "external-secondary")
+ ]
+
+ func testDisconnectAllKeepsBuiltinEvenWhenAnExternalIsMain() {
+ let plan = DisplayConnectionPlan.make(action: .disconnectAll, connected: displays, disconnected: [])
+ XCTAssertEqual(plan.disconnectUUIDs, ["external-main", "external-secondary"])
+ XCTAssertTrue(plan.reconnectUUIDs.isEmpty)
+ }
+
+ func testDesktopKeepsMainOrFirstPhysicalDisplay() {
+ let desktop = displays.filter { !$0.isBuiltin }
+ XCTAssertEqual(DisplayConnectionPlan.make(action: .disconnectAll, connected: desktop, disconnected: []).disconnectUUIDs,
+ ["external-secondary"])
+ let unnamedMain: [DisplayConnectionPlan.Display] = [.init(uuid: "first"), .init(uuid: "second")]
+ XCTAssertEqual(DisplayConnectionPlan.make(action: .disconnectAll, connected: unnamedMain, disconnected: []).disconnectUUIDs,
+ ["second"])
+ }
+
+ func testVirtualInactiveAndHeldDisplaysCannotServeAsKeeper() {
+ let topology: [DisplayConnectionPlan.Display] = [
+ .init(uuid: "virtual-main", isMain: true, isVirtual: true),
+ .init(uuid: "sleeping-builtin", isBuiltin: true, isActive: false),
+ .init(uuid: "held-builtin", isBuiltin: true),
+ .init(uuid: "physical")
+ ]
+ let plan = DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: ["held-builtin"])
+ XCTAssertTrue(plan.disconnectUUIDs.isEmpty)
+ }
+
+ func testMirrorTargetDisconnectsWhileTheOnlyActiveScreenIsKept() {
+ let topology: [DisplayConnectionPlan.Display] = [
+ .init(uuid: "source", isBuiltin: true),
+ .init(uuid: "mirror-target", isActive: false)
+ ]
+ let plan = DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: [])
+ XCTAssertEqual(plan.disconnectUUIDs, ["mirror-target"])
+ XCTAssertTrue(plan.reconnectUUIDs.isEmpty)
+ }
+
+ func testNoActiveKeeperMeansNothingIsDisconnected() {
+ let topology: [DisplayConnectionPlan.Display] = [.init(uuid: "sleeping", isActive: false)]
+ XCTAssertTrue(DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: []).disconnectUUIDs.isEmpty)
+ }
+
+ func testMixedStateToggleReconnectsAllAndDeduplicatesRecords() {
+ let plan = DisplayConnectionPlan.make(action: .toggleAll, connected: displays, disconnected: ["held", "held", "other"])
+ XCTAssertEqual(plan.reconnectUUIDs, ["held", "other"])
+ XCTAssertTrue(plan.disconnectUUIDs.isEmpty)
+ }
+
+ func testConnectedToggleDisconnectsOthersAndReconnectIsIdempotent() {
+ XCTAssertEqual(DisplayConnectionPlan.make(action: .toggleAll, connected: displays, disconnected: []),
+ DisplayConnectionPlan.make(action: .disconnectAll, connected: displays, disconnected: []))
+ XCTAssertTrue(DisplayConnectionPlan.make(action: .reconnectAll, connected: displays, disconnected: []).disconnectUUIDs.isEmpty)
+ XCTAssertTrue(DisplayConnectionPlan.make(action: .reconnectAll, connected: displays, disconnected: []).reconnectUUIDs.isEmpty)
+ }
+
+ func testEmptySingleAndDuplicateTopologiesDoNotDisconnectLastScreen() {
+ let topologies: [[DisplayConnectionPlan.Display]] = [[], [.init(uuid: "only")], [.init(uuid: "only"), .init(uuid: "only")]]
+ for topology in topologies {
+ XCTAssertTrue(DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: []).disconnectUUIDs.isEmpty)
+ }
+ }
+
+ func testOneToEightExternalsDisconnectWithoutTargetingAnyBuiltin() {
+ for count in 1...8 {
+ let externals = (0.. Bool = {
+ guard activeScreens > 1 else { return false }
+ inFlight += 1
+ peakInFlight = max(peakInFlight, inFlight)
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ activeScreens -= 1
+ inFlight -= 1
+ return true
+ }
+ async let first = queue.run(disconnect)
+ async let second = queue.run(disconnect)
+ let results = await [first, second]
+ XCTAssertEqual(results.filter { $0 }.count, 1)
+ XCTAssertEqual(activeScreens, 1)
+ XCTAssertEqual(peakInFlight, 1)
+ }
+
+ @MainActor
+ func testQueueContinuesAfterARefusedOperation() async {
+ let queue = DisplayConnectionQueue()
+ let refused = await queue.run { false }
+ let reconnected = await queue.run { true }
+ XCTAssertFalse(refused)
+ XCTAssertTrue(reconnected)
+ }
+}
diff --git a/CrispTests/ExternalDisplayConnectionStateTests.swift b/CrispTests/ExternalDisplayConnectionStateTests.swift
new file mode 100644
index 0000000..dd1c28a
--- /dev/null
+++ b/CrispTests/ExternalDisplayConnectionStateTests.swift
@@ -0,0 +1,95 @@
+import XCTest
+
+final class ExternalDisplayConnectionStateTests: XCTestCase {
+ private let builtin = DisplayConnectionPlan.Display(uuid: "builtin", isBuiltin: true, name: "Built-in")
+
+ func testOneToFourNamesStayOnOneRowAndFiveToEightUseTwo() {
+ for count in 1...8 {
+ let external = (0..&2
+ exit 1
+fi
+ASSETS=$(mktemp -d -t crisp-dev-assets)
+trap 'rm -rf "$ASSETS"' EXIT
+DEVELOPER_DIR="$AI_DEV" xcrun actool Crisp/Assets.xcassets \
+ --compile "$ASSETS" --platform macosx --target-device mac \
+ --minimum-deployment-target 14.0 --app-icon AppIcon \
+ --output-partial-info-plist "$ASSETS/asset-info.plist"
+
echo "==> Compiling Crisp $VERSION ($BUILD)..."
# The target is explicit: on macOS 27 swiftc with no -target stamps the binary
# with a deployment target above the running system, and LaunchServices then
@@ -54,6 +64,8 @@ echo "==> Swapping into ${APP}..."
pkill -x Crisp 2>/dev/null || true
sleep 1
cp Crisp-bin "$APP/Contents/MacOS/Crisp"
+mkdir -p "$APP/Contents/Resources"
+cp "$ASSETS/Assets.car" "$APP/Contents/Resources/Assets.car"
if [ -n "$AI_DEV" ]; then
echo "==> Writing Shortcuts actions metadata..."
appintents_metadata "$APP/Contents/MacOS/Crisp" "$APP/Contents/Resources" arm64
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
index b926687..5accef0 100644
--- a/docs/BUILDING.md
+++ b/docs/BUILDING.md
@@ -1,13 +1,13 @@
-# Building without Xcode
+# Building Crisp
-**TL;DR:** run `./dev.sh`, which compiles, swaps the binary into the installed
-`/Applications/Crisp.app`, syncs the version from `project.yml`, re-signs, and
-relaunches. The rest of this doc explains what it does.
+**TL;DR:** with full Xcode installed, run `./dev.sh`, which compiles the binary
+and vector assets, updates the installed `/Applications/Crisp.app`, syncs the
+version from `project.yml`, re-signs, and relaunches. The rest of this doc explains what it does.
You can build the full .app in Xcode (`xcodegen generate`, then archive), or a
-full DMG with `./scripts/release.sh vX.Y.Z` (swiftc, but it needs Xcode for the
-Shortcuts actions, see below). But for the fast
-dev loop, the binary alone compiles with just the Command Line Tools:
+full DMG with `./scripts/release.sh vX.Y.Z` (swiftc, with Xcode for vector assets
+and Shortcuts actions). The binary alone can compile with matching Command Line
+Tools and a macOS 26-or-newer SDK, but this does not compile the SVG assets:
```sh
./scripts/fetch-sparkle.sh # once: vendors the Sparkle updater framework
@@ -23,9 +23,9 @@ swiftc -O -swift-version 6 -parse-as-library \
-o Crisp-bin
```
-To run it, swap the binary into an existing Crisp.app install and re-sign ad
-hoc (the install must contain `Contents/Frameworks/Sparkle.framework`; installs
-of a pre-Sparkle release don't, so use `./dev.sh`, which copies it in):
+To run a binary-only build, the existing bundle must already contain matching
+`Contents/Resources/Assets.car`, Shortcuts metadata and Sparkle.framework. Prefer
+`./dev.sh` to synchronize these app resources before re-signing:
```sh
pkill -x Crisp
@@ -35,15 +35,16 @@ codesign --force -s - --entitlements Crisp/Crisp.entitlements /Applications/Cris
open /Applications/Crisp.app
```
-This is the fast dev loop: edit, compile, swap, relaunch, no Xcode involved.
+The fast dev loop is edit, compile, synchronize resources, re-sign, relaunch.
+`dev.sh` requires full Xcode to compile the SVG asset catalog before the swap.
## Shortcuts actions
-Shortcuts finds Crisp's actions through `Contents/Resources/Metadata.appintents`, which an Xcode build writes. `scripts/appintents.sh` does the same for the swiftc build: swiftc emits the App Intents types' const values, and `appintentsmetadataprocessor` turns them into the metadata. Both steps need Xcode's toolchain, so `release.sh` fails without Xcode, and `dev.sh` without Xcode keeps the installed app's metadata as it is, so actions you changed or added do not show in Shortcuts. After a deploy, Shortcuts picks up changed actions only after `lsregister -f /Applications/Crisp.app` and a relaunch of Shortcuts.
+Shortcuts finds Crisp's actions through `Contents/Resources/Metadata.appintents`, which an Xcode build writes. `scripts/appintents.sh` does the same for the swiftc build: swiftc emits the App Intents types' const values, and `appintentsmetadataprocessor` turns them into the metadata. Both steps need Xcode's toolchain. Both release and dev builds require it and regenerate the metadata. After a deploy, Shortcuts picks up changed actions only after `lsregister -f /Applications/Crisp.app` and a relaunch of Shortcuts.
## crispctl
-`dev.sh` swaps only the app binary. To build the command line tool on its own (the same sources `scripts/release.sh` uses):
+`dev.sh` updates the app binary/resources, not the CLI. To build the command line tool on its own (the same sources `scripts/release.sh` uses):
```sh
swiftc -O -swift-version 6 -target arm64-apple-macos14.0 \
diff --git a/project.yml b/project.yml
index 94b36b4..25b20f1 100644
--- a/project.yml
+++ b/project.yml
@@ -86,6 +86,11 @@ targets:
- path: Crisp/Models/DDCVolumeMax.swift
- path: Crisp/Models/EdgeCrossing.swift
- path: Crisp/Models/DDCInputSource.swift
+ - path: Crisp/Models/DisplayConnectionPlan.swift
+ - path: Crisp/Models/ExternalDisplayConnectionState.swift
+ - path: Crisp/Models/DisplayConnectionQueue.swift
+ - path: Crisp/Models/DisplayConfigurationGate.swift
+ - path: Crisp/Services/CGHelpers.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.crisp.tests
diff --git a/scripts/release.sh b/scripts/release.sh
index 10c3d81..44b00cf 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -86,6 +86,13 @@ cp -R "$ROOT/vendor/Sparkle/Sparkle.framework" "$APP/Contents/Frameworks/"
rm -rf "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices" \
"$APP/Contents/Frameworks/Sparkle.framework/XPCServices"
+# Preserve SVG menu assets in the swiftc release path as well as Xcode builds.
+echo "==> Compiling vector image assets…"
+DEVELOPER_DIR="$AI_DEV" xcrun actool Crisp/Assets.xcassets \
+ --compile "$APP/Contents/Resources" --platform macosx --target-device mac \
+ --minimum-deployment-target 14.0 --app-icon AppIcon \
+ --output-partial-info-plist "$BUILD/asset-info.plist"
+
echo "==> Building app icon from asset catalog…"
ICONSET="$BUILD/AppIcon.iconset"; mkdir -p "$ICONSET"
ICONS="Crisp/Assets.xcassets/AppIcon.appiconset"