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 @@ + + External displays + + + + + + + + + + + + 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"