From f84898a06674974a9ad41f06ca2551527504dcb6 Mon Sep 17 00:00:00 2001 From: TRAGSAX <252893922+Blazetes@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:15:46 +0800 Subject: [PATCH 1/5] Add batch display connection controls in Tools --- Crisp/App/AppDelegate.swift | 1 + Crisp/Models/DisplayConnectionPlan.swift | 30 ++++++ Crisp/Models/DisplayConnectionQueue.swift | 19 ++++ Crisp/Resources/Localizable.xcstrings | 26 +++++- .../BatchDisplayConnectionService.swift | 55 +++++++++++ .../PhysicalDisplayToggleService.swift | 12 ++- Crisp/Views/BatchDisplayConnectionView.swift | 91 +++++++++++++++++++ CrispTests/DisplayConnectionPlanTests.swift | 55 +++++++++++ CrispTests/DisplayConnectionQueueTests.swift | 35 +++++++ project.yml | 2 + 10 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 Crisp/Models/DisplayConnectionPlan.swift create mode 100644 Crisp/Models/DisplayConnectionQueue.swift create mode 100644 Crisp/Services/BatchDisplayConnectionService.swift create mode 100644 Crisp/Views/BatchDisplayConnectionView.swift create mode 100644 CrispTests/DisplayConnectionPlanTests.swift create mode 100644 CrispTests/DisplayConnectionQueueTests.swift 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/Models/DisplayConnectionPlan.swift b/Crisp/Models/DisplayConnectionPlan.swift new file mode 100644 index 0000000..911a39a --- /dev/null +++ b/Crisp/Models/DisplayConnectionPlan.swift @@ -0,0 +1,30 @@ +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 + } + + let reconnectUUIDs: [String] + let disconnectUUIDs: [String] + + static func make(action: Action, connected: [Display], disconnected: [String]) -> Self { + var seen: Set = [] + let held = disconnected.filter { seen.insert($0).inserted } + if action == .reconnectAll || (action == .toggleAll && !held.isEmpty) { + return Self(reconnectUUIDs: held, disconnectUUIDs: []) + } + seen = Set(held) + let active = connected.filter { !$0.isVirtual && $0.isActive && seen.insert($0.uuid).inserted } + let keeper = active.first(where: { $0.isBuiltin }) ?? active.first(where: { $0.isMain }) ?? active.first + return Self(reconnectUUIDs: [], disconnectUUIDs: active.filter { $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/Resources/Localizable.xcstrings b/Crisp/Resources/Localizable.xcstrings index 8d7c3ab..efae1da 100644 --- a/Crisp/Resources/Localizable.xcstrings +++ b/Crisp/Resources/Localizable.xcstrings @@ -579,6 +579,12 @@ } } }, + "All Displays Connected" : { + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "连接所有显示器" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "連線所有顯示器" } } + } + }, "All connected displays" : { "localizations" : { "zh-Hans" : { @@ -1543,6 +1549,12 @@ } } }, + "Disconnect All Displays" : { + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "断开所有显示器" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "中斷所有顯示器連線" } } + } + }, "Disconnect Display" : { "localizations" : { "zh-Hans" : { @@ -2318,6 +2330,12 @@ } } }, + "Keeps one physical display connected." : { + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "保留一块实体显示器连接。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "保留一台實體顯示器連線。" } } + } + }, "Keep Awake" : { "localizations" : { "zh-Hans" : { @@ -3117,6 +3135,12 @@ } } }, + "Reconnect All Displays" : { + "localizations" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "重新连接所有显示器" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "重新連線所有顯示器" } } + } + }, "Reconnect Display" : { "localizations" : { "zh-Hans" : { @@ -4303,4 +4327,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..2db862d --- /dev/null +++ b/Crisp/Services/BatchDisplayConnectionService.swift @@ -0,0 +1,55 @@ +import Foundation +import CoreGraphics +import Combine + +@MainActor +final class BatchDisplayConnectionService: ObservableObject { + static let shared = BatchDisplayConnectionService() + private init() {} + + @Published private(set) var isBusy = false + + struct Failure: Identifiable { + let id: String + let name: String + let error: PhysicalDisplayToggleService.ToggleError + } + + func perform(_ action: DisplayConnectionPlan.Action, using manager: DisplayManager) async -> [Failure] { + guard !isBusy else { return [] } + isBusy = true + defer { isBusy = false; manager.refreshDisplays() } + + let service = PhysicalDisplayToggleService.shared + let records = service.disconnected + let plan = DisplayConnectionPlan.make( + action: action, + connected: manager.displays.map { + .init(uuid: $0.displayUUID, isBuiltin: $0.isBuiltin, isMain: $0.isMain, + isVirtual: VirtualDisplayService.shared.isVirtualDisplay($0.displayID), + isActive: CGDisplayIsActive($0.displayID) != 0) + }, + disconnected: records.map(\.uuid) + ) + var failures: [Failure] = [] + for uuid in plan.reconnectUUIDs { + let result = await InputSwitchService.shared.reconnect(uuid: uuid) + manager.refreshDisplays() + if case .failure(let error) = result { + failures.append(Failure(id: uuid, name: records.first { $0.uuid == uuid }?.name ?? uuid, error: error)) + if case .timedOut = error { return failures } + } + } + for uuid in plan.disconnectUUIDs { + manager.refreshDisplays() + guard let display = manager.displays.first(where: { $0.displayUUID == uuid }), + !service.isDisconnected(uuid: uuid) else { continue } + let result = await service.disconnect(display) + if case .failure(let error) = result { + failures.append(Failure(id: uuid, name: display.name, error: error)) + if case .timedOut = error { return failures } + } + } + return failures + } +} diff --git a/Crisp/Services/PhysicalDisplayToggleService.swift b/Crisp/Services/PhysicalDisplayToggleService.swift index d32a107..0fc686a 100644 --- a/Crisp/Services/PhysicalDisplayToggleService.swift +++ b/Crisp/Services/PhysicalDisplayToggleService.swift @@ -21,6 +21,7 @@ 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() /// 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. @@ -138,7 +139,8 @@ final class PhysicalDisplayToggleService: ObservableObject { /// 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 CGDisplayIsActive(displayID) != 0 && active.count <= 1 } /// All display IDs known to the window server, INCLUDING ones disabled via @@ -240,6 +242,10 @@ 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 { + await connectionQueue.run { await self.performDisconnect(display, returnInput: returnInput) } + } + + private func performDisconnect(_ display: DisplayInfo, returnInput: UInt16?) async -> Result { guard isSupported else { return .failure(.unsupportedPlatform) } let displayID = display.displayID if wouldLeaveNoActiveDisplay(displayID) { return .failure(.wouldLeaveNoActiveDisplay) } @@ -351,6 +357,10 @@ 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 let record = disconnected.first(where: { $0.uuid == uuid }) else { return .failure(.displayNotFound) diff --git a/Crisp/Views/BatchDisplayConnectionView.swift b/Crisp/Views/BatchDisplayConnectionView.swift new file mode 100644 index 0000000..3052b16 --- /dev/null +++ b/Crisp/Views/BatchDisplayConnectionView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import CoreGraphics + +struct BatchDisplayConnectionView: View { + @EnvironmentObject private var displayManager: DisplayManager + @ObservedObject private var service = PhysicalDisplayToggleService.shared + @ObservedObject private var batch = BatchDisplayConnectionService.shared + @State private var failures: [BatchDisplayConnectionService.Failure] = [] + + private var physicalDisplays: [DisplayInfo] { + displayManager.displays.filter { !VirtualDisplayService.shared.isVirtualDisplay($0.displayID) } + } + + private var canDisconnect: Bool { + physicalDisplays.contains { !service.wouldLeaveNoActiveDisplay($0.displayID) && CGDisplayIsActive($0.displayID) != 0 } + } + + var body: some View { + if service.isSupported, Set(physicalDisplays.map(\.displayUUID) + service.disconnected.map(\.uuid)).count > 1 { + VStack(alignment: .leading, spacing: 0) { + Toggle(isOn: Binding( + get: { service.disconnected.isEmpty }, + set: { run($0 ? .reconnectAll : .disconnectAll) } + )) { + HStack(spacing: 8) { + MenuItemIcon(systemName: "display.2", color: .blue, active: service.disconnected.isEmpty) + .accessibilityHidden(true) + Text("All Displays Connected").font(.body) + Spacer() + } + } + .toggleStyle(.switch) + .controlSize(.small) + .disabled(batch.isBusy || (service.disconnected.isEmpty && !canDisconnect)) + .padding(.horizontal, 12) + .padding(.vertical, 3) + + BatchConnectionActionRow(label: "Reconnect All Displays", icon: "arrow.clockwise", + disabled: batch.isBusy || service.disconnected.isEmpty) { run(.reconnectAll) } + BatchConnectionActionRow(label: "Disconnect All Displays", icon: "rectangle.slash", + disabled: batch.isBusy || !canDisconnect) { run(.disconnectAll) } + .help("Keeps one physical display connected.") + + if batch.isBusy { + ProgressView().controlSize(.small).padding(.horizontal, 12) + } + ForEach(failures) { failure in + Text(verbatim: "\(failure.name): \(failure.error.description)") + .font(.caption) + .foregroundColor(.red) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } + } + } + + private func run(_ action: DisplayConnectionPlan.Action) { + guard PanelOpenGuard.allowsActivation, !batch.isBusy else { return } + failures = [] + Task { @MainActor in + failures = await batch.perform(action, using: displayManager) + } + } +} + +private struct BatchConnectionActionRow: View { + let label: String + let icon: String + let disabled: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + MenuItemIcon(systemName: icon, active: false).accessibilityHidden(true) + Text(LocalizedStringKey(label)).font(.body) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .padding(.horizontal, 12) + .padding(.vertical, 3) + .menuRowHover(isHovered && !disabled) + .onHover { isHovered = $0 } + } +} diff --git a/CrispTests/DisplayConnectionPlanTests.swift b/CrispTests/DisplayConnectionPlanTests.swift new file mode 100644 index 0000000..d04c8b6 --- /dev/null +++ b/CrispTests/DisplayConnectionPlanTests.swift @@ -0,0 +1,55 @@ +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 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) + } + } +} diff --git a/CrispTests/DisplayConnectionQueueTests.swift b/CrispTests/DisplayConnectionQueueTests.swift new file mode 100644 index 0000000..6ec2149 --- /dev/null +++ b/CrispTests/DisplayConnectionQueueTests.swift @@ -0,0 +1,35 @@ +import XCTest + +final class DisplayConnectionQueueTests: XCTestCase { + @MainActor + func testOverlappingDisconnectsRecheckTheLastScreenAfterPreviousChange() async { + let queue = DisplayConnectionQueue() + var activeScreens = 2 + var inFlight = 0 + var peakInFlight = 0 + let disconnect: @MainActor () async -> 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/project.yml b/project.yml index 94b36b4..d4cd9c3 100644 --- a/project.yml +++ b/project.yml @@ -86,6 +86,8 @@ targets: - path: Crisp/Models/DDCVolumeMax.swift - path: Crisp/Models/EdgeCrossing.swift - path: Crisp/Models/DDCInputSource.swift + - path: Crisp/Models/DisplayConnectionPlan.swift + - path: Crisp/Models/DisplayConnectionQueue.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.crisp.tests From ec3bc557dbce94ce76d9f94027dbe878c07d57ae Mon Sep 17 00:00:00 2001 From: TRAGSAX <252893922+Blazetes@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:23:17 +0800 Subject: [PATCH 2/5] Retain batch feedback and resolve queued display identities --- Crisp/Services/BatchDisplayConnectionService.swift | 5 +++-- Crisp/Services/PhysicalDisplayToggleService.swift | 4 +++- Crisp/Views/BatchDisplayConnectionView.swift | 6 ++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Crisp/Services/BatchDisplayConnectionService.swift b/Crisp/Services/BatchDisplayConnectionService.swift index 2db862d..fc7c837 100644 --- a/Crisp/Services/BatchDisplayConnectionService.swift +++ b/Crisp/Services/BatchDisplayConnectionService.swift @@ -8,6 +8,7 @@ final class BatchDisplayConnectionService: ObservableObject { private init() {} @Published private(set) var isBusy = false + @Published private(set) var failures: [Failure] = [] struct Failure: Identifiable { let id: String @@ -16,8 +17,9 @@ final class BatchDisplayConnectionService: ObservableObject { } func perform(_ action: DisplayConnectionPlan.Action, using manager: DisplayManager) async -> [Failure] { - guard !isBusy else { return [] } + guard !isBusy else { return failures } isBusy = true + failures = [] defer { isBusy = false; manager.refreshDisplays() } let service = PhysicalDisplayToggleService.shared @@ -31,7 +33,6 @@ final class BatchDisplayConnectionService: ObservableObject { }, disconnected: records.map(\.uuid) ) - var failures: [Failure] = [] for uuid in plan.reconnectUUIDs { let result = await InputSwitchService.shared.reconnect(uuid: uuid) manager.refreshDisplays() diff --git a/Crisp/Services/PhysicalDisplayToggleService.swift b/Crisp/Services/PhysicalDisplayToggleService.swift index 0fc686a..68c2c50 100644 --- a/Crisp/Services/PhysicalDisplayToggleService.swift +++ b/Crisp/Services/PhysicalDisplayToggleService.swift @@ -247,7 +247,9 @@ final class PhysicalDisplayToggleService: ObservableObject { private func performDisconnect(_ display: DisplayInfo, returnInput: UInt16?) async -> Result { guard isSupported else { return .failure(.unsupportedPlatform) } - let displayID = display.displayID + guard let displayID = onlineDisplayIDs().first(where: { uuid(for: $0) == display.displayUUID }) 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. diff --git a/Crisp/Views/BatchDisplayConnectionView.swift b/Crisp/Views/BatchDisplayConnectionView.swift index 3052b16..eeed555 100644 --- a/Crisp/Views/BatchDisplayConnectionView.swift +++ b/Crisp/Views/BatchDisplayConnectionView.swift @@ -5,7 +5,6 @@ struct BatchDisplayConnectionView: View { @EnvironmentObject private var displayManager: DisplayManager @ObservedObject private var service = PhysicalDisplayToggleService.shared @ObservedObject private var batch = BatchDisplayConnectionService.shared - @State private var failures: [BatchDisplayConnectionService.Failure] = [] private var physicalDisplays: [DisplayInfo] { displayManager.displays.filter { !VirtualDisplayService.shared.isVirtualDisplay($0.displayID) } @@ -44,7 +43,7 @@ struct BatchDisplayConnectionView: View { if batch.isBusy { ProgressView().controlSize(.small).padding(.horizontal, 12) } - ForEach(failures) { failure in + ForEach(batch.failures) { failure in Text(verbatim: "\(failure.name): \(failure.error.description)") .font(.caption) .foregroundColor(.red) @@ -58,9 +57,8 @@ struct BatchDisplayConnectionView: View { private func run(_ action: DisplayConnectionPlan.Action) { guard PanelOpenGuard.allowsActivation, !batch.isBusy else { return } - failures = [] Task { @MainActor in - failures = await batch.perform(action, using: displayManager) + _ = await batch.perform(action, using: displayManager) } } } From 57466e1f75776884d4f15331661ad0ade5248bd7 Mon Sep 17 00:00:00 2001 From: TRAGSAX <252893922+Blazetes@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:26:04 +0800 Subject: [PATCH 3/5] Match catalog formatting and keep feature additions independent --- Crisp/Resources/Localizable.xcstrings | 64 ++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/Crisp/Resources/Localizable.xcstrings b/Crisp/Resources/Localizable.xcstrings index efae1da..f7c7bdf 100644 --- a/Crisp/Resources/Localizable.xcstrings +++ b/Crisp/Resources/Localizable.xcstrings @@ -581,8 +581,18 @@ }, "All Displays Connected" : { "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "连接所有显示器" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "連線所有顯示器" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接所有显示器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線所有顯示器" + } + } } }, "All connected displays" : { @@ -1551,8 +1561,18 @@ }, "Disconnect All Displays" : { "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "断开所有显示器" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "中斷所有顯示器連線" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开所有显示器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "中斷所有顯示器連線" + } + } } }, "Disconnect Display" : { @@ -2330,12 +2350,6 @@ } } }, - "Keeps one physical display connected." : { - "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "保留一块实体显示器连接。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "保留一台實體顯示器連線。" } } - } - }, "Keep Awake" : { "localizations" : { "zh-Hans" : { @@ -2433,6 +2447,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" : { @@ -3137,8 +3167,18 @@ }, "Reconnect All Displays" : { "localizations" : { - "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "重新连接所有显示器" } }, - "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "重新連線所有顯示器" } } + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新连接所有显示器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新連線所有顯示器" + } + } } }, "Reconnect Display" : { From b0fc3d9e9f4966daadf84c533767a7707d72b075 Mon Sep 17 00:00:00 2001 From: TRAGSAX <252893922+Blazetes@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:50:32 +0800 Subject: [PATCH 4/5] Include physical mirror targets in batch disconnects --- Crisp/Models/DisplayConnectionPlan.swift | 9 ++++++--- .../Services/PhysicalDisplayToggleService.swift | 2 +- Crisp/Views/BatchDisplayConnectionView.swift | 9 ++++++++- CrispTests/DisplayConnectionPlanTests.swift | 17 ++++++++++++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/Crisp/Models/DisplayConnectionPlan.swift b/Crisp/Models/DisplayConnectionPlan.swift index 911a39a..a377401 100644 --- a/Crisp/Models/DisplayConnectionPlan.swift +++ b/Crisp/Models/DisplayConnectionPlan.swift @@ -23,8 +23,11 @@ struct DisplayConnectionPlan: Equatable { return Self(reconnectUUIDs: held, disconnectUUIDs: []) } seen = Set(held) - let active = connected.filter { !$0.isVirtual && $0.isActive && seen.insert($0.uuid).inserted } - let keeper = active.first(where: { $0.isBuiltin }) ?? active.first(where: { $0.isMain }) ?? active.first - return Self(reconnectUUIDs: [], disconnectUUIDs: active.filter { $0.uuid != keeper?.uuid }.map(\.uuid)) + 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.uuid != keeper.uuid }.map(\.uuid)) } } diff --git a/Crisp/Services/PhysicalDisplayToggleService.swift b/Crisp/Services/PhysicalDisplayToggleService.swift index 68c2c50..0694c95 100644 --- a/Crisp/Services/PhysicalDisplayToggleService.swift +++ b/Crisp/Services/PhysicalDisplayToggleService.swift @@ -140,7 +140,7 @@ final class PhysicalDisplayToggleService: ObservableObject { /// don't count: a headless virtual left alone still blacks out the physical machine. func wouldLeaveNoActiveDisplay(_ displayID: CGDirectDisplayID) -> Bool { let active = viewableActiveDisplays().filter { !isDisconnected(uuid: uuid(for: $0)) } - return CGDisplayIsActive(displayID) != 0 && active.count <= 1 + return active.isEmpty || (CGDisplayIsActive(displayID) != 0 && active.count <= 1) } /// All display IDs known to the window server, INCLUDING ones disabled via diff --git a/Crisp/Views/BatchDisplayConnectionView.swift b/Crisp/Views/BatchDisplayConnectionView.swift index eeed555..44dffa6 100644 --- a/Crisp/Views/BatchDisplayConnectionView.swift +++ b/Crisp/Views/BatchDisplayConnectionView.swift @@ -11,7 +11,14 @@ struct BatchDisplayConnectionView: View { } private var canDisconnect: Bool { - physicalDisplays.contains { !service.wouldLeaveNoActiveDisplay($0.displayID) && CGDisplayIsActive($0.displayID) != 0 } + !DisplayConnectionPlan.make( + action: .disconnectAll, + connected: physicalDisplays.map { + .init(uuid: $0.displayUUID, isBuiltin: $0.isBuiltin, isMain: $0.isMain, + isActive: CGDisplayIsActive($0.displayID) != 0) + }, + disconnected: service.disconnected.map(\.uuid) + ).disconnectUUIDs.isEmpty } var body: some View { diff --git a/CrispTests/DisplayConnectionPlanTests.swift b/CrispTests/DisplayConnectionPlanTests.swift index d04c8b6..a17654d 100644 --- a/CrispTests/DisplayConnectionPlanTests.swift +++ b/CrispTests/DisplayConnectionPlanTests.swift @@ -30,7 +30,22 @@ final class DisplayConnectionPlanTests: XCTestCase { .init(uuid: "physical") ] let plan = DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: ["held-builtin"]) - XCTAssertTrue(plan.disconnectUUIDs.isEmpty) + XCTAssertEqual(plan.disconnectUUIDs, ["sleeping-builtin"]) + } + + 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() { From d7137e289676e48ebc9891ce0d1458942ad30b1b Mon Sep 17 00:00:00 2001 From: TRAGSAX <252893922+Blazetes@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:53:36 +0800 Subject: [PATCH 5/5] Use a compact external-display switch with named states and transaction safety --- .../Contents.json | 16 ++ .../ExternalDisplayConnection.svg | 15 ++ Crisp/Models/DDCOperationQueuePool.swift | 2 +- Crisp/Models/DisplayConfigurationGate.swift | 21 ++ Crisp/Models/DisplayConnectionPlan.swift | 9 +- .../ExternalDisplayConnectionState.swift | 39 +++ Crisp/Resources/Localizable.xcstrings | 112 ++++++++ .../BatchDisplayConnectionService.swift | 108 ++++++-- Crisp/Services/CGHelpers.swift | 5 +- Crisp/Services/DDCService.swift | 2 +- Crisp/Services/InputSwitchService.swift | 18 +- .../PhysicalDisplayToggleService.swift | 247 +++++++++++++----- Crisp/Views/BatchDisplayConnectionView.swift | 161 +++++++----- .../DisplayConfigurationGateTests.swift | 93 +++++++ CrispTests/DisplayConnectionPlanTests.swift | 19 +- .../ExternalDisplayConnectionStateTests.swift | 95 +++++++ Makefile | 2 +- README.md | 2 +- dev.sh | 16 +- docs/BUILDING.md | 27 +- project.yml | 3 + scripts/release.sh | 7 + 22 files changed, 830 insertions(+), 189 deletions(-) create mode 100644 Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/Contents.json create mode 100644 Crisp/Assets.xcassets/ExternalDisplayConnection.imageset/ExternalDisplayConnection.svg create mode 100644 Crisp/Models/DisplayConfigurationGate.swift create mode 100644 Crisp/Models/ExternalDisplayConnectionState.swift create mode 100644 CrispTests/DisplayConfigurationGateTests.swift create mode 100644 CrispTests/ExternalDisplayConnectionStateTests.swift 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 index a377401..03ed7c0 100644 --- a/Crisp/Models/DisplayConnectionPlan.swift +++ b/Crisp/Models/DisplayConnectionPlan.swift @@ -11,16 +11,19 @@ struct DisplayConnectionPlan: Equatable { 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]) -> Self { + 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) { - return Self(reconnectUUIDs: held, disconnectUUIDs: []) + 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 } @@ -28,6 +31,6 @@ struct DisplayConnectionPlan: Equatable { 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.uuid != keeper.uuid }.map(\.uuid)) + return Self(reconnectUUIDs: [], disconnectUUIDs: physical.filter { !$0.isBuiltin && $0.uuid != keeper.uuid }.map(\.uuid)) } } 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 f7c7bdf..07fc9c8 100644 --- a/Crisp/Resources/Localizable.xcstrings +++ b/Crisp/Resources/Localizable.xcstrings @@ -1212,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" : { @@ -1672,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" : { @@ -1738,6 +1786,22 @@ } } }, + "Display Unavailable" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示器不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示器無法使用" + } + } + } + }, "Display: %@" : { "localizations" : { "zh-Hans" : { @@ -1787,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" : { @@ -2511,6 +2591,22 @@ } } }, + "Main Display Kept Connected" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主屏保留连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "主螢幕保持連線" + } + } + } + }, "Main" : { "extractionState" : "stale", "localizations" : { @@ -2840,6 +2936,22 @@ } } }, + "Other External Displays" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他外接显示器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他外接顯示器" + } + } + } + }, "Options" : { "localizations" : { "zh-Hans" : { diff --git a/Crisp/Services/BatchDisplayConnectionService.swift b/Crisp/Services/BatchDisplayConnectionService.swift index fc7c837..c73b813 100644 --- a/Crisp/Services/BatchDisplayConnectionService.swift +++ b/Crisp/Services/BatchDisplayConnectionService.swift @@ -14,6 +14,25 @@ final class BatchDisplayConnectionService: ObservableObject { 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] { @@ -22,35 +41,78 @@ final class BatchDisplayConnectionService: ObservableObject { 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 + let records = service.disconnected.filter { + !service.isBuiltinDisconnectedDisplay($0) && service.isAvailableForReconnect($0) + } let plan = DisplayConnectionPlan.make( - action: action, - connected: manager.displays.map { - .init(uuid: $0.displayUUID, isBuiltin: $0.isBuiltin, isMain: $0.isMain, - isVirtual: VirtualDisplayService.shared.isVirtualDisplay($0.displayID), - isActive: CGDisplayIsActive($0.displayID) != 0) - }, - disconnected: records.map(\.uuid) + action: shouldConnect ? .reconnectAll : .disconnectAll, + connected: connectedDisplays(using: manager), + disconnected: service.disconnected.map(\.uuid), + builtinUUIDs: Set(service.disconnected.filter { service.isBuiltinDisconnectedDisplay($0) }.map(\.uuid)) ) - for uuid in plan.reconnectUUIDs { - let result = await InputSwitchService.shared.reconnect(uuid: 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() - if case .failure(let error) = result { - failures.append(Failure(id: uuid, name: records.first { $0.uuid == uuid }?.name ?? uuid, error: error)) - if case .timedOut = error { return failures } - } + 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 uuid in plan.disconnectUUIDs { + for _ in 0..<20 { manager.refreshDisplays() - guard let display = manager.displays.first(where: { $0.displayUUID == uuid }), - !service.isDisconnected(uuid: uuid) else { continue } - let result = await service.disconnect(display) - if case .failure(let error) = result { - failures.append(Failure(id: uuid, name: display.name, error: error)) - if case .timedOut = error { return failures } - } + if manager.displays.contains(where: { $0.displayUUID == uuid }) == connect { return true } + try? await Task.sleep(nanoseconds: 100_000_000) } - return failures + 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 0694c95..9172726 100644 --- a/Crisp/Services/PhysicalDisplayToggleService.swift +++ b/Crisp/Services/PhysicalDisplayToggleService.swift @@ -22,6 +22,9 @@ final class PhysicalDisplayToggleService: ObservableObject { /// 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. @@ -51,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 { @@ -64,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.") } } } @@ -136,6 +142,21 @@ 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 { @@ -242,12 +263,16 @@ 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 { - await connectionQueue.run { await self.performDisconnect(display, returnInput: returnInput) } + let requestedUUID = display.displayUUID + return await connectionQueue.run { await self.performDisconnect(display, uuid: requestedUUID, returnInput: returnInput) } } - private func performDisconnect(_ display: DisplayInfo, returnInput: UInt16?) async -> Result { + private func performDisconnect(_ display: DisplayInfo, uuid requestedUUID: String, + returnInput: UInt16?) async -> Result { guard isSupported else { return .failure(.unsupportedPlatform) } - guard let displayID = onlineDisplayIDs().first(where: { uuid(for: $0) == display.displayUUID }) else { + 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) } @@ -256,12 +281,12 @@ final class PhysicalDisplayToggleService: ObservableObject { 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, @@ -271,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 { @@ -364,6 +397,7 @@ final class PhysicalDisplayToggleService: ObservableObject { 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) } @@ -375,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. @@ -400,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 @@ -407,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. @@ -437,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). @@ -494,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. @@ -644,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) }) @@ -678,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() + } } } } @@ -709,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() @@ -977,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 index 44dffa6..3d0ca21 100644 --- a/Crisp/Views/BatchDisplayConnectionView.swift +++ b/Crisp/Views/BatchDisplayConnectionView.swift @@ -1,96 +1,125 @@ import SwiftUI -import CoreGraphics struct BatchDisplayConnectionView: View { @EnvironmentObject private var displayManager: DisplayManager @ObservedObject private var service = PhysicalDisplayToggleService.shared @ObservedObject private var batch = BatchDisplayConnectionService.shared - private var physicalDisplays: [DisplayInfo] { - displayManager.displays.filter { !VirtualDisplayService.shared.isVirtualDisplay($0.displayID) } - } - - private var canDisconnect: Bool { - !DisplayConnectionPlan.make( - action: .disconnectAll, - connected: physicalDisplays.map { - .init(uuid: $0.displayUUID, isBuiltin: $0.isBuiltin, isMain: $0.isMain, - isActive: CGDisplayIsActive($0.displayID) != 0) - }, - disconnected: service.disconnected.map(\.uuid) - ).disconnectUUIDs.isEmpty - } + private var state: ExternalDisplayConnectionState { batch.state(using: displayManager) } var body: some View { - if service.isSupported, Set(physicalDisplays.map(\.displayUUID) + service.disconnected.map(\.uuid)).count > 1 { - VStack(alignment: .leading, spacing: 0) { - Toggle(isOn: Binding( - get: { service.disconnected.isEmpty }, - set: { run($0 ? .reconnectAll : .disconnectAll) } - )) { - HStack(spacing: 8) { - MenuItemIcon(systemName: "display.2", color: .blue, active: service.disconnected.isEmpty) - .accessibilityHidden(true) - Text("All Displays Connected").font(.body) - Spacer() + 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)) } - .toggleStyle(.switch) - .controlSize(.small) - .disabled(batch.isBusy || (service.disconnected.isEmpty && !canDisconnect)) - .padding(.horizontal, 12) - .padding(.vertical, 3) - BatchConnectionActionRow(label: "Reconnect All Displays", icon: "arrow.clockwise", - disabled: batch.isBusy || service.disconnected.isEmpty) { run(.reconnectAll) } - BatchConnectionActionRow(label: "Disconnect All Displays", icon: "rectangle.slash", - disabled: batch.isBusy || !canDisconnect) { run(.disconnectAll) } - .help("Keeps one physical display connected.") - - if batch.isBusy { - ProgressView().controlSize(.small).padding(.horizontal, 12) + 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(.bottom, 4) } } + .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 run(_ action: DisplayConnectionPlan.Action) { - guard PanelOpenGuard.allowsActivation, !batch.isBusy else { return } - Task { @MainActor in - _ = await batch.perform(action, 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 struct BatchConnectionActionRow: View { - let label: String - let icon: String - let disabled: Bool - let action: () -> Void - @State private var isHovered = false + 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" + } - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - MenuItemIcon(systemName: icon, active: false).accessibilityHidden(true) - Text(LocalizedStringKey(label)).font(.body) - Spacer() - } - .contentShape(Rectangle()) + 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") } - .buttonStyle(.plain) - .disabled(disabled) - .padding(.horizontal, 12) - .padding(.vertical, 3) - .menuRowHover(isHovered && !disabled) - .onHover { isHovered = $0 } + 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 index a17654d..3db0b59 100644 --- a/CrispTests/DisplayConnectionPlanTests.swift +++ b/CrispTests/DisplayConnectionPlanTests.swift @@ -30,7 +30,7 @@ final class DisplayConnectionPlanTests: XCTestCase { .init(uuid: "physical") ] let plan = DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: ["held-builtin"]) - XCTAssertEqual(plan.disconnectUUIDs, ["sleeping-builtin"]) + XCTAssertTrue(plan.disconnectUUIDs.isEmpty) } func testMirrorTargetDisconnectsWhileTheOnlyActiveScreenIsKept() { @@ -67,4 +67,21 @@ final class DisplayConnectionPlanTests: XCTestCase { XCTAssertTrue(DisplayConnectionPlan.make(action: .disconnectAll, connected: topology, disconnected: []).disconnectUUIDs.isEmpty) } } + + func testOneToEightExternalsDisconnectWithoutTargetingAnyBuiltin() { + for count in 1...8 { + let externals = (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 d4cd9c3..25b20f1 100644 --- a/project.yml +++ b/project.yml @@ -87,7 +87,10 @@ targets: - 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"