diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbc47a..d7a199e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,15 @@ jobs: # survived to a release. - name: Test run: make test + # The distribution Python tests exercise packaging/script contracts that + # Xcode's unit-test target does not load. The actual round trip is fully + # headless: it uses the fixture control host and --no-start. + - name: Test crispctl distribution and headless round trip + run: | + python3 -m unittest discover -s Tests/DistributionTests -p 'test_*.py' + SCRATCH="$(mktemp -d /tmp/crispctl-roundtrip-ci.XXXXXX)" + trap 'rm -rf "$SCRATCH"' EXIT + ./scripts/test-crispctl-roundtrip.sh "$SCRATCH" # Every localization key the code uses must exist in the String Catalog. # SwiftUI coalesces interpolated text into "%@"-style lookup keys, and a # key missing from the catalog silently falls back to English at runtime diff --git a/.gitignore b/.gitignore index fd73a5a..ab37e19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store +# Python bytecode/cache +__pycache__/ +*.py[cod] build/ vendor/ *.xcodeproj/xcuserdata/ diff --git a/Crisp/App/AppDelegate.swift b/Crisp/App/AppDelegate.swift index b9bc7a2..2ac1afd 100644 --- a/Crisp/App/AppDelegate.swift +++ b/Crisp/App/AppDelegate.swift @@ -4,6 +4,9 @@ import CoreGraphics import ApplicationServices import Combine import os.log +#if canImport(CrispControlCore) +import CrispControlCore +#endif /// Borderless key-capable panel for the menu bar UI. /// Owning the panel (instead of MenuBarExtra's window) removes the WindowServer @@ -53,6 +56,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { /// cancelled by closePanel: nothing polls while the panel is hidden; /// showPanel's click-time refresh covers state that drifted while closed. private var externalStatePollTask: Task? + private let brightnessHeartbeatController = BrightnessHeartbeatController() + private var controlHost: CrispControlHost? /// Called after wake-from-sleep; wired in setupStartupBehavior. var onWake: (() -> Void)? @@ -133,6 +138,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { setupStartupBehavior() setupStatusItem() + let host = CrispControlHost(displayManager: displayManager) + do { + try host.start() + controlHost = host + } catch { + NSLog("Crisp control host failed to start: %@", String(describing: error)) + } + // Re-anchor the open panel when screens change: switching the main // display re-origins global coordinates, which would otherwise leave // the panel floating at a stale position. @@ -207,30 +220,39 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { /// monitor's own buttons or another app). All reads run off the main /// thread; called at the click in showPanel. private func refreshExternalState() { - CoreBrightnessService.shared.refresh() - for display in displayManager.displays { - Task { await BrightnessService.shared.refreshBrightness(for: display) } + brightnessHeartbeatController.schedule( + displays: displayManager.displays, + panelVisible: true, + autoBrightnessEnabled: false, + lastManualAdjustment: nil, + isBuiltin: { $0.isBuiltin }, + prepare: { CoreBrightnessService.shared.refresh() } + ) { display in + await BrightnessService.shared.refreshBrightness(for: display) } } private func pollExternalState() { // The panel is never ordered out (hidden = alpha 0), so isVisible // alone is always true; alpha is the actual shown state. - guard isPanelShown, let p = panel, p.alphaValue > 0 else { return } - // Don't fight the user's own adjustments (or busy the DDC bus mid-drag). - if let last = BrightnessService.shared.lastManualAdjustDate, - Date().timeIntervalSince(last) < 3 { return } - CoreBrightnessService.shared.refresh() + let panelVisible = isPanelShown && (panel?.alphaValue ?? 0) > 0 + let displays = visibleDisplays() let autoBrightnessOn = AutoBrightnessService.shared.isEnabled - for display in visibleDisplays() { - // Skip any display something else is actively driving (see the - // original note in MenuBarView history, issue #12 follow-up). - if display.isBuiltin || autoBrightnessOn { continue } - Task { await BrightnessService.shared.refreshBrightness(for: display) } + let lastManualAdjustment = BrightnessService.shared.lastManualAdjustDate + brightnessHeartbeatController.schedule( + displays: displays, + panelVisible: panelVisible, + autoBrightnessEnabled: autoBrightnessOn, + lastManualAdjustment: lastManualAdjustment, + isBuiltin: { $0.isBuiltin }, + prepare: { CoreBrightnessService.shared.refresh() } + ) { display in + await BrightnessService.shared.refreshBrightness(for: display) } } func applicationWillTerminate(_ notification: Notification) { + controlHost?.stop() if let obs = wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(obs) } @@ -254,7 +276,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // touching display state. try? await Task.sleep(nanoseconds: 2_000_000_000) dm.refreshDisplays() - // Re-disconnect any physical displays macOS re-enabled on wake. + // Invalidate numeric-ID fallback continuity after wake; this performs no display write. await PhysicalDisplayToggleService.shared.reapplyOnWake() dm.refreshDisplays() try? await Task.sleep(nanoseconds: 500_000_000) @@ -929,6 +951,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { canvas.parkSpring() externalStatePollTask?.cancel() externalStatePollTask = nil + brightnessHeartbeatController.cancel() // Hide with a quick fade, like native menus; never order out (see // isPanelShown comment). Click-through is immediate. p.ignoresMouseEvents = true diff --git a/Crisp/Automation/CrispControlHost.swift b/Crisp/Automation/CrispControlHost.swift new file mode 100644 index 0000000..23f5ea6 --- /dev/null +++ b/Crisp/Automation/CrispControlHost.swift @@ -0,0 +1,271 @@ +import Foundation +#if canImport(CrispControlCore) +import CrispControlCore +#endif + +@MainActor +final class CrispControlHost { + private let server: UnixSocketServer + + init(displayManager: DisplayManager) { + let service = CrispControlAppService(displayManager: displayManager) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let dispatcher = ControlCommandDispatcher(service: service, appVersion: version) + server = UnixSocketServer { request in await dispatcher.handle(request) } + } + + func start() throws { try server.start() } + func stop() { server.stop() } +} + +@MainActor +private final class CrispControlAppService: ControlCommandService, @unchecked Sendable { + private let displayManager: DisplayManager + + init(displayManager: DisplayManager) { + self.displayManager = displayManager + } + + func displays() async throws -> [ControlDisplay] { + let displays = displayManager.displays + let connectionCapabilities = PhysicalDisplayToggleService.shared + .connectionCapabilitiesForControl(displays) + return displays.map { display in + let boost = BrightnessBoostService.shared + let isVirtual = VirtualDisplayService.shared.isVirtualDisplay(display.displayID) + let connection = connectionCapabilities[display.displayUUID] ?? .unsupported( + connected: true, + platformSupported: true, + reason: "the display was absent from the authoritative capability batch", + remediation: "refresh displays before making a connection change" + ) + let extraBrightness = extraBrightnessCapability(for: display, isVirtual: isVirtual) + let hdr = hdrCapability(for: display, isVirtual: isVirtual) + let logicalMax = boost.isEnabled(for: display) && boost.isEligible(display) + ? max(100, display.maxBrightness) : 100 + let capability: BrightnessCapability + if display.isBuiltin { + capability = BrightnessCapability( + state: .writable, + backend: BrightnessService.shared.hasDisplayServicesControl ? .displayServices : .ioKit, + range: ControlRange(min: 0, max: logicalMax, precision: 0.1), + readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: logicalMax, precision: 0.1) + ) + } else if BrightnessService.shared.isDDCAvailable(for: display.displayID) == true { + let backend = BrightnessService.shared.controlBackend(for: display) + let readback = BrightnessService.shared.controlReadbackQuality(for: display) + capability = BrightnessCapability( + state: .writable, + backend: backend, + range: ControlRange(min: 0, max: logicalMax, precision: 1), + readback: readback, + hardwareRange: ControlRange(min: 0, max: 100, precision: 1), + logicalRange: ControlRange(min: 0, max: logicalMax, precision: 1), + reason: readback == .unavailable + ? "active backend does not provide independent read-back" + : "monitor DDC read-back may be quantized" + ) + } else { + capability = BrightnessCapability( + state: .writable, + backend: .software, + range: ControlRange(min: 0, max: logicalMax, precision: 1), + readback: .unavailable, + hardwareRange: ControlRange(min: 0, max: 100, precision: 1), + logicalRange: ControlRange(min: 0, max: logicalMax, precision: 1), + reason: "hardware DDC is unavailable or not yet established", + remediation: "open Crisp once after connecting the display to allow DDC discovery" + ) + } + return ControlDisplay( + uuid: display.displayUUID, + name: display.name, + isMain: display.isMain, + isBuiltin: display.isBuiltin, + isVirtual: isVirtual, + brightness: capability, + brightnessPercent: display.brightness, + extraBrightness: extraBrightness, + hdr: hdr, + connection: connection + ) + }.sorted { $0.uuid < $1.uuid } + } + + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { + try PhysicalDisplayToggleService.shared.disconnectedDisplaysForControl() + } + + func disconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + guard let display = displayManager.displays.first(where: { $0.displayUUID == displayUUID }) else { + throw DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: displayUUID, + requestedConnectionState: .disconnected, + message: "exact UUID disappeared before the app service could re-resolve it" + ) + } + return try await PhysicalDisplayToggleService.shared.disconnectForControl(display) + } + + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + try await PhysicalDisplayToggleService.shared.reconnectForControl(uuid: displayUUID) + } + + func readBrightness(displayUUID: String) async throws -> Double? { + try await readBrightnessState(displayUUID: displayUUID)?.logicalPercent + } + + func readBrightnessState(displayUUID: String) async throws -> BrightnessReadSnapshot? { + guard let display = displayManager.displays.first(where: { $0.displayUUID == displayUUID }) else { + throw ControlServiceError.readFailed("display disconnected before brightness read-back") + } + return await BrightnessService.shared.readBrightnessStateForControl(for: display) + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + guard let display = displayManager.displays.first(where: { $0.displayUUID == displayUUID }) else { + throw ControlServiceError.writeFailed("display disconnected before brightness write") + } + return try await BrightnessService.shared.writeBrightnessForControl(percent, for: display) + } + + func setExtraBrightness(displayUUID: String, enabled: Bool) async throws -> ExtraBrightnessSetResult { + let display = try connectedDisplay(uuid: displayUUID, operation: "Extra Brightness write") + let boost = BrightnessBoostService.shared + let eligible = boost.isEligible(display) + let cleanupNeeded = !enabled && boost.needsDisableCleanup(for: display) + guard eligible || cleanupNeeded else { + throw ControlServiceError.unsupported("Extra Brightness is not eligible for this display") + } + let mutationOutcome = try await boost.setEnabledForControl(enabled, for: display) + if let resolvedResult = try mutationOutcome.resolvedControlResult( + capability: extraBrightnessCapability( + for: display, + isVirtual: VirtualDisplayService.shared.isVirtualDisplay(display.displayID) + ) + ) { + return resolvedResult + } + + for _ in 0..<10 { + let persisted = BrightnessBoostService.shared.isEnabled(for: display) + let ceilingSettled = enabled ? display.maxBrightness > 100 : display.maxBrightness <= 100.5 + if persisted == enabled, ceilingSettled { break } + try await Task.sleep(for: .milliseconds(50)) + } + let capability = extraBrightnessCapability( + for: display, + isVirtual: VirtualDisplayService.shared.isVirtualDisplay(display.displayID) + ) + let ceilingSettled = enabled ? capability.maxBrightness > 100 : capability.maxBrightness <= 100.5 + let verification: AppStateVerificationQuality = ceilingSettled ? .appStateVerified : .settling + let warnings = ceilingSettled ? [] : [ + enabled + ? "Extra Brightness was accepted but the dynamic EDR ceiling is still settling" + : "Extra Brightness is off in persisted app state but the animated ceiling is still collapsing" + ] + return ExtraBrightnessSetResult( + capability: capability, verification: verification, warnings: warnings + ) + } + + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult { + let display = try connectedDisplay(uuid: displayUUID, operation: "HDR write") + guard BrightnessBoostService.shared.isEligibleForHDRToggle(display) else { + throw ControlServiceError.unsupported( + display.isBuiltin + ? "built-in displays do not expose an HDR preference toggle; use Extra Brightness when eligible" + : "Crisp does not expose a writable HDR toggle for this display" + ) + } + guard try await BrightnessBoostService.shared.setHDRPreference(enabled, for: display) else { + throw ControlServiceError.writeFailed("HDR preference request was rejected by the live app service") + } + for _ in 0..<20 { + if BrightnessBoostService.shared.isHDREnabled(for: display) == enabled { break } + try await Task.sleep(for: .milliseconds(100)) + } + let capability = hdrCapability(for: display, isVirtual: false) + guard capability.enabled == enabled else { + throw ControlServiceError.writeFailed("HDR preference did not match bounded live read-back") + } + return HDRSetResult(capability: capability, verification: .verified) + } + + private func connectedDisplay(uuid: String, operation: String) throws -> DisplayInfo { + guard let display = displayManager.displays.first(where: { $0.displayUUID == uuid }) else { + throw ControlServiceError.writeFailed("display disconnected before \(operation)") + } + return display + } + + private func extraBrightnessCapability( + for display: DisplayInfo, + isVirtual: Bool + ) -> ExtraBrightnessCapability { + let boost = BrightnessBoostService.shared + let persisted = boost.isEnabled(for: display) + let headroom = boost.controlHeadroomSnapshot(for: display) + guard !isVirtual, boost.isEligible(display) else { + return .unsupported( + enabled: display.maxBrightness > 100, + persistedEnabled: persisted, + maxBrightness: display.maxBrightness, + headroom: headroom, + reason: isVirtual + ? "Extra Brightness is unavailable for virtual displays" + : "no usable live EDR headroom and no writable external HDR toggle", + remediation: "use a built-in XDR panel or an external display with a Crisp-writable HDR toggle" + ) + } + let hasLiveHeadroom = headroom.potential > 1.05 + return ExtraBrightnessCapability( + state: .writable, + enabled: display.maxBrightness > 100, + persistedEnabled: persisted, + maxBrightness: display.maxBrightness, + headroom: headroom, + reason: hasLiveHeadroom + ? "usable live EDR headroom is available" + : "eligible through the external HDR toggle but not boost-ready until HDR headroom settles", + remediation: hasLiveHeadroom ? nil : "enable Extra Brightness to request HDR and re-check live headroom" + ) + } + + private func hdrCapability(for display: DisplayInfo, isVirtual: Bool) -> HDRCapability { + let boost = BrightnessBoostService.shared + if display.isBuiltin { + return .unsupported( + reason: "built-in displays do not expose an HDR preference toggle", + remediation: "use Extra Brightness when eligible" + ) + } + guard !isVirtual else { + return .unsupported( + reason: "HDR preference is unavailable for virtual displays", + remediation: "use the display or macOS settings if HDR is available there" + ) + } + if boost.isEligibleForHDRToggle(display) { + return HDRCapability( + state: .writable, + enabled: boost.isHDREnabled(for: display) + ) + } + guard let liveState = boost.controlHDRState(for: display) else { + return .unsupported( + reason: "Crisp cannot read or write HDR preference for this display", + remediation: "use the display or macOS settings if HDR is available there" + ) + } + return HDRCapability( + state: .readable, + enabled: liveState, + reason: "HDR preference is readable, but Crisp does not expose a writable toggle for this display", + remediation: "use the display or macOS settings to change HDR" + ) + } +} diff --git a/Crisp/Resources/Localizable.xcstrings b/Crisp/Resources/Localizable.xcstrings index 5f790e5..7f91859 100644 --- a/Crisp/Resources/Localizable.xcstrings +++ b/Crisp/Resources/Localizable.xcstrings @@ -717,6 +717,16 @@ } } }, + "Display cannot be proven to be hardware-backed physical." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法确认该显示器为硬件支持的物理显示器。" + } + } + } + }, "Display configuration failed (CGError %@)." : { "localizations" : { "zh-Hans" : { @@ -727,6 +737,16 @@ } } }, + "Display configuration may still complete; refresh before deciding again." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示器配置仍有可能完成;请刷新后再作决定。" + } + } + } + }, "Display Mode" : { "extractionState" : "stale", "localizations" : { diff --git a/Crisp/Services/BrightnessBoostService.swift b/Crisp/Services/BrightnessBoostService.swift index 2e06a60..78fce6f 100644 --- a/Crisp/Services/BrightnessBoostService.swift +++ b/Crisp/Services/BrightnessBoostService.swift @@ -1,23 +1,132 @@ // Crisp/Services/BrightnessBoostService.swift import AppKit import CoreGraphics +import ObjectiveC +#if canImport(CrispControlCore) +import CrispControlCore +#endif + +/// All private MonitorPanel ABI calls live here. Selector presence and the full +/// Objective-C method signature are checked before invocation; callers receive +/// nil/false on ABI drift. +@MainActor +private final class MonitorPanelRuntimeAdapter: HDRPreferenceAdapting { + private let manager: NSObject? + + init() { + guard dlopen("/System/Library/PrivateFrameworks/MonitorPanel.framework/MonitorPanel", RTLD_LAZY) != nil, + let cls = NSClassFromString("MPDisplayMgr") as? NSObject.Type else { + manager = nil + return + } + manager = cls.init() + } + + func snapshot(for displayID: CGDirectDisplayID) -> HDRAdapterState? { + guard let state = readState(displayID: displayID), state.canSet else { return nil } + return state + } + + func readState(displayID: UInt32) -> HDRAdapterState? { + guard let display = displayObject(for: displayID), + let supports = boolGetter("hasHDRModes", on: display), + let prefers = boolGetter("preferHDRModes", on: display) else { return nil } + let setter = NSSelectorFromString("setPreferHDRModes:") + return HDRAdapterState( + supportsHDR: supports, + prefersHDR: prefers, + canSet: hasCompatibleMethod(setter, on: display, as: .boolSetter), + identity: String(describing: ObjectIdentifier(display)) + ) + } + + func setPreference( + _ enabled: Bool, + displayID: UInt32, + expectedIdentity: String + ) -> Bool { + guard let display = displayObject(for: displayID), + String(describing: ObjectIdentifier(display)) == expectedIdentity else { return false } + let selector = NSSelectorFromString("setPreferHDRModes:") + guard let method = compatibleMethod(selector, on: display, as: .boolSetter) else { return false } + typealias Setter = @convention(c) (AnyObject, Selector, Bool) -> Void + unsafeBitCast(method_getImplementation(method), to: Setter.self)(display, selector, enabled) + return true + } + + private func displayObject(for displayID: CGDirectDisplayID) -> NSObject? { + let displaysSelector = NSSelectorFromString("displays") + guard let manager, + hasCompatibleMethod(displaysSelector, on: manager, as: .displaysGetter), + let value = manager.perform(displaysSelector)?.takeUnretainedValue(), + let displays = value as? [NSObject] else { return nil } + return displays.first { display in + let selector = NSSelectorFromString("displayID") + guard let method = compatibleMethod(selector, on: display, as: .displayIDGetter) else { return false } + typealias Getter = @convention(c) (AnyObject, Selector) -> UInt32 + return unsafeBitCast(method_getImplementation(method), to: Getter.self)( + display, selector + ) == displayID + } + } + + private func boolGetter(_ name: String, on object: NSObject) -> Bool? { + let selector = NSSelectorFromString(name) + guard let method = compatibleMethod(selector, on: object, as: .boolGetter) else { return nil } + typealias Getter = @convention(c) (AnyObject, Selector) -> Bool + return unsafeBitCast(method_getImplementation(method), to: Getter.self)(object, selector) + } + + private func hasCompatibleMethod( + _ selector: Selector, + on object: NSObject, + as method: MonitorPanelABIMethod + ) -> Bool { + compatibleMethod(selector, on: object, as: method) != nil + } + + private func compatibleMethod( + _ selector: Selector, + on object: NSObject, + as expectedMethod: MonitorPanelABIMethod + ) -> Method? { + guard object.responds(to: selector), + let runtimeMethod = class_getInstanceMethod(type(of: object), selector), + let encoding = methodEncoding(runtimeMethod) else { return nil } + guard MonitorPanelABISignatureValidator.isCompatible( + encoding, + with: expectedMethod + ) else { return nil } + return runtimeMethod + } + + private func methodEncoding(_ method: Method) -> ObjectiveCMethodEncoding? { + let returnType = method_copyReturnType(method) + defer { free(returnType) } + var argumentTypes: [String] = [] + argumentTypes.reserveCapacity(Int(method_getNumberOfArguments(method))) + for index in 0.. Bool { + if display.isBuiltin { + guard EDROverlayManager.shared.setFactor(1, for: display.displayID) else { return false } + recordControlAppliedFactor(1, for: display) + } else { + let identity = displayIdentity(display) + let token = appliedFactorCommits.begin( + uuid: display.displayUUID, + identity: identity, + factor: 1 + ) + let queueAccepted = await BrightnessService.shared.setBoostFactorForControl( + 1, for: display.displayID, expectedDisplayUUID: display.displayUUID + ) + try Task.checkCancellation() + guard currentDisplayMatches(display) else { return false } + guard appliedFactorCommits.complete( + token, + queueAccepted: queueAccepted, + currentUUID: display.displayUUID, + currentIdentity: displayIdentity(display) + ) else { return false } + } + return true + } - private func animateMaxBrightness(to target: Double, for display: DisplayInfo) { + private func displayIdentity(_ display: DisplayInfo) -> String { + String(describing: ObjectIdentifier(display)) + } + + private func currentDisplayMatches(_ display: DisplayInfo) -> Bool { + DisplayManagerAccessor.shared.displays.contains { + $0 === display && $0.displayUUID == display.displayUUID && $0.displayID == display.displayID + } + } + + private func transitionAccepts(_ token: BoostTransitionToken, display: DisplayInfo) -> Bool { + currentDisplayMatches(display) && boostTransitions.accepts( + token, + currentUUID: display.displayUUID, + currentIdentity: displayIdentity(display) + ) + } + + private func animateMaxBrightness( + to target: Double, + for display: DisplayInfo, + token: BoostTransitionToken + ) { let animator = maxAnimators[display.displayID] ?? BrightnessAnimator() maxAnimators[display.displayID] = animator animator.animate( from: display.maxBrightness, to: target, steps: max(8, Int(0.2 / 0.008)), duration: 0.2 - ) { [weak display] value, _ in - display?.maxBrightness = value + ) { [weak self, weak display] value, _ in + guard let self, let display, self.transitionAccepts(token, display: display) else { return } + display.maxBrightness = value } } @@ -97,7 +303,7 @@ final class BrightnessBoostService { self.headroomLossSince[display.displayID] = since if Date().timeIntervalSince(since) >= 1.5 { self.headroomLossSince.removeValue(forKey: display.displayID) - await self.setEnabled(false, for: display) + _ = try? await self.setEnabled(false, for: display) } } if !anyBoosted { @@ -145,6 +351,21 @@ final class BrightnessBoostService { return Double(s.maximumExtendedDynamicRangeColorComponentValue) } + /// Narrow read-only automation snapshot. Values are relative EDR component + /// headroom reported by NSScreen, never inferred nits. + func controlHeadroomSnapshot(for display: DisplayInfo) -> EDRHeadroomSnapshot { + let appliedFactor = appliedFactorCommits.appliedFactor( + uuid: display.displayUUID, + identity: displayIdentity(display) + ) + return EDRHeadroomSnapshot( + potential: potentialHeadroom(for: display.displayID), + current: currentHeadroom(for: display.displayID), + appliedFactor: appliedFactor, + factorVerification: appliedFactor == nil ? nil : "app_state" + ) + } + // MARK: - Eligibility /// A display can boost when it reports usable EDR headroom (built-in XDR, @@ -157,14 +378,27 @@ final class BrightnessBoostService { return false } + /// A disable remains available after live eligibility disappears when + /// Crisp still has persisted or active boost state to collapse and clear. + func needsDisableCleanup(for display: DisplayInfo) -> Bool { + isEnabled(for: display) + || display.maxBrightness > 100 + || display.brightness > 100 + || activeBoostDisplays.contains(display.displayID) + || collapsingDisplays.contains(display.displayID) + } + // MARK: - Toggle /// Enable or disable boost. Async because switching an external monitor to /// HDR mode takes a moment to settle. Returns false when enabling failed /// (caller reverts the toggle UI). @discardableResult - func setEnabled(_ enabled: Bool, for display: DisplayInfo) async -> Bool { + func setEnabled(_ enabled: Bool, for display: DisplayInfo) async throws -> Bool { let uuid = display.displayUUID + let token = boostTransitions.begin( + uuid: uuid, identity: displayIdentity(display), enabled: enabled + ) if enabled { // A disable-collapse may still be running from a rapid off/on // flip; cancel it where it is (through the same maxAnimators slot @@ -174,15 +408,48 @@ final class BrightnessBoostService { BrightnessService.shared.cancelAnimation(for: display.displayID) maxAnimators[display.displayID]?.cancel() collapsingDisplays.remove(display.displayID) - // Externals in SDR mode: switch to HDR first. + // Externals in SDR mode: switch to HDR first. A matching live + // preference read-back is necessary but not sufficient; EDR + // headroom can appear later, so readiness settles separately. var switchedHDRForThisAttempt = false - if !display.isBuiltin, potentialHeadroom(for: display.displayID) <= 1.05 { - guard supportsHDRMode(display.displayID), setHDRMode(true, for: display.displayID) else { return false } - switchedHDRForThisAttempt = true - // Give WindowServer a moment to re-sync the display in HDR mode. - try? await Task.sleep(nanoseconds: 2_000_000_000) + var potential = potentialHeadroom(for: display.displayID) + if !display.isBuiltin, potential <= 1.05 { + guard supportsHDRMode(display.displayID) else { return false } + if controlHDRState(for: display) != true { + guard try await setHDRMode(true, for: display) else { return false } + switchedHDRForThisAttempt = true + } + let settlement = try await EDRHeadroomSettlement.wait( + maxSamples: 20, + threshold: 1.05, + isCurrent: { + self.transitionAccepts(token, display: display) + }, + isCapable: { + self.supportsHDRMode(display.displayID) + && self.controlHDRState(for: display) == true + }, + potentialHeadroom: { + self.potentialHeadroom(for: display.displayID) + }, + pause: { + try await Task.sleep(nanoseconds: 100_000_000) + } + ) + try Task.checkCancellation() + switch settlement { + case let .ready(potentialHeadroom): + potential = potentialHeadroom + case .timedOut, .capabilityLost: + if switchedHDRForThisAttempt, transitionAccepts(token, display: display) { + _ = try await setHDRMode(false, for: display, requiring: token) + } + return false + case .invalidated: + return false + } } - let potential = potentialHeadroom(for: display.displayID) + guard transitionAccepts(token, display: display) else { return false } let newMax = BrightnessBoostMath.sliderMax(potentialHeadroom: potential) guard newMax > 100 else { // No usable headroom: fail quietly. A user-set HDR mode is @@ -191,21 +458,46 @@ final class BrightnessBoostService { // (preference recorded, mode never applied) leaves the OS // rendering HDR into an SDR link, washing the screen out. if switchedHDRForThisAttempt { - _ = setHDRMode(false, for: display.displayID) + _ = try await setHDRMode(false, for: display, requiring: token) } return false } UserDefaults.standard.set(true, forKey: enabledKey(uuid)) - animateMaxBrightness(to: newMax, for: display) + guard boostTransitions.completeEnable(token) else { return false } + animateMaxBrightness(to: newMax, for: display, token: token) syncOverlay(for: display) + try Task.checkCancellation() return true } else { UserDefaults.standard.set(false, forKey: enabledKey(uuid)) - collapseAndDisable(for: display) - return true + return try await collapseAndDisable(for: display, token: token) } } + /// Automation needs more truth than the GUI's legacy Boolean completion: + /// a disable can be accepted and persisted while a same-display collapse + /// is still observable. Unknown identity or terminal-factor outcomes stay + /// indeterminate, and cancellation continues to throw. + func setEnabledForControl( + _ enabled: Bool, + for display: DisplayInfo + ) async throws -> ExtraBrightnessControlMutationOutcome { + if enabled { + return try await setEnabled(true, for: display) + ? .accepted : .rejectedBeforeAcceptance + } + let operationCompleted = try await setEnabled(enabled, for: display) + return ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: operationCompleted, + identityMatches: currentDisplayMatches(display), + persistedEnabled: isEnabled(for: display), + liveEnabled: display.maxBrightness > 100, + maxBrightness: display.maxBrightness, + cleanupInProgress: collapsingDisplays.contains(display.displayID) + ) + } + /// Single combined collapse: brightness and maxBrightness glide back to /// 100 together, driven by one progress animator, instead of fading /// brightness to 100 first and only then collapsing maxBrightness. That @@ -214,13 +506,19 @@ final class BrightnessBoostService { /// sliderMax for the overlay factor is the frozen starting maxBrightness /// (max0), not the live (shrinking) one, so the multiplier tracks the /// thumb instead of jumping. - private func collapseAndDisable(for display: DisplayInfo) { + private func collapseAndDisable( + for display: DisplayInfo, + token: BoostTransitionToken + ) async throws -> Bool { let displayID = display.displayID let v0 = display.brightness let max0 = display.maxBrightness guard abs(v0 - 100) > 0.001 || abs(max0 - 100) > 0.001 else { - finishDisable(for: display) - return + display.brightness = min(display.brightness, 100) + display.maxBrightness = 100 + guard try await restoreIdentityFactor(for: display), + transitionAccepts(token, display: display) else { return false } + return try await finishDisable(for: display, token: token) } collapsingDisplays.insert(displayID) let animator = maxAnimators[displayID] ?? BrightnessAnimator() @@ -238,6 +536,7 @@ final class BrightnessBoostService { self.maxAnimators[displayID]?.cancel() return } + guard self.transitionAccepts(token, display: display) else { return } // A brightness already at or below 100 is in the native range and // must stay put; only the boosted excess collapses toward 100. let vEnd = min(v0, 100) @@ -249,28 +548,44 @@ final class BrightnessBoostService { currentEDR: self.currentHeadroom(for: displayID), potentialHeadroom: self.potentialHeadroom(for: displayID) ) - EDROverlayManager.shared.setFactor(factor, for: displayID) + if EDROverlayManager.shared.setFactor(factor, for: displayID) { + self.recordControlAppliedFactor(factor, for: display) + } } else { let factor = BrightnessBoostMath.externalBoostFactor( brightness: display.brightness, sliderMax: max0) - BrightnessService.shared.setBoostFactor(factor, for: displayID) + self.queueExternalFactor(factor, for: display) } if isLast { self.collapsingDisplays.remove(displayID) - self.finishDisable(for: display) } } + var polls = 0 + while collapsingDisplays.contains(displayID), polls < 100 { + try await Task.sleep(nanoseconds: 20_000_000) + guard transitionAccepts(token, display: display) else { return false } + polls += 1 + } + guard !collapsingDisplays.contains(displayID), + display.brightness <= 100.001, display.maxBrightness <= 100.001 else { return false } + display.brightness = min(display.brightness, 100) + display.maxBrightness = 100 + guard try await restoreIdentityFactor(for: display), + transitionAccepts(token, display: display) else { return false } + return try await finishDisable(for: display, token: token) } - private func finishDisable(for display: DisplayInfo) { + private func finishDisable( + for display: DisplayInfo, + token: BoostTransitionToken + ) async throws -> Bool { // Close the EDR surface only after everything is static: closing // exits EDR mode, and doing that mid-motion is what flashed. let displayID = display.displayID - Task { @MainActor in - try? await Task.sleep(nanoseconds: 2_000_000_000) - guard !self.isEnabled(for: display) else { return } - EDROverlayManager.shared.removeOverlay(for: displayID) - } + try await Task.sleep(nanoseconds: 2_000_000_000) + guard transitionAccepts(token, display: display), !isEnabled(for: display) else { return false } + EDROverlayManager.shared.removeOverlay(for: displayID) + return boostTransitions.completeDisable(token, atIdentity: true) } // MARK: - Overlay sync (called on every brightness change) @@ -292,6 +607,9 @@ final class BrightnessBoostService { // letting this run concurrently (e.g. from the headroom poll) would // fight it. guard !collapsingDisplays.contains(display.displayID) else { return } + guard boostTransitions.headroomMaySync( + uuid: display.displayUUID, identity: displayIdentity(display) + ) else { return } if display.isBuiltin { let factor = BrightnessBoostMath.overlayFactor( brightness: display.brightness, @@ -299,7 +617,9 @@ final class BrightnessBoostService { currentEDR: currentHeadroom(for: display.displayID), potentialHeadroom: potentialHeadroom(for: display.displayID) ) - EDROverlayManager.shared.setFactor(factor, for: display.displayID) + if EDROverlayManager.shared.setFactor(factor, for: display.displayID) { + recordControlAppliedFactor(factor, for: display) + } // First entry into the boost region arms the fast-poll window: the // EDR ramp that follows is what the poll needs to track closely. if factor > 1.001 { @@ -316,7 +636,7 @@ final class BrightnessBoostService { // the table after an ICC-restore clobber without extra plumbing. let factor = BrightnessBoostMath.externalBoostFactor( brightness: display.brightness, sliderMax: display.maxBrightness) - BrightnessService.shared.setBoostFactor(factor, for: display.displayID) + queueExternalFactor(factor, for: display) } if display.maxBrightness > 100 { startHeadroomPollIfNeeded() } } @@ -328,7 +648,20 @@ final class BrightnessBoostService { func reapplyAll() { syncHDRRouting() var anyEnabled = false - for display in DisplayManagerAccessor.shared.displays where isEnabled(for: display) { + for display in DisplayManagerAccessor.shared.displays { + guard isEnabled(for: display) else { + if display.maxBrightness > 100 { + display.brightness = min(display.brightness, 100) + display.maxBrightness = 100 + if display.isBuiltin { + EDROverlayManager.shared.removeOverlay(for: display.displayID) + recordControlAppliedFactor(1, for: display) + } else { + queueExternalFactor(1, for: display) + } + } + continue + } anyEnabled = true guard isEligible(display) else { continue } let potential = potentialHeadroom(for: display.displayID) @@ -359,9 +692,15 @@ final class BrightnessBoostService { /// displayID cannot inherit it (same hazard as BrightnessService's /// invalidateDDCState; DisplayManager calls both from its removed loop). func invalidate(for displayID: CGDirectDisplayID) { + if let display = DisplayManagerAccessor.shared.displays.first(where: { $0.displayID == displayID }) { + boostTransitions.invalidate(uuid: display.displayUUID, identity: displayIdentity(display)) + appliedFactorCommits.removeAll() + hdrMutations = HDRMutationCoordinator() + } maxAnimators[displayID]?.cancel() maxAnimators.removeValue(forKey: displayID) headroomLossSince.removeValue(forKey: displayID) + activeBoostDisplays.remove(displayID) hdrRequestGeneration.removeValue(forKey: displayID) collapsingDisplays.remove(displayID) hdrSupportCache.removeValue(forKey: displayID) @@ -369,8 +708,21 @@ final class BrightnessBoostService { @objc private func screenParametersChanged() { // DisplayIDs can be reassigned across a reconfiguration; drop the - // capability cache before anything re-reads it. + // capability cache and all in-flight identity generations before + // anything re-reads or mutates a potentially re-used display ID. hdrSupportCache.removeAll() + boostTransitions = BoostTransitionCoordinator() + hdrMutations = HDRMutationCoordinator() + hdrRequestGeneration.removeAll() + maxAnimators.values.forEach { $0.cancel() } + maxAnimators.removeAll() + collapsingDisplays.removeAll() + appliedFactorCommits.removeAll() + headroomPollTask?.cancel() + headroomPollTask = nil + headroomLossSince.removeAll() + activeBoostDisplays.removeAll() + fastPollUntil = nil // Reconcile ONCE after connect/disconnect storms settle (mirrors the // panel's own debounce; mid-reconfig geometry and headroom reads are // garbage): cancel any reconcile a previous notification scheduled. @@ -394,8 +746,13 @@ final class BrightnessBoostService { /// Live HDR mode state, read straight from MPDisplay (not persisted: the /// OS already remembers HDR preference itself). func isHDREnabled(for display: DisplayInfo) -> Bool { - guard let d = mpDisplay(for: display.displayID) else { return false } - return (d.value(forKey: "preferHDRModes") as? Bool) == true + controlHDRState(for: display) == true + } + + /// Narrow read-only automation state. Nil means MonitorPanel cannot + /// currently provide a live preference value for this display. + func controlHDRState(for display: DisplayInfo) -> Bool? { + monitorPanel.readState(displayID: display.displayID)?.prefersHDR } /// Newest HDR-preference request per display. An off request waits out @@ -411,15 +768,16 @@ final class BrightnessBoostService { /// 100 before the mode switch, instead of the collapse animation fighting /// an SDR display underneath it. @discardableResult - func setHDRPreference(_ on: Bool, for display: DisplayInfo) async -> Bool { + func setHDRPreference(_ on: Bool, for display: DisplayInfo) async throws -> Bool { let displayID = display.displayID let generation = (hdrRequestGeneration[displayID] ?? 0) + 1 hdrRequestGeneration[displayID] = generation if on { - return setHDRMode(true, for: displayID) + return try await setHDRMode(true, for: display) } + let hadBoostState = isEnabled(for: display) || collapsingDisplays.contains(displayID) if isEnabled(for: display) { - _ = await setEnabled(false, for: display) + guard try await setEnabled(false, for: display) else { return false } } // Wait on the live collapse set, not the isEnabled flag: a collapse // started moments earlier from the Extra Brightness row has already @@ -428,23 +786,31 @@ final class BrightnessBoostService { // tick would otherwise leave the marker set and spin this forever. var waited = 0 while collapsingDisplays.contains(displayID), waited < 40 { - try? await Task.sleep(nanoseconds: 50_000_000) + try await Task.sleep(nanoseconds: 50_000_000) waited += 1 } // Brief settle so the collapse's last brightness write lands // before the mode switch. - try? await Task.sleep(nanoseconds: 200_000_000) - guard hdrRequestGeneration[displayID] == generation else { return false } - return setHDRMode(false, for: displayID) + try await Task.sleep(nanoseconds: 200_000_000) + guard hdrRequestGeneration[displayID] == generation, + currentDisplayMatches(display), + !collapsingDisplays.contains(displayID), + !isEnabled(for: display), + display.brightness <= 100.001, + display.maxBrightness <= 100.001 else { return false } + if hadBoostState { + guard appliedFactorCommits.isCommitted( + factor: 1, + uuid: display.displayUUID, + identity: displayIdentity(display), + tolerance: 0.001 + ) else { return false } + } + return try await setHDRMode(false, for: display) } // MARK: - MonitorPanel HDR mode (private API; selectors verified by the Task 1 spike) - private func mpDisplay(for displayID: CGDirectDisplayID) -> NSObject? { - guard let displays = manager?.value(forKey: "displays") as? [NSObject] else { return nil } - return displays.first { ($0.value(forKey: "displayID") as? UInt32) == displayID } - } - /// Hardware capability, cached per displayID: the MPDisplay read is a /// synchronous WindowServer round-trip (SLSDisplaySupportsHDRMode), and /// HDRToggleView's body hits this on every render, 125x/s during a @@ -454,21 +820,49 @@ final class BrightnessBoostService { private func supportsHDRMode(_ displayID: CGDirectDisplayID) -> Bool { if let cached = hdrSupportCache[displayID] { return cached } - guard let d = mpDisplay(for: displayID) else { return false } - let supported = (d.value(forKey: "hasHDRModes") as? Bool) == true + let supported = monitorPanel.snapshot(for: displayID)?.supportsHDR == true hdrSupportCache[displayID] = supported return supported } @discardableResult - private func setHDRMode(_ on: Bool, for displayID: CGDirectDisplayID) -> Bool { - guard let d = mpDisplay(for: displayID) else { return false } - let sel = NSSelectorFromString("setPreferHDRModes:") - guard d.responds(to: sel) else { return false } - typealias Fn = @convention(c) (NSObject, Selector, Bool) -> Void - unsafeBitCast(d.method(for: sel), to: Fn.self)(d, sel, on) - BrightnessService.shared.setHDRSoftwareDimming(on, for: displayID) - return true + private func setHDRMode( + _ on: Bool, + for display: DisplayInfo, + requiring boostToken: BoostTransitionToken? = nil + ) async throws -> Bool { + try Task.checkCancellation() + if let boostToken { + guard transitionAccepts(boostToken, display: display) else { return false } + } + guard display.isOnline, !display.isBuiltin, + !VirtualDisplayService.shared.isVirtualDisplay(display.displayID), + currentDisplayMatches(display), + let monitorPanelIdentity = HDRPreferenceAdapterDriver.beginSet( + using: monitorPanel, displayID: display.displayID, requested: on + ) else { return false } + let mutationIdentity = "\(displayIdentity(display))|\(monitorPanelIdentity)" + let token = hdrMutations.begin( + uuid: display.displayUUID, identity: mutationIdentity, requested: on + ) + guard hdrMutations.recordSetterInvocation(token) else { return false } + for _ in 0..<20 { + try Task.checkCancellation() + guard currentDisplayMatches(display) else { return false } + if let liveState = monitorPanel.readState(displayID: display.displayID), + liveState.identity == monitorPanelIdentity, + hdrMutations.observe( + token, + currentUUID: display.displayUUID, + currentIdentity: mutationIdentity, + readback: liveState.prefersHDR + ), let verified = hdrMutations.verifiedRoutingState(for: token) { + BrightnessService.shared.setHDRSoftwareDimming(verified, for: display.displayID) + return true + } + try await Task.sleep(nanoseconds: 100_000_000) + } + return false } /// Keeps BrightnessService's DDC-vs-software routing in step with each @@ -483,7 +877,7 @@ final class BrightnessBoostService { // must be actively cleared out of the software-dimming set, or its // DDC control stays silently routed to gamma. for display in DisplayManagerAccessor.shared.displays where !display.isBuiltin { - let dimmed = isEligibleForHDRToggle(display) && isHDREnabled(for: display) + let dimmed = controlHDRState(for: display) == true BrightnessService.shared.setHDRSoftwareDimming(dimmed, for: display.displayID) } } diff --git a/Crisp/Services/BrightnessService.swift b/Crisp/Services/BrightnessService.swift index 81b87bc..c143138 100644 --- a/Crisp/Services/BrightnessService.swift +++ b/Crisp/Services/BrightnessService.swift @@ -4,6 +4,9 @@ import IOKit.graphics import CoreGraphics // For CGDisplayCreateUUIDFromDisplayID (ApplicationServices, not CoreGraphics). import AppKit +#if canImport(CrispControlCore) +import CrispControlCore +#endif @_silgen_name("CGDisplayIOServicePort") private func CGDisplayIOServicePort(_ display: CGDirectDisplayID) -> io_service_t @@ -159,6 +162,29 @@ final class BrightnessService: @unchecked Sendable { private let queue = DispatchQueue(label: "com.crisp.brightness", qos: .userInitiated) + var hasDisplayServicesControl: Bool { _DSGetBrightness != nil && _DSSetBrightness != nil } + + @MainActor + func controlBackend(for display: DisplayInfo) -> BrightnessBackend { + if display.isBuiltin { return hasDisplayServicesControl ? .displayServices : .ioKit } + let usesSoftware = ddcAvailableLock.withLock { + ddcAvailable[display.displayID] != true || hdrDimmedDisplays.contains(display.displayID) + } + return usesSoftware ? .software : .ddc + } + + @MainActor + func controlReadbackQuality(for display: DisplayInfo) -> ReadbackQuality { + switch controlBackend(for: display) { + case .displayServices, .ioKit: .authoritative + case .ddc: + ddcAvailableLock.withLock { + ddcMaxBrightness[display.displayID] == nil ? .unavailable : .approximate + } + case .software, .unavailable: .unavailable + } + } + // MARK: - Per-display Animators (main thread only) /// One animator per display. Accessed only on the main thread. @@ -379,13 +405,7 @@ final class BrightnessService: @unchecked Sendable { } // Record manual adjust time so auto-brightness can honour the cooldown period. - if !isAutoAdjust { - manualAdjustLock.withLock { - lastManualAdjustDate = Date() - } - PresetService.shared.noteManualChange() - noteManualBrightnessChange(displayID: displayID, isBuiltin: isBuiltin, value: clamped) - } + if !isAutoAdjust { recordManualAdjustment(displayID: displayID, isBuiltin: isBuiltin, value: clamped) } if isBuiltin { let value = Float(hardware / 100.0) @@ -432,6 +452,132 @@ final class BrightnessService: @unchecked Sendable { } } + @MainActor + private func recordManualAdjustment(displayID: CGDirectDisplayID, isBuiltin: Bool, value: Double) { + manualAdjustLock.withLock { lastManualAdjustDate = Date() } + PresetService.shared.noteManualChange() + noteManualBrightnessChange(displayID: displayID, isBuiltin: isBuiltin, value: value) + } + + /// Automation uses the same backend state as the panel. Reads are awaited so + /// the command dispatcher can enforce write-after-read-back before reporting success. + @MainActor + func readBrightnessForControl(for display: DisplayInfo) async -> Double? { + if display.isBuiltin { + let value = await withCheckedContinuation { continuation in + queue.async { [weak self] in continuation.resume(returning: self?.getInternalBrightness()) } + } + if let value { display.brightness = value } + return value + } + guard controlBackend(for: display) == .ddc else { return display.brightness } + guard let percent = await readExternalDDCBrightnessForControl(for: display) else { return nil } + display.brightness = percent + return percent + } + + @MainActor + private func readExternalDDCBrightnessForControl(for display: DisplayInfo) async -> Double? { + let result = await withCheckedContinuation { continuation in + DDCService.shared.readAsync(displayID: display.displayID, command: DDCService.brightnessVCP) { + continuation.resume(returning: $0) + } + } + guard let result, result.max > 0 else { return nil } + ddcAvailableLock.withLock { + ddcMaxBrightness[display.displayID] = result.max + ddcAvailable[display.displayID] = true + } + return Double(result.current) / Double(result.max) * 100 + } + + /// Separates Crisp's logical slider state from native/DDC read-back. While + /// boosted, hardware is intentionally pinned at 100 and must not overwrite + /// the committed logical value above 100. + @MainActor + func readBrightnessStateForControl(for display: DisplayInfo) async -> BrightnessReadSnapshot? { + if display.brightness > 100 { + let hardware: Double? = if display.isBuiltin { + await withCheckedContinuation { continuation in + queue.async { [weak self] in + continuation.resume(returning: self?.getInternalBrightness()) + } + } + } else if controlBackend(for: display) == .ddc { + await readExternalDDCBrightnessForControl(for: display) + } else { + nil + } + return BrightnessReadSnapshot( + logicalPercent: display.brightness, + hardwareReadbackPercent: hardware + ) + } + guard let value = await readBrightnessForControl(for: display) else { return nil } + let hardware = controlReadbackQuality(for: display) == .unavailable ? nil : value + return BrightnessReadSnapshot(logicalPercent: display.brightness, hardwareReadbackPercent: hardware) + } + + /// Performs one bounded backend write for crispctl. The dispatcher owns range + /// validation and the subsequent independent read-back check. + @MainActor + func writeBrightnessForControl(_ percent: Double, for display: DisplayInfo) async throws -> Double { + guard percent >= 0, percent <= display.maxBrightness else { + throw ControlServiceError.writeFailed("brightness is outside the live logical range") + } + if percent > 100 { + await setBrightness(percent, for: display) + return display.brightness + } + + let applied = max(0, min(display.maxBrightness, percent)) + cancelAnimation(for: display.displayID) + + return try await BrightnessWriteCommit.perform { + if display.isBuiltin { + let succeeded = await withCheckedContinuation { continuation in + queue.async { [weak self] in + continuation.resume(returning: self?.setInternalBrightness(Float(applied / 100)) == true) + } + } + guard succeeded else { + throw ControlServiceError.writeFailed("built-in brightness backend rejected the write") + } + } else if controlBackend(for: display) == .ddc { + let maximum = ddcAvailableLock.withLock { ddcMaxBrightness[display.displayID] ?? 100 } + let value = UInt16((applied / 100 * Double(maximum)).rounded()) + let succeeded = await withCheckedContinuation { continuation in + DDCService.shared.writeAsync( + displayID: display.displayID, + command: DDCService.brightnessVCP, + value: value + ) { continuation.resume(returning: $0) } + } + guard succeeded else { + throw ControlServiceError.writeFailed("DDC brightness backend rejected the write") + } + await withCheckedContinuation { continuation in + queue.async { [weak self] in + self?.applyDDCGammaBlend(percent: applied, displayID: display.displayID) + continuation.resume() + } + } + } else { + await withCheckedContinuation { continuation in + queue.async { [weak self] in + self?.setSoftwareBrightness(applied, for: display.displayID) + continuation.resume() + } + } + } + return applied + } commit: { applied in + recordManualAdjustment(displayID: display.displayID, isBuiltin: display.isBuiltin, value: applied) + display.brightness = applied + BrightnessBoostService.shared.syncOverlay(for: display) + } + } + // MARK: - Coalescing DDC Writer /// Latest pending brightness percent per display. Only one DDC write is in flight @@ -490,21 +636,15 @@ final class BrightnessService: @unchecked Sendable { ddcPumpLock.unlock() if !alreadyPumping { pumpDDCWrite(for: displayID) } - queue.async { [weak self] in - guard let self else { return } - if percent < self.gammaBlendThreshold { - self.setSoftwareBrightness(percent / self.gammaBlendThreshold * 100.0, for: displayID) - } else if let f = self.currentSoftwareBrightness(for: displayID), f < 1.0 { - // Only clear a software dim once DDC has actually succeeded on - // this display. While it is still unproven (nil), a display - // whose writes all fail (Dell without a DDC channel) would - // otherwise flash to full on every attempt, fighting the gamma - // fallback that is actually doing the dimming. - let proven = self.ddcAvailableLock.withLock { self.ddcAvailable[displayID] == true } - if proven { - self.setSoftwareBrightness(100.0, for: displayID) - } - } + queue.async { [weak self] in self?.applyDDCGammaBlend(percent: percent, displayID: displayID) } + } + + private func applyDDCGammaBlend(percent: Double, displayID: CGDirectDisplayID) { + if percent < gammaBlendThreshold { + setSoftwareBrightness(percent / gammaBlendThreshold * 100, for: displayID) + } else if let factor = currentSoftwareBrightness(for: displayID), factor < 1, + ddcAvailableLock.withLock({ ddcAvailable[displayID] == true }) { + setSoftwareBrightness(100, for: displayID) } } @@ -710,9 +850,43 @@ final class BrightnessService: @unchecked Sendable { /// External boost region: BrightnessBoostService drives the transfer table /// above 1.0 through here, on the same serial queue as the dim path, so /// slider motion above and below 100 is always one writer, one table. - func setBoostFactor(_ factor: Double, for displayID: CGDirectDisplayID) { + func setBoostFactor( + _ factor: Double, + for displayID: CGDirectDisplayID, + expectedDisplayUUID: String, + completion: @escaping @Sendable (Bool) -> Void + ) { queue.async { [weak self] in - self?.setSoftwareBrightness(factor * 100.0, for: displayID) + guard Self.displayUUIDString(for: displayID) == expectedDisplayUUID, + let self else { + completion(false) + return + } + self.setSoftwareBrightness(factor * 100.0, for: displayID) + completion(true) + } + } + + /// Automation terminal-state barrier for Extra Brightness disable. This + /// still uses the GUI-owned transfer-table path; completion means Crisp's + /// queued app-state write ran, not that emitted light was independently + /// measured. + @MainActor + func setBoostFactorForControl( + _ factor: Double, + for displayID: CGDirectDisplayID, + expectedDisplayUUID: String + ) async -> Bool { + await withCheckedContinuation { continuation in + queue.async { [weak self] in + guard Self.displayUUIDString(for: displayID) == expectedDisplayUUID, + let self else { + continuation.resume(returning: false) + return + } + self.setSoftwareBrightness(factor * 100.0, for: displayID) + continuation.resume(returning: true) + } } } @@ -875,11 +1049,12 @@ final class BrightnessService: @unchecked Sendable { return nil } - private func setInternalBrightness(_ value: Float) { + @discardableResult + private func setInternalBrightness(_ value: Float) -> Bool { // Primary: DisplayServices (works on Apple Silicon, where IODisplayConnect is gone) if let set = _DSSetBrightness, let id = builtinDisplayID() { if set(id, value) == 0 { - return + return true } } @@ -888,7 +1063,7 @@ final class BrightnessService: @unchecked Sendable { if IODisplaySetFloatParameter( servicePort, 0, Self.ioDisplayBrightnessKey, value ) == KERN_SUCCESS { - return + return true } } @@ -915,7 +1090,7 @@ final class BrightnessService: @unchecked Sendable { IOServiceMatching("IODisplayConnect"), &iter ) == KERN_SUCCESS else { - return + return false } defer { IOObjectRelease(iter) } @@ -926,8 +1101,9 @@ final class BrightnessService: @unchecked Sendable { if IODisplaySetFloatParameter( service, 0, Self.ioDisplayBrightnessKey, value ) == KERN_SUCCESS { - return + return true } } + return false } } diff --git a/Crisp/Services/CGHelpers.swift b/Crisp/Services/CGHelpers.swift index d9e7ca6..b7d672a 100644 --- a/Crisp/Services/CGHelpers.swift +++ b/Crisp/Services/CGHelpers.swift @@ -1,5 +1,11 @@ import Foundation +enum CGOperationOutcome: Sendable { + case completed(Value) + case timedOut + case cancelled +} + /// Shared utilities for wrapping blocking CoreGraphics calls. enum CGHelpers { @@ -23,26 +29,63 @@ enum CGHelpers { fallback: T, operation: @escaping @Sendable () -> T ) async -> T { - await withCheckedContinuation { cont in - let lock = NSLock() - var didResume = false + switch await runWithTimeoutOutcome(seconds: seconds, operation: operation) { + case let .completed(value): value + case .timedOut, .cancelled: fallback + } + } - DispatchQueue.global(qos: .userInitiated).async { - let result = operation() - lock.lock() - guard !didResume else { lock.unlock(); return } - didResume = true - lock.unlock() - cont.resume(returning: result) + /// Unlike the compatibility wrapper above, this preserves whether the language-level + /// wait timed out or was cancelled. The queued blocking operation is intentionally not + /// described as cancelled: Dispatch/WindowServer work may continue after either event. + static func runWithTimeoutOutcome( + seconds: Double, + operation: @escaping @Sendable () -> T + ) async -> CGOperationOutcome { + guard !Task.isCancelled else { return .cancelled } + let race = CGOperationRace() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + race.install(continuation) + DispatchQueue.global(qos: .userInitiated).async { + race.resolve(.completed(operation())) + } + DispatchQueue.global().asyncAfter(deadline: .now() + max(0, seconds)) { + race.resolve(.timedOut) + } } + } onCancel: { + race.resolve(.cancelled) + } + } +} - DispatchQueue.global().asyncAfter(deadline: .now() + seconds) { - lock.lock() - guard !didResume else { lock.unlock(); return } - didResume = true - lock.unlock() - cont.resume(returning: fallback) - } +private final class CGOperationRace: @unchecked Sendable { + private let lock = NSLock() + private var outcome: CGOperationOutcome? + private var continuation: CheckedContinuation, Never>? + + func install(_ continuation: CheckedContinuation, Never>) { + lock.lock() + if let outcome { + lock.unlock() + continuation.resume(returning: outcome) + return + } + self.continuation = continuation + lock.unlock() + } + + func resolve(_ outcome: CGOperationOutcome) { + lock.lock() + guard self.outcome == nil else { + lock.unlock() + return } + self.outcome = outcome + let continuation = continuation + self.continuation = nil + lock.unlock() + continuation?.resume(returning: outcome) } } diff --git a/Crisp/Services/PhysicalDisplayToggleService.swift b/Crisp/Services/PhysicalDisplayToggleService.swift index 0632dd6..ca04176 100644 --- a/Crisp/Services/PhysicalDisplayToggleService.swift +++ b/Crisp/Services/PhysicalDisplayToggleService.swift @@ -1,7 +1,19 @@ +// Safety-critical display recovery is intentionally kept beside its persistence adapter. +// swiftlint:disable file_length import Foundation import CoreGraphics import ColorSync import IOKit +import Security +import Darwin +#if canImport(CrispControlCore) +import CrispControlCore +#endif + +@_silgen_name("CGDisplayIOServicePort") +private func CGDisplayIOServicePortForPhysicalProof( + _ display: CGDirectDisplayID +) -> io_service_t /// Disconnects / reconnects REAL (physical) displays on the fly, the way BetterDisplay's /// "Disconnect Display" works. This is fundamentally different from VirtualDisplayService: @@ -11,56 +23,81 @@ import IOKit /// PLATFORM: Apple Silicon + macOS 13+ ONLY. On Intel the API does not perform a true /// disconnect. Everything is gated behind `isSupported`. /// -/// KEY QUIRK: once a display is disabled it disappears from `CGGetOnlineDisplayList` -/// (and `CGGetActiveDisplayList`). To reconnect it we must find it again via -/// `SLSGetDisplayList`, which still enumerates disabled displays. Because the disconnected -/// display is also gone from DisplayManager's list, this service keeps its own snapshot -/// (`disconnected`) of what we turned off so the UI can still offer a Reconnect action. +/// KEY QUIRK: once a display is disabled it can lose its UUID even though `SLSGetDisplayList` +/// retains an ID. This service keeps UUID-scoped UI metadata plus a bounded, one-shot recovery +/// capability; exact UUID resolution always takes priority when it is available. @MainActor -final class PhysicalDisplayToggleService: ObservableObject { +final class PhysicalDisplayToggleService: ObservableObject, DisplayConnectionMutationAdapter { + private static let connectionRecoveryStateKey = + "crisp.PhysicalDisplayToggleService.connectionRecoveryState.v1" static let shared = PhysicalDisplayToggleService() + + private let connectionPersistence: DisplayConnectionPersistenceBoundary private init() { + let key = Self.connectionRecoveryStateKey + connectionPersistence = DisplayConnectionPersistenceBoundary( + read: { UserDefaults.standard.data(forKey: key) }, + write: { UserDefaults.standard.set($0, forKey: key) } + ) loadDesired() } /// 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. - struct DisconnectedDisplay: Identifiable, Codable, Sendable, Equatable { - let uuid: String // stable identity across CGDirectDisplayID reassignment - var displayID: CGDirectDisplayID // last-known ID (used to reconnect) - var name: String - var width: Int - var height: Int - var id: String { uuid } - } - + typealias DisconnectedDisplay = DisplayConnectionPersistedRecord + private typealias PersistedConnectionState = DisplayConnectionPersistenceEnvelope enum ToggleError: Error, Sendable, CustomStringConvertible { - case unsupportedPlatform - case wouldLeaveNoActiveDisplay + case unsupportedPlatform, wouldLeaveNoActiveDisplay, outcomeIndeterminate + case displayNotFound, hardwareBackingUnproven case configurationFailed(CGError) - case displayNotFound + case mutationFailed(String) var description: String { switch self { - case .unsupportedPlatform: - return String(localized: "Physical display disconnect requires Apple Silicon (macOS 13+).") - case .wouldLeaveNoActiveDisplay: - return String(localized: "Refusing to disconnect: it would leave no active display.") - case .configurationFailed(let err): - return String(localized: "Display configuration failed (CGError \(String(err.rawValue))).") - case .displayNotFound: - return String(localized: "Display not found.") + case .unsupportedPlatform: return String(localized: "Physical display disconnect requires Apple Silicon (macOS 13+).") + case .wouldLeaveNoActiveDisplay: return String(localized: "Refusing to disconnect: it would leave no active display.") + case .configurationFailed(let err): return String(localized: "Display configuration failed (CGError \(String(err.rawValue))).") + case .outcomeIndeterminate: + return String(localized: "Display configuration may still complete; refresh before deciding again.") + case .displayNotFound: return String(localized: "Display not found.") + case .hardwareBackingUnproven: return String(localized: "Display cannot be proven to be hardware-backed physical.") + case let .mutationFailed(message): return message } } } + private enum ConfigurationTransactionOutcome: Sendable { + case completed, timedOut, cancelled + case rejectedBeforeDispatch(CGError) + case failedAfterDispatch(CGError) + } + + private enum ControlEnumerationError: Error { + case fullListFailed(CGError), onlineListFailed(CGError), activeListFailed(CGError) + case persistenceFailed + } + // MARK: - State /// Displays the user has disconnected and can reconnect. Persisted (by UUID) so wake and /// relaunch can restore the intended state. @Published private(set) var disconnected: [DisconnectedDisplay] = [] + /// Process-local ownership for explicit reconnects. The durable reservation below records + /// crash uncertainty; only this set proves that the current process still has a live owner. + private var liveReconnectReservationUUIDs: Set = [] + /// Quarantine reconciliation is explicit and read-back-only, but still single-flight so an + /// overlapping GUI/CLI request cannot observe its metadata transition and dispatch a write. + private var liveQuarantineReconciliationUUIDs: Set = [] + + // Legacy keys are migration-only once the authoritative envelope exists. private let desiredKey = "crisp.PhysicalDisconnectedUUIDs" + /// UUIDs whose automation disconnect was prepared but has not yet been proved offline. + /// The marker is persisted before dispatch. A timed-out WindowServer call may continue, + /// so normal reconciliation and wake handling must preserve the recovery record until + /// fresh enumeration proves that exact UUID is offline. + private let controlPendingDisconnectUUIDsKey = + "crisp.PhysicalDisplayToggleService.controlPendingDisconnectUUIDs" /// Dead-man markers: the UUIDs of displays a softReconnect is (or was, if the app died) /// mid-toggle on. A list, not a single slot: a manual smooth-scaling toggle and the /// auto-HiDPI path (autoEnableHiDPIIfNeeded) can blink two different displays at once, @@ -72,18 +109,68 @@ final class PhysicalDisplayToggleService: ObservableObject { /// this keeps the recovery path and the sweep from re-enabling a display out from under /// its own retry loop. Per-display, so concurrent blinks don't mask each other. private var softReconnectInFlight: Set = [] + /// Guards against overlapping recovery runs from reconfiguration-callback bursts. + private var strandedRecoveryInFlight = false + /// Sleep guard parked by a soft reconnect that has not verifiably recovered. + private var lingeringSleepGuard: CGVirtualDisplay? + /// Portables enforce Clamshell Sleep the moment no display is active; desktops don't. + /// Battery presence is the lid-independent laptop test. + private static let hasBattery: Bool = { + let service = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("AppleSmartBattery") + ) + guard service != 0 else { return false } + IOObjectRelease(service) + return true + }() + + private func connectionStateSnapshot( + synchronizePublished: Bool = false + ) throws -> DisplayConnectionPersistenceSnapshot { + do { + let snapshot = try connectionPersistence.snapshot() + if synchronizePublished { + connectionPersistence.adoptPublishedRecords(from: snapshot) + disconnected = connectionPersistence.publishedRecords + } + return snapshot + } catch { + throw ControlEnumerationError.persistenceFailed + } + } + @discardableResult + private func persistConnectionState( + _ proposedState: PersistedConnectionState, + replacing oldSnapshot: DisplayConnectionPersistenceSnapshot, + quarantiningUUIDs: Set + ) throws -> PersistedConnectionState { + let result: DisplayConnectionPersistenceWriteResult + do { + result = try connectionPersistence.replace( + oldState: oldSnapshot.envelope, + proposedState: proposedState, + quarantiningUUIDs: quarantiningUUIDs + ) + } catch { + throw ControlEnumerationError.persistenceFailed + } + disconnected = connectionPersistence.publishedRecords + guard result.disposition == .committedProposed else { + throw ControlEnumerationError.persistenceFailed + } + return result.snapshot.envelope + } private func pendingSoftReconnectUUIDs() -> [String] { UserDefaults.standard.stringArray(forKey: softReconnectPendingKey) ?? [] } - private func addPendingSoftReconnect(_ displayUUID: String) { var pending = pendingSoftReconnectUUIDs() guard !pending.contains(displayUUID) else { return } pending.append(displayUUID) UserDefaults.standard.set(pending, forKey: softReconnectPendingKey) } - private func removePendingSoftReconnect(_ displayUUID: String) { let pending = pendingSoftReconnectUUIDs().filter { $0 != displayUUID } if pending.isEmpty { @@ -98,7 +185,8 @@ final class PhysicalDisplayToggleService: ObservableObject { /// True only on Apple Silicon. The disconnect API is a no-op / misbehaves on Intel. let isSupported: Bool = { #if arch(arm64) - return true + if #available(macOS 13.0, *) { return true } + return false #else return false #endif @@ -115,7 +203,10 @@ final class PhysicalDisplayToggleService: ObservableObject { /// Virtual displays are excluded from the count on purpose: they are headless, /// so leaving only a virtual display still blacks out the physical machine. func wouldLeaveNoActiveDisplay(_ displayID: CGDirectDisplayID) -> Bool { - CGDisplayIsActive(displayID) != 0 && physicalActiveDisplayCount() <= 1 + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: CGDisplayIsActive(displayID) != 0, + activePhysicalDisplayCount: physicalActiveDisplayCount() + ) } /// All display IDs known to the window server, INCLUDING ones disabled via @@ -128,85 +219,917 @@ final class PhysicalDisplayToggleService: ObservableObject { return Array(ids.prefix(Int(count))) } - /// Count of active displays that are real physical screens, excluding virtual - /// displays managed by VirtualDisplayService (a virtual display is active in - /// CGGetActiveDisplayList but is not a viewable screen). - private func physicalActiveDisplayCount() -> Int { + /// Count only active displays with positive hardware-backed physical proof. + private func physicalActiveDisplayCount() -> Int? { var count: UInt32 = 0 - guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return 0 } + guard CGGetActiveDisplayList(0, nil, &count) == .success else { return nil } + guard count > 0 else { return 0 } var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) - guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return 0 } - let virtual = VirtualDisplayService.shared - return ids.prefix(Int(count)).filter { id in - guard !virtual.isVirtualDisplay(id) else { return false } - // Once the last real display is gone macOS spawns a placeholder - // display (vendor 'unkn' 0x756E6B6E, model 'virt' 0x76697274, - // fingerprinted live on macOS 26). It is not a viewable screen, and - // counting it kept restoreIfNoActiveDisplay from ever firing in the - // all-screens-black state it exists to fix. - let isPlaceholder = CGDisplayVendorNumber(id) == 0x756E6B6E - && CGDisplayModelNumber(id) == 0x76697274 - return !isPlaceholder - }.count + guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return nil } + return ids.prefix(Int(count)).filter(isHardwareBackedPhysicalDisplay).count + } + + private func hardwareBackingEvidence( + for displayID: CGDirectDisplayID + ) -> HardwareBackedPhysicalDisplayEvidence { + let servicePort = CGDisplayIOServicePortForPhysicalProof(displayID) + let hasIOServicePort = servicePort != 0 && servicePort != MACH_PORT_NULL + let conformsToDisplayConnect = hasIOServicePort + && IOObjectConformsTo(servicePort, "IODisplayConnect") != 0 + let isBuiltin = CGDisplayIsBuiltin(displayID) != 0 + let isKnownVirtual = VirtualDisplayService.shared.isVirtualDisplay(displayID) + let needsFramebufferFallback = !isBuiltin + && !isKnownVirtual + && !conformsToDisplayConnect + return HardwareBackedPhysicalDisplayEvidence( + isBuiltin: isBuiltin, + isKnownVirtual: isKnownVirtual, + hasIOServicePort: hasIOServicePort, + ioServiceConformsToDisplayConnect: conformsToDisplayConnect, + coreGraphicsIdentity: needsFramebufferFallback + ? hardwareIdentity(for: displayID) + : nil, + framebufferSnapshot: needsFramebufferFallback + ? framebufferSnapshotForPhysicalProof() + : nil + ) + } + + private func hardwareBackingEvidence( + for displayID: CGDirectDisplayID, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> HardwareBackedPhysicalDisplayEvidence { + let servicePort = CGDisplayIOServicePortForPhysicalProof(displayID) + let hasIOServicePort = servicePort != 0 && servicePort != MACH_PORT_NULL + let conformsToDisplayConnect = hasIOServicePort + && IOObjectConformsTo(servicePort, "IODisplayConnect") != 0 + let isBuiltin = CGDisplayIsBuiltin(displayID) != 0 + let isKnownVirtual = VirtualDisplayService.shared.isVirtualDisplay(displayID) + let needsFramebufferFallback = !isBuiltin + && !isKnownVirtual + && !conformsToDisplayConnect + return HardwareBackedPhysicalDisplayEvidence( + isBuiltin: isBuiltin, + isKnownVirtual: isKnownVirtual, + hasIOServicePort: hasIOServicePort, + ioServiceConformsToDisplayConnect: conformsToDisplayConnect, + coreGraphicsIdentity: needsFramebufferFallback + ? hardwareIdentity(for: displayID) + : nil, + framebufferSnapshot: needsFramebufferFallback ? framebufferSnapshot : nil + ) + } + + private func hardwareIdentity(for displayID: CGDirectDisplayID) -> HardwareDisplayIdentity { + HardwareDisplayIdentity( + vendorID: CGDisplayVendorNumber(displayID), + productID: CGDisplayModelNumber(displayID), + serialNumber: CGDisplaySerialNumber(displayID) + ) + } + + /// Keep every enumerated framebuffer so Core can fail closed over all EDID-backed candidates. + private func framebufferSnapshotForPhysicalProof( + ) -> [HardwareFramebufferIdentityEvidence]? { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching("IOMobileFramebuffer"), + &iterator + ) == KERN_SUCCESS else { return nil } + defer { IOObjectRelease(iterator) } + + var snapshot: [HardwareFramebufferIdentityEvidence] = [] + var service = IOIteratorNext(iterator) + while service != 0 { + snapshot.append(framebufferIdentityEvidenceForPhysicalProof(service)) + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + return snapshot + } + + private func framebufferIdentityEvidenceForPhysicalProof( + _ service: io_service_t + ) -> HardwareFramebufferIdentityEvidence { + let edidUUID = IORegistryEntryCreateCFProperty( + service, + "EDID UUID" as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String + var registryEntryID: UInt64 = 0 + let hasRegistryEntryID = IORegistryEntryGetRegistryEntryID( + service, + ®istryEntryID + ) == KERN_SUCCESS + return HardwareFramebufferIdentityEvidence( + registryEntryID: hasRegistryEntryID ? registryEntryID : nil, + hasEDIDUUID: edidUUID?.isEmpty == false, + identity: framebufferIdentityForPhysicalProof(service) + ) + } + + private func framebufferIdentityForPhysicalProof( + _ service: io_service_t + ) -> HardwareDisplayIdentity? { + guard let displayAttributes = IORegistryEntryCreateCFProperty( + service, + "DisplayAttributes" as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? [String: Any], + let productAttributes = displayAttributes["ProductAttributes"] as? [String: Any] + else { return nil } + + return HardwareDisplayIdentity( + vendorID: uint32PhysicalProofValue(productAttributes["LegacyManufacturerID"]), + productID: uint32PhysicalProofValue(productAttributes["ProductID"]), + serialNumber: uint32PhysicalProofValue(productAttributes["SerialNumber"]) + ) + } + + private func uint32PhysicalProofValue(_ value: Any?) -> UInt32? { + guard let number = value as? NSNumber else { return nil } + let signedValue = number.int64Value + guard signedValue >= 0, signedValue <= Int64(UInt32.max) else { return nil } + return UInt32(signedValue) + } + + func isHardwareBackedPhysicalDisplay(_ displayID: CGDirectDisplayID) -> Bool { + HardwareBackedPhysicalDisplayClassifier.isHardwareBacked( + hardwareBackingEvidence(for: displayID) + ) } private func uuid(for displayID: CGDirectDisplayID) -> String { - if let cf = CGDisplayCreateUUIDFromDisplayID(displayID), - let s = CFUUIDCreateString(nil, cf.takeRetainedValue()) { - return s as String + stableUUID(for: displayID) ?? "id-\(displayID)" + } + + private func stableUUID(for displayID: CGDirectDisplayID) -> String? { + guard let cf = CGDisplayCreateUUIDFromDisplayID(displayID), + let value = CFUUIDCreateString(nil, cf.takeRetainedValue()) else { return nil } + let uuid = value as String + return isExactControlUUID(uuid) ? uuid : nil + } + + private func isExactControlUUID(_ value: String) -> Bool { + ControlRequest.isExactDisplayUUID(value) + } + + private func connectionCandidate( + displayID: CGDirectDisplayID, + onlineIDs: Set, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]?, + retainedRecords: [DisconnectedDisplay] + ) -> DisplayConnectionCandidate { + let evidence = hardwareBackingEvidence( + for: displayID, + framebufferSnapshot: framebufferSnapshot + ) + let isHardwareBacked = HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(evidence) + let stableUUID = stableUUID(for: displayID) + let directRecoveryProof = recoveryHardwareProof( + for: displayID, + isHardwareBacked: isHardwareBacked, + framebufferSnapshot: framebufferSnapshot + ) + return DisplayConnectionCandidate( + displayID: displayID, + stableUUID: stableUUID, + isOnline: onlineIDs.contains(displayID), + isHardwareBackedPhysical: isHardwareBacked, + recoveryHardwareProof: directRecoveryProof ?? (stableUUID == nil + ? retainedRecoveryHardwareProof( + for: displayID, + framebufferSnapshot: framebufferSnapshot, + retainedRecords: retainedRecords + ) + : nil) + ) + } + + private func retainedRecoveryHardwareProof( + for displayID: CGDirectDisplayID, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]?, + retainedRecords: [DisconnectedDisplay] + ) -> DisplayConnectionRecoveryHardwareProof? { + let claims = retainedRecords.compactMap(\.recoveryCapability).filter { + $0.displayID == displayID } - return "id-\(displayID)" + guard claims.count == 1, let proof = claims.first?.hardwareProof else { return nil } + return DisplayConnectionRecoveryProofBinder.isDirectlyBound( + retainedProof: proof, + currentIsBuiltIn: CGDisplayIsBuiltin(displayID) != 0, + currentIdentity: hardwareIdentity(for: displayID), + framebufferSnapshot: framebufferSnapshot + ) ? proof : nil } - // MARK: - Disconnect / Reconnect + private func recoveryHardwareProof( + for displayID: CGDirectDisplayID, + isHardwareBacked: Bool, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> DisplayConnectionRecoveryHardwareProof? { + guard isHardwareBacked else { return nil } + if CGDisplayIsBuiltin(displayID) != 0 { + return DisplayConnectionRecoveryHardwareProof(isBuiltIn: true, identity: nil) + } + let identity = hardwareIdentity(for: displayID) + guard HardwareFramebufferIdentityMatcher.hasUniqueExactMatch( + target: identity, + framebufferSnapshot: framebufferSnapshot + ) else { return nil } + return DisplayConnectionRecoveryHardwareProof(isBuiltIn: false, identity: identity) + } - /// Disconnects a physical display and records a snapshot for later reconnect. Refuses if it - /// would leave zero active displays, so the user can never black out their only screen. - @discardableResult - func disconnect(_ display: DisplayInfo) async -> Result { - guard isSupported else { return .failure(.unsupportedPlatform) } - let displayID = display.displayID - if wouldLeaveNoActiveDisplay(displayID) { return .failure(.wouldLeaveNoActiveDisplay) } + private func currentBootSessionID() -> String? { + guard let value = sysctlString("kern.bootsessionuuid"), + UUID(uuidString: value) != nil else { return nil } + return value.uppercased() + } - // Snapshot BEFORE disabling, afterwards the display is gone from the normal APIs. - let snapshot = DisconnectedDisplay( + private func currentLoginSessionID() -> String? { + var sessionID: SecuritySessionId = 0 + var attributes = SessionAttributeBits(rawValue: 0) + guard SessionGetInfo( + callerSecuritySession, + &sessionID, + &attributes + ) == errSecSuccess, sessionID != 0 else { return nil } + return String(sessionID) + } + + private func currentWakeSessionID() -> String? { + var timebase = mach_timebase_info_data_t() + guard mach_timebase_info(&timebase) == KERN_SUCCESS else { return nil } + let continuousBefore = mach_continuous_time() + let absolute = mach_absolute_time() + let continuousAfter = mach_continuous_time() + return DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: continuousBefore, + absoluteTicks: absolute, + continuousAfterTicks: continuousAfter, + timebaseNumerator: timebase.numer, timebaseDenominator: timebase.denom + ) + } + + private func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 1 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer) + } +} + +// MARK: - Automation control adapter + +extension PhysicalDisplayToggleService { + func connectionCapabilitiesForControl( + _ displays: [DisplayInfo] + ) -> [String: DisplayConnectionCapability] { + let subjects = displays.map { display in + DisplayConnectionCapabilitySubject( + uuid: display.displayUUID, + staticUnsupportedCapability: staticUnsupportedConnectionCapability(for: display) + ) + } + return DisplayConnectionReadOnlyQueries.connectedCapabilities( + subjects: subjects, + loadSnapshot: { try connectionStateSnapshot() }, + buildObservation: { snapshot in + try connectionObservation(persistenceSnapshot: snapshot) + } + ) + } + + private func staticUnsupportedConnectionCapability( + for display: DisplayInfo + ) -> DisplayConnectionCapability? { + guard isSupported else { + return .unsupported( + connected: true, + reason: "physical display disconnect requires Apple Silicon and macOS 13 or later", + remediation: "use a supported Apple Silicon Mac or leave connection changes to the GUI" + ) + } + guard isExactControlUUID(display.displayUUID) else { + return .unsupported( + connected: true, + platformSupported: true, + reason: "a stable display UUID is unavailable", + remediation: "refresh displays after reconnecting the physical cable" + ) + } + if let unsupported = HardwareBackedPhysicalDisplayClassifier + .unsupportedConnectionCapability( + for: hardwareBackingEvidence(for: display.displayID), + connected: true + ) { + return unsupported + } + return nil + } + + func disconnectedDisplaysForControl() throws -> [ControlDisconnectedDisplay] { + let snapshot = try connectionStateSnapshot() + let observation = try connectionObservation(persistenceSnapshot: snapshot) + return DisplayConnectionReadOnlyQueries.disconnectedDisplays( + persistenceSnapshot: snapshot, + observation: observation + ) + } + + func disconnectForControl(_ display: DisplayInfo) async throws + -> DisplayConnectionSetResult { + let target = DisplayConnectionTarget( uuid: display.displayUUID, - displayID: displayID, + displayID: display.displayID, name: display.name, width: display.pixelWidth, - height: display.pixelHeight + height: display.pixelHeight, + isHardwareBackedPhysical: isHardwareBackedPhysicalDisplay(display.displayID) + ) + return try await DisplayConnectionMutationCoordinator(adapter: self).disconnect(target) + } + + func reconnectForControl(uuid: String) async throws -> DisplayConnectionSetResult { + try await DisplayConnectionMutationCoordinator(adapter: self).reconnect(uuid: uuid) + } + + func connectionObservation() throws -> DisplayConnectionObservation { + let persistenceSnapshot = try connectionStateSnapshot(synchronizePublished: true) + return try connectionObservation(persistenceSnapshot: persistenceSnapshot) + } + + private func connectionObservation( + persistenceSnapshot: DisplayConnectionPersistenceSnapshot + ) throws -> DisplayConnectionObservation { + let records = persistenceSnapshot.envelope.records + guard records.allSatisfy({ isExactControlUUID($0.uuid) }) else { + throw ControlEnumerationError.persistenceFailed + } + let all = try controlAllDisplayIDs() + let online = try controlOnlineDisplayIDs() + let active = try controlActiveDisplayIDs() + let onlineIDs = Set(online) + let framebufferSnapshot = framebufferSnapshotForPhysicalProof() + let candidates = all.map { + connectionCandidate( + displayID: $0, + onlineIDs: onlineIDs, + framebufferSnapshot: framebufferSnapshot, + retainedRecords: records + ) + } + let allUUIDs = Set(candidates.compactMap(\.stableUUID)) + let onlineUUIDs = Set(candidates.compactMap { candidate in + candidate.isOnline ? candidate.stableUUID : nil + }) + let hardwareBackedUUIDs = hardwareBackedPhysicalUUIDs(in: candidates) + let unsafePhysicalMutationUUIDs = allUUIDs.subtracting(hardwareBackedUUIDs) + let activePhysicalViewableUUIDs = Set(active.compactMap { id -> String? in + let matches = candidates.filter { $0.displayID == id } + guard matches.count == 1, let candidate = matches.first, + candidate.isHardwareBackedPhysical, + let uuid = candidate.stableUUID, + hardwareBackedUUIDs.contains(uuid) else { return nil } + return uuid + }) + return DisplayConnectionObservation( + persistenceSnapshot: persistenceSnapshot, + platformSupported: isSupported, + allUUIDs: allUUIDs, + onlineUUIDs: onlineUUIDs, + virtualUUIDs: unsafePhysicalMutationUUIDs, + activePhysicalViewableUUIDs: activePhysicalViewableUUIDs, + candidates: candidates, + bootSessionID: currentBootSessionID(), + loginSessionID: currentLoginSessionID(), + wakeSessionID: currentWakeSessionID(), + topologyFingerprint: DisplayConnectionTopologyFingerprint.make( + displayIDs: all, + framebufferSnapshot: framebufferSnapshot + ) ) + } - let result = await setEnabled(false, displayID: displayID) - if case .success = result { - disconnected.removeAll { $0.uuid == snapshot.uuid } - disconnected.append(snapshot) - saveDesired() + private func hardwareBackedPhysicalUUIDs( + in candidates: [DisplayConnectionCandidate] + ) -> Set { + let identified = candidates.compactMap { candidate -> (String, Bool)? in + guard let uuid = candidate.stableUUID else { return nil } + return (uuid, candidate.isHardwareBackedPhysical) } - return result + let grouped = Dictionary(grouping: identified, by: \.0) + return Set(grouped.compactMap { uuid, matches in + guard matches.count == 1, matches[0].1 else { return nil } + return uuid + }) } - /// Reconnects a previously disconnected display and drops it from the disconnected set. - @discardableResult - func reconnect(uuid: String) async -> Result { - guard isSupported else { return .failure(.unsupportedPlatform) } - guard let record = disconnected.first(where: { $0.uuid == uuid }) else { - return .failure(.displayNotFound) + func retainDisconnectedRecord( + _ target: DisplayConnectionTarget + ) throws -> DisplayConnectionRecoveryCapability? { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed } - // The CGDirectDisplayID can be reassigned; re-resolve by UUID against the full list. - let targetID = resolveCurrentID(for: record) ?? record.displayID - let result = await setEnabled(true, displayID: targetID) - if case .success = result { - disconnected.removeAll { $0.uuid == uuid } - saveDesired() + let currentState = snapshot.envelope + let observation = try connectionObservation(persistenceSnapshot: snapshot) + let matches = observation.candidates.filter { $0.stableUUID == target.uuid } + guard matches.count == 1, let candidate = matches.first, + candidate.isOnline, + candidate.isHardwareBackedPhysical, + target.displayID == nil || target.displayID == candidate.displayID else { + throw ControlEnumerationError.persistenceFailed + } + let recoveryCapability: DisplayConnectionRecoveryCapability? + if let proof = candidate.recoveryHardwareProof, + let bootSessionID = observation.bootSessionID, + let loginSessionID = observation.loginSessionID, + let wakeSessionID = observation.wakeSessionID, + let topologyFingerprint = observation.topologyFingerprint { + recoveryCapability = DisplayConnectionRecoveryCapability( + uuid: target.uuid, + displayID: candidate.displayID, + hardwareProof: proof, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint, + state: .prepared + ) + } else { + recoveryCapability = nil } - return result + var nextPending = currentState.pendingSet + nextPending.insert(target.uuid) + var nextRecords = currentState.records.filter { $0.uuid != target.uuid } + nextRecords.append(DisconnectedDisplay( + uuid: target.uuid, + displayID: candidate.displayID, + name: target.name, + width: target.width, + height: target.height, + recoveryCapability: recoveryCapability + )) + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: nextPending, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [target.uuid] + ) + return recoveryCapability } + func confirmDisconnectedRecord(uuid: String) throws { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + var nextRecords = currentState.records + var nextPending = currentState.pendingSet + let removedPending = nextPending.remove(uuid) != nil + if let index = nextRecords.firstIndex(where: { $0.uuid == uuid }), + let capability = nextRecords[index].recoveryCapability, + capability.state == .prepared { + nextRecords[index].recoveryCapability = capability.changingState(to: .available) + } + guard removedPending || nextRecords != currentState.records else { return } + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: nextPending, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + } + func consumeRecoveryCapability( + _ capability: DisplayConnectionRecoveryCapability + ) throws -> DisplayConnectionRecoveryCapability { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + let matches = currentState.records.indices.filter { + currentState.records[$0].uuid == capability.uuid + && currentState.records[$0].recoveryCapability == capability + } + guard matches.count == 1, let index = matches.first, + capability.state == .available else { + throw ControlEnumerationError.persistenceFailed + } + let consumed = capability.changingState(to: .consumed) + var nextRecords = currentState.records + nextRecords[index].recoveryCapability = consumed + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [capability.uuid] + ) + return consumed + } + private func updateReconnectReservation(uuid: String, adding: Bool) throws { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + guard currentState.records.filter({ $0.uuid == uuid }).count == 1 else { + throw ControlEnumerationError.persistenceFailed + } + var reservations = currentState.reconnectReservationSet + let changed = adding + ? reservations.insert(uuid).inserted + : reservations.remove(uuid) != nil + guard changed else { throw ControlEnumerationError.persistenceFailed } + try persistConnectionState( + PersistedConnectionState( + records: currentState.records, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: reservations, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + } + func reserveReconnect(uuid: String) throws { + guard !liveReconnectReservationUUIDs.contains(uuid) else { + throw ControlEnumerationError.persistenceFailed + } + liveReconnectReservationUUIDs.insert(uuid) + do { + try updateReconnectReservation(uuid: uuid, adding: true) + } catch { + liveReconnectReservationUUIDs.remove(uuid) + throw error + } + } + func releaseReconnectReservation(uuid: String) throws { + guard liveReconnectReservationUUIDs.contains(uuid) else { + throw ControlEnumerationError.persistenceFailed + } + try updateReconnectReservation(uuid: uuid, adding: false) + liveReconnectReservationUUIDs.remove(uuid) + } + func rollbackRejectedReconnectBeforeDispatch( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) throws { + guard liveReconnectReservationUUIDs.contains(uuid) else { + throw ControlEnumerationError.persistenceFailed + } + defer { liveReconnectReservationUUIDs.remove(uuid) } + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let rolledBack = try RejectedReconnectRollback.proposedState( + uuid: uuid, + consumedRecoveryCapability: consumedRecoveryCapability, + currentState: snapshot.envelope + ) + try persistConnectionState( + rolledBack, + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + } + func reconcileOrphanedReconnectAttempt( + uuid: String + ) throws -> DisplayReconnectOrphanReconciliation { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authority == .durable else { return .unavailable } + let currentState = snapshot.envelope + guard currentState.reconnectReservationSet.contains(uuid) else { return .unavailable } + guard !liveReconnectReservationUUIDs.contains(uuid) else { return .liveAttempt } + let observation = try connectionObservation(persistenceSnapshot: snapshot) + if DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate( + uuid: uuid, + observation: observation + ) != nil { + return .alreadyOnline + } + let resolution = DisplayConnectionRecoveryResolver.orphanedReconnectResolution( + uuid: uuid, + observation: observation + ) + var nextRecords = currentState.records + switch resolution { + case .exactUUID: + if let index = nextRecords.firstIndex(where: { $0.uuid == uuid }), + let capability = nextRecords[index].recoveryCapability, + [.consumed, .indeterminate].contains(capability.state) { + if DisplayConnectionRecoveryResolver + .restorableRecoveryCapabilityForExactOrphan( + uuid: uuid, + observation: observation + ) == capability { + nextRecords[index].recoveryCapability = capability.changingState(to: .available) + } else { + // Exact UUID authority is sufficient for the next explicit request. Drop an + // unsafe fallback rather than reviving it without direct continuity proof. + nextRecords[index].recoveryCapability = nil + } + } + case let .oneShotRecovery(capability): + let matches = nextRecords.indices.filter { + nextRecords[$0].uuid == uuid + && nextRecords[$0].recoveryCapability == capability + } + guard matches.count == 1, let index = matches.first else { return .unavailable } + nextRecords[index].recoveryCapability = capability.changingState(to: .available) + case .alreadyOnline, .unavailable: + return .unavailable + } + var nextReservations = currentState.reconnectReservationSet + guard nextReservations.remove(uuid) != nil else { return .unavailable } + var nextUncertain = currentState.reconnectPersistenceUncertainSet + nextUncertain.remove(uuid) + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: nextReservations, + reconnectPersistenceUncertainUUIDs: nextUncertain + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + return .reconciled + } + func reconcileQuarantinedReconnectAttempt( + uuid: String + ) async throws -> DisplayReconnectQuarantineReconciliation { + guard !liveReconnectReservationUUIDs.contains(uuid), + !liveQuarantineReconciliationUUIDs.contains(uuid) else { + return .liveAttempt + } + liveQuarantineReconciliationUUIDs.insert(uuid) - /// Finds the current CGDirectDisplayID for a disconnected record by matching its UUID - /// across the full (incl. disabled) display list. - private func resolveCurrentID(for record: DisconnectedDisplay) -> CGDirectDisplayID? { - allDisplaysIncludingDisabled().first { uuid(for: $0) == record.uuid } + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authority == .durable, + snapshot.envelope.reconnectPersistenceUncertainSet.contains(uuid) else { + return .unavailable + } + let observation = try connectionObservation(persistenceSnapshot: snapshot) + guard let result = try connectionPersistence.reconcileQuarantinedReconnect( + uuid: uuid, + snapshot: snapshot, + observation: observation + ) else { return .unavailable } + disconnected = connectionPersistence.publishedRecords + guard result.writeResult.disposition == .committedProposed else { + throw ControlEnumerationError.persistenceFailed + } + switch result.kind { + case .reconciledOffline: + return .reconciled + case .alreadyOnline: + return .alreadyOnline + } + } + func finishQuarantinedReconnectAttempt(uuid: String) { + liveQuarantineReconciliationUUIDs.remove(uuid) + } + private func changeRecoveryCapabilityState( + uuid: String, + allowedStates: Set, + to state: DisplayConnectionRecoveryCapabilityState + ) throws { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + guard let index = currentState.records.firstIndex(where: { $0.uuid == uuid }), + let capability = currentState.records[index].recoveryCapability, + allowedStates.contains(capability.state), capability.state != state else { return } + var nextRecords = currentState.records + nextRecords[index].recoveryCapability = capability.changingState(to: state) + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + } + func markReconnectAttemptIndeterminate(uuid: String) throws { + defer { liveReconnectReservationUUIDs.remove(uuid) } + try changeRecoveryCapabilityState( + uuid: uuid, + allowedStates: [.prepared, .available, .invalidatedByWake, .consumed], + to: .indeterminate + ) + } + func markRecoveryCapabilityIndeterminate(uuid: String) throws { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authorizesConnectionMutation else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + guard let index = currentState.records.firstIndex(where: { $0.uuid == uuid }) else { + return + } + var nextRecords = currentState.records + if let capability = nextRecords[index].recoveryCapability { + nextRecords[index].recoveryCapability = capability.changingState(to: .indeterminate) + } + var nextPending = currentState.pendingSet + nextPending.insert(uuid) + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: nextPending, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + } + + func removeDisconnectedRecord(uuid: String) throws { + let snapshot = try connectionStateSnapshot(synchronizePublished: true) + guard snapshot.authority == .durable else { + throw ControlEnumerationError.persistenceFailed + } + let currentState = snapshot.envelope + let nextRecords = currentState.records.filter { $0.uuid != uuid } + var nextPending = currentState.pendingSet + nextPending.remove(uuid) + var nextReservations = currentState.reconnectReservationSet + nextReservations.remove(uuid) + var nextUncertain = currentState.reconnectPersistenceUncertainSet + nextUncertain.remove(uuid) + try persistConnectionState( + PersistedConnectionState( + records: nextRecords, + pendingUUIDs: nextPending, + reconnectReservationUUIDs: nextReservations, + reconnectPersistenceUncertainUUIDs: nextUncertain + ), + replacing: snapshot, + quarantiningUUIDs: [uuid] + ) + liveReconnectReservationUUIDs.remove(uuid) + } + + func dispatchConnectionChange( + _ request: DisplayConnectionDispatchRequest + ) async -> DisplayConnectionDispatchOutcome { + guard isSupported, isExactControlUUID(request.uuid), request.displayID != 0 else { + return .rejectedBeforeDispatch("platform support or stable UUID preflight failed") + } + let observation: DisplayConnectionObservation + do { + observation = try connectionObservation() + } catch { + return .rejectedBeforeDispatch("fresh full-list re-resolution failed before mutation") + } + switch request.authorization { + case .exactUUID: + let authorized: Bool + switch request.requestedState { + case .disconnected: + authorized = DisplayConnectionRecoveryResolver.authorizesExactDisconnect( + uuid: request.uuid, + displayID: request.displayID, + observation: observation + ) + case .connected: + authorized = DisplayConnectionRecoveryResolver.authorizesReservedExactReconnect( + uuid: request.uuid, + displayID: request.displayID, + observation: observation + ) + } + guard authorized else { + return .rejectedBeforeDispatch( + "fresh exact-UUID and hardware preflight rejected the mutation" + ) + } + case .oneShotRecovery: + guard request.requestedState == .connected, + DisplayConnectionRecoveryResolver.authorizesConsumedRecoveryDispatch( + uuid: request.uuid, + displayID: request.displayID, + observation: observation + ) else { + return .rejectedBeforeDispatch( + "consumed recovery capability failed final continuity validation" + ) + } + } + guard !Task.isCancelled else { + return .rejectedBeforeDispatch("display connection request was cancelled before dispatch") + } + switch await setEnabledOutcome( + request.requestedState == .connected, + displayID: request.displayID + ) { + case .completed: + return .completed + case let .rejectedBeforeDispatch(error): + return .rejectedBeforeDispatch("display configuration was rejected before dispatch (CGError \(error.rawValue))") + case let .failedAfterDispatch(error): + return .failedAfterDispatch("display configuration failed after dispatch (CGError \(error.rawValue))") + case .timedOut: + return .timedOut + case .cancelled: + return .cancelled + } + } + + private func controlAllDisplayIDs() throws -> [CGDirectDisplayID] { + var count: UInt32 = 0 + let countError = SLSGetDisplayList(0, nil, &count) + guard countError == .success else { throw ControlEnumerationError.fullListFailed(countError) } + guard count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + let listError = SLSGetDisplayList(count, &ids, &count) + guard listError == .success else { throw ControlEnumerationError.fullListFailed(listError) } + return Array(ids.prefix(Int(count))) + } + + private func controlOnlineDisplayIDs() throws -> [CGDirectDisplayID] { + var count: UInt32 = 0 + let countError = CGGetOnlineDisplayList(0, nil, &count) + guard countError == .success else { throw ControlEnumerationError.onlineListFailed(countError) } + guard count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + let listError = CGGetOnlineDisplayList(count, &ids, &count) + guard listError == .success else { throw ControlEnumerationError.onlineListFailed(listError) } + return Array(ids.prefix(Int(count))) + } + + private func controlActiveDisplayIDs() throws -> [CGDirectDisplayID] { + var count: UInt32 = 0 + let countError = CGGetActiveDisplayList(0, nil, &count) + guard countError == .success else { throw ControlEnumerationError.activeListFailed(countError) } + guard count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + let listError = CGGetActiveDisplayList(count, &ids, &count) + guard listError == .success else { throw ControlEnumerationError.activeListFailed(listError) } + return Array(ids.prefix(Int(count))) + } + +} + +// MARK: - Disconnect / Reconnect + +extension PhysicalDisplayToggleService { + /// Disconnects a physical display and records a snapshot for later reconnect. Refuses if it + /// would leave zero active displays, so the user can never black out their only screen. + @discardableResult + func disconnect(_ display: DisplayInfo) async -> Result { + do { + _ = try await disconnectForControl(display) + return .success(()) + } catch let error as DisplayConnectionMutationError { + return .failure(.mutationFailed(error.message)) + } catch { + return .failure(.configurationFailed(.failure)) + } + } + + /// Reconnects a previously disconnected display and drops it from the disconnected set. + @discardableResult + func reconnect(uuid: String) async -> Result { + do { + _ = try await reconnectForControl(uuid: uuid) + return .success(()) + } catch let error as DisplayConnectionMutationError { + return .failure(.mutationFailed(error.message)) + } catch { + return .failure(.configurationFailed(.failure)) + } } /// Soft-reconnects a display (disable then re-enable its framebuffer) to force macOS to @@ -352,22 +1275,49 @@ final class PhysicalDisplayToggleService: ObservableObject { /// `.permanently` is the flag the proven implementations (Lunar BlackOut, screen_tune, /// BetterDisplay) use, it commits the change so the disconnect actually takes effect. private func setEnabled(_ enabled: Bool, displayID: CGDirectDisplayID) async -> Result { - await CGHelpers.runWithTimeout(seconds: 10, fallback: .failure(.configurationFailed(.failure))) { + switch await setEnabledOutcome(enabled, displayID: displayID) { + case .completed: + return .success(()) + case let .rejectedBeforeDispatch(error): + return .failure(.configurationFailed(error)) + case .failedAfterDispatch, .timedOut, .cancelled: + return .failure(.outcomeIndeterminate) + } + } + + /// Returns transaction phase truth for automation. Once SLSConfigureDisplayEnabled has + /// been invoked, every non-success (including a language-level timeout/cancellation) is + /// potentially in flight and must not be represented as a definite safe failure. + private func setEnabledOutcome( + _ enabled: Bool, + displayID: CGDirectDisplayID + ) async -> ConfigurationTransactionOutcome { + let outcome = await CGHelpers.runWithTimeoutOutcome( + seconds: ControlTimeoutPolicy.displayConfigurationTimeout + ) { var config: CGDisplayConfigRef? guard CGBeginDisplayConfiguration(&config) == .success, let cfg = config else { - return .failure(.configurationFailed(.failure)) + return ConfigurationTransactionOutcome.rejectedBeforeDispatch(.failure) } let setErr = SLSConfigureDisplayEnabled(cfg, displayID, enabled) guard setErr == .success else { CGCancelDisplayConfiguration(cfg) - return .failure(.configurationFailed(setErr)) + return ConfigurationTransactionOutcome.failedAfterDispatch(setErr) } let complete = CGCompleteDisplayConfiguration(cfg, .permanently) guard complete == .success else { CGCancelDisplayConfiguration(cfg) - return .failure(.configurationFailed(complete)) + return ConfigurationTransactionOutcome.failedAfterDispatch(complete) } - return .success(()) + return ConfigurationTransactionOutcome.completed + } + switch outcome { + case let .completed(transaction): + return transaction + case .timedOut: + return .timedOut + case .cancelled: + return .cancelled } } @@ -412,16 +1362,6 @@ final class PhysicalDisplayToggleService: ObservableObject { return false } - /// Portables enforce Clamshell Sleep the moment no display is active; desktops don't. - /// Battery presence is the lid-independent laptop test (the built-in panel can vanish - /// from the display lists entirely while the lid is closed, so it can't be the signal). - private static let hasBattery: Bool = { - let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) - guard service != 0 else { return false } - IOObjectRelease(service) - return true - }() - /// Throwaway virtual display held while blinking a portable's sole active display, so /// Clamshell Sleep never sees a zero-display moment (see softReconnect). Registered but /// deliberately minimal: 1080p, no HiDPI ladder. Stamped with the shared virtual vendor @@ -462,31 +1402,31 @@ final class PhysicalDisplayToggleService: ObservableObject { // MARK: - Reconcile / Wake restore /// Drops records for displays that are back online (e.g. physically re-plugged, or macOS - /// re-enabled them). Called from DisplayManager.refreshDisplays so the UI stays honest. + /// re-enabled them), and confirms pending records only from fresh offline truth. Called from + /// DisplayManager.refreshDisplays; this topology-event path changes metadata but never a + /// display's enabled state. func reconcile() { - guard !disconnected.isEmpty else { return } - var onlineCount: UInt32 = 0 - CGGetOnlineDisplayList(0, nil, &onlineCount) - var onlineIDs = [CGDirectDisplayID](repeating: 0, count: Int(onlineCount)) - CGGetOnlineDisplayList(onlineCount, &onlineIDs, &onlineCount) - let onlineUUIDs = Set(onlineIDs.prefix(Int(onlineCount)).map { uuid(for: $0) }) - - let before = disconnected.count - disconnected.removeAll { onlineUUIDs.contains($0.uuid) } - if disconnected.count != before { saveDesired() } + guard let snapshot = try? connectionStateSnapshot(synchronizePublished: true), + !snapshot.envelope.records.isEmpty else { return } + let observation = try? connectionObservation(persistenceSnapshot: snapshot) + do { + guard let result = try connectionPersistence.reconcileTopologyMetadata( + snapshot: snapshot, + observation: observation + ) else { return } + disconnected = connectionPersistence.publishedRecords + let remainingUUIDs = Set(result.snapshot.envelope.records.map(\.uuid)) + liveReconnectReservationUUIDs.formIntersection(remainingUUIDs) + } catch { + disconnected = connectionPersistence.publishedRecords + } } - /// Guards against overlapping recovery runs from reconfiguration-callback bursts, same - /// as restoreInFlight below for restoreIfNoActiveDisplay. - private var strandedRecoveryInFlight = false - /// Sleep guard parked by a softReconnect whose display never verifiably returned (see /// its retry-exhausted path): holding it keeps a lid-closed portable awake so the /// marker/recovery cadence can keep retrying instead of the machine sleeping on a /// stranded display. Released by recovery once every marked display is resolved, or /// adopted by the next blink (the fixed identity can't be created twice). - private var lingeringSleepGuard: CGVirtualDisplay? - /// Recovery for softReconnects that never finished: if the app died (crash, force-quit) /// between disabling a display and a successful re-enable, the markers softReconnect /// left behind name exactly the stranded displays. Called from @@ -550,75 +1490,94 @@ final class PhysicalDisplayToggleService: ObservableObject { } } - /// Guards against overlapping restore attempts from reconfiguration-callback bursts. - private var restoreInFlight = false - - /// Called on every display-list refresh. The guard in disconnect() can't stop a physical - /// unplug: with the internal disabled via Crisp and the external cable pulled, zero active - /// displays remain and macOS does NOT re-enable the disabled one, every screen stays black. - /// Re-enable a still-attached disconnected display (built-in first) so the machine always - /// has a live screen. The settle delay rides out transient empty display lists during - /// wake/replug storms, so a monitor that comes right back keeps the disconnect intact. + /// Kept for call-site compatibility only. Topology-event metadata reconciliation belongs to + /// `reconcile()`; CLI inventory queries are pure reads, and reconnect stays user initiated. func restoreIfNoActiveDisplay() { - guard isSupported, !disconnected.isEmpty, !restoreInFlight else { return } - guard physicalActiveDisplayCount() == 0 else { return } - restoreInFlight = true - Task { [weak self] in - try? await Task.sleep(nanoseconds: 2_000_000_000) - guard let self else { return } - defer { self.restoreInFlight = false } - guard self.physicalActiveDisplayCount() == 0 else { return } - // In the placeholder-display state SLSGetDisplayList shrinks to just - // the placeholder (verified live), so records that fail to resolve - // must fall back to their last-known ID rather than being dropped: - // SLSConfigureDisplayEnabled still honors a stale ID for attached - // hardware, while detached hardware fails at - // CGCompleteDisplayConfiguration (error 1001) and the loop moves on. - // Prefer the built-in panel when the ID still classifies; stale IDs - // answer CGDisplayIsBuiltin with garbage, which sorts as non-builtin. - let candidates = self.disconnected - .map { record in (record, self.resolveCurrentID(for: record) ?? record.displayID) } - .sorted { CGDisplayIsBuiltin($0.1) == 1 && CGDisplayIsBuiltin($1.1) != 1 } - for (record, _) in candidates { - if case .success = await self.reconnect(uuid: record.uuid) { return } - } - } + // Intentionally empty. Kept as a call-site-compatible safety boundary. } - /// Re-applies disconnect for displays macOS re-enabled after wake-from-sleep. Called from - /// AppDelegate.onWake after WindowServer settles. + /// Wake is a hard continuity boundary for persisted display-ID fallback capabilities. It + /// never writes display state or invents a pending mutation; fresh exact UUID authority stays. func reapplyOnWake() async { - guard isSupported, !disconnected.isEmpty else { return } - var onlineCount: UInt32 = 0 - CGGetOnlineDisplayList(0, nil, &onlineCount) - var onlineIDs = [CGDirectDisplayID](repeating: 0, count: Int(onlineCount)) - CGGetOnlineDisplayList(onlineCount, &onlineIDs, &onlineCount) - let onlineByUUID = Dictionary(uniqueKeysWithValues: - onlineIDs.prefix(Int(onlineCount)).map { (uuid(for: $0), $0) }) - - for record in disconnected { - // Only re-disconnect ones macOS brought back online, and never the last screen. - guard let liveID = onlineByUUID[record.uuid] else { continue } - guard !wouldLeaveNoActiveDisplay(liveID) else { continue } - _ = await setEnabled(false, displayID: liveID) + guard isSupported, + let snapshot = try? connectionStateSnapshot(synchronizePublished: true), + snapshot.authorizesConnectionMutation else { return } + let currentState = snapshot.envelope + var records = currentState.records + var affectedUUIDs: Set = [] + for index in records.indices { + guard let capability = records[index].recoveryCapability, + [.prepared, .available].contains(capability.state) else { continue } + records[index].recoveryCapability = capability.changingState(to: .invalidatedByWake) + affectedUUIDs.insert(records[index].uuid) } + guard !affectedUUIDs.isEmpty else { return } + _ = try? persistConnectionState( + PersistedConnectionState( + records: records, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: currentState.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet + ), + replacing: snapshot, + quarantiningUUIDs: affectedUUIDs + ) } // MARK: - Persistence - private func saveDesired() { - guard let data = try? JSONEncoder().encode(disconnected) else { return } - UserDefaults.standard.set(data, forKey: desiredKey) - } - private func loadDesired() { - guard let data = UserDefaults.standard.data(forKey: desiredKey), - let decoded = try? JSONDecoder().decode([DisconnectedDisplay].self, from: data) - else { return } - disconnected = decoded - // By design we do NOT auto-disconnect on launch, restarting the app must never - // black out a screen on its own. The loaded list only populates the "Disconnected" - // UI so the user can reconnect (or ignore) at their choice. Only the sleep/wake path - // re-applies disconnect, via reapplyOnWake(). + do { + let snapshot = try connectionPersistence.snapshot() + connectionPersistence.adoptPublishedRecords(from: snapshot) + disconnected = connectionPersistence.publishedRecords + return + } catch let error as DisplayConnectionPersistenceError where error == .corrupt { + // Corrupt authoritative bytes are retained and every connection mutation fails closed. + return + } catch { + // A missing authoritative value is the only state eligible for legacy migration. + } + + let defaults = UserDefaults.standard + let legacyRecords: [DisconnectedDisplay] + if let data = defaults.data(forKey: desiredKey) { + guard let decoded = try? JSONDecoder().decode( + [DisconnectedDisplay].self, + from: data + ), decoded.allSatisfy({ isExactControlUUID($0.uuid) }), + Set(decoded.map(\.uuid)).count == decoded.count else { return } + legacyRecords = decoded + } else { + legacyRecords = [] + } + let recordUUIDs = Set(legacyRecords.map(\.uuid)) + let legacyPending = Set( + defaults.stringArray(forKey: controlPendingDisconnectUUIDsKey) ?? [] + ).intersection(recordUUIDs) + let empty = PersistedConnectionState( + records: [], + pendingUUIDs: [], + reconnectReservationUUIDs: [] + ) + let migrated = PersistedConnectionState( + records: legacyRecords, + pendingUUIDs: legacyPending, + reconnectReservationUUIDs: [] + ) + guard let result = try? connectionPersistence.replace( + oldState: empty, + proposedState: migrated, + quarantiningUUIDs: recordUUIDs + ) else { return } + connectionPersistence.adoptPublishedRecords(from: result.snapshot) + disconnected = connectionPersistence.publishedRecords + if result.disposition == .committedProposed { + defaults.removeObject(forKey: desiredKey) + defaults.removeObject(forKey: controlPendingDisconnectUUIDsKey) + } + // Relaunch only restores UI/recovery metadata. No display write occurs until a user + // explicitly requests reconnect through the shared coordinator. } } diff --git a/Crisp/Views/ExtraBrightnessView.swift b/Crisp/Views/ExtraBrightnessView.swift index 0f2af66..4c32a90 100644 --- a/Crisp/Views/ExtraBrightnessView.swift +++ b/Crisp/Views/ExtraBrightnessView.swift @@ -28,7 +28,9 @@ struct ExtraBrightnessView: View { .onChange(of: isOn) { _, newValue in guard !isProgrammaticChange else { return } Task { @MainActor in - let ok = await BrightnessBoostService.shared.setEnabled(newValue, for: display) + let ok = (try? await BrightnessBoostService.shared.setEnabled( + newValue, for: display + )) == true if !ok { // Quiet revert, per the spec: no dialogs. isProgrammaticChange = true diff --git a/Crisp/Views/HDRToggleView.swift b/Crisp/Views/HDRToggleView.swift index 049dfeb..04eea3b 100644 --- a/Crisp/Views/HDRToggleView.swift +++ b/Crisp/Views/HDRToggleView.swift @@ -35,7 +35,9 @@ struct HDRToggleView: View { guard !isProgrammaticChange else { return } requestInFlight = true Task { @MainActor in - _ = await BrightnessBoostService.shared.setHDRPreference(newValue, for: display) + _ = try? await BrightnessBoostService.shared.setHDRPreference( + newValue, for: display + ) // Read back the live state rather than trust newValue: // this both confirms success and is the quiet revert // on failure, per the spec: no dialogs. diff --git a/Crisp/Views/PhysicalDisplayToggleView.swift b/Crisp/Views/PhysicalDisplayToggleView.swift index ccdcfa0..9a3949d 100644 --- a/Crisp/Views/PhysicalDisplayToggleView.swift +++ b/Crisp/Views/PhysicalDisplayToggleView.swift @@ -68,6 +68,7 @@ struct ReconnectDisplaysSection: View { @EnvironmentObject var displayManager: DisplayManager @ObservedObject private var service = PhysicalDisplayToggleService.shared @State private var busyUUIDs: Set = [] + @State private var errorMessages: [String: String] = [:] var body: some View { if !service.disconnected.isEmpty { @@ -83,6 +84,7 @@ struct ReconnectDisplaysSection: View { DisconnectedDisplayRow( record: record, busy: busyUUIDs.contains(record.uuid), + errorMessage: errorMessages[record.uuid], onReconnect: { reconnect(record) } ) } @@ -93,8 +95,12 @@ struct ReconnectDisplaysSection: View { private func reconnect(_ record: PhysicalDisplayToggleService.DisconnectedDisplay) { guard !busyUUIDs.contains(record.uuid) else { return } busyUUIDs.insert(record.uuid) + errorMessages[record.uuid] = nil Task { @MainActor in - _ = await service.reconnect(uuid: record.uuid) + let result = await service.reconnect(uuid: record.uuid) + if case .failure(let error) = result { + errorMessages[record.uuid] = error.description + } displayManager.refreshDisplays() busyUUIDs.remove(record.uuid) } @@ -108,38 +114,46 @@ struct ReconnectDisplaysSection: View { private struct DisconnectedDisplayRow: View { let record: PhysicalDisplayToggleService.DisconnectedDisplay let busy: Bool + let errorMessage: String? let onReconnect: () -> Void @State private var isHovered = false var body: some View { - HStack(spacing: 8) { - // The display is inactive, so dim its icon + name; it brightens as the - // row is hovered, cueing that a click brings it back. + VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { - MenuItemIcon(systemName: "rectangle.slash", color: .secondary, active: false) - VStack(alignment: .leading, spacing: 1) { - Text(record.name).font(.body).lineLimit(1) - Text(verbatim: "\(record.width)×\(record.height)") - .font(.caption2).foregroundColor(.secondary) + // The display is inactive, so dim its icon + name; it brightens as the + // row is hovered, cueing that a click brings it back. + HStack(spacing: 8) { + MenuItemIcon(systemName: "rectangle.slash", color: .secondary, active: false) + VStack(alignment: .leading, spacing: 1) { + Text(record.name).font(.body).lineLimit(1) + Text(verbatim: "\(record.width)×\(record.height)") + .font(.caption2).foregroundColor(.secondary) + } } - } - .opacity(isHovered ? 1 : 0.6) + .opacity(isHovered ? 1 : 0.6) - Spacer() + Spacer() - if busy { - ProgressView().scaleEffect(0.6).frame(width: 16, height: 16) - } else { - // Liquid-Glass accent capsule; the whole row is the tap target, so - // this reads as the affordance and deepens with the row on hover. - Text("Reconnect") - .font(.caption).fontWeight(.medium) - .foregroundColor(.accentColor) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - Capsule().fill(Color.accentColor.opacity(isHovered ? 0.22 : 0.12)) - ) + if busy { + ProgressView().scaleEffect(0.6).frame(width: 16, height: 16) + } else { + // Liquid-Glass accent capsule; the whole row is the tap target, so + // this reads as the affordance and deepens with the row on hover. + Text("Reconnect") + .font(.caption).fontWeight(.medium) + .foregroundColor(.accentColor) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule().fill(Color.accentColor.opacity(isHovered ? 0.22 : 0.12)) + ) + } + } + if let errorMessage { + Text(errorMessage) + .font(.caption2) + .foregroundColor(.red) } } .padding(.horizontal, 12) diff --git a/CrispTests/BrightnessHeartbeatWiringTests.swift b/CrispTests/BrightnessHeartbeatWiringTests.swift new file mode 100644 index 0000000..e444715 --- /dev/null +++ b/CrispTests/BrightnessHeartbeatWiringTests.swift @@ -0,0 +1,223 @@ +import XCTest + +final class BrightnessHeartbeatWiringTests: XCTestCase { + func testAppDelegateWiresBothRefreshPathsAndCloseCancellation() throws { + let repository = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: repository.appendingPathComponent("Crisp/App/AppDelegate.swift"), + encoding: .utf8 + ) + + let clickRefresh = try SwiftSource.functionBody(named: "refreshExternalState", in: source) + let timerHeartbeat = try SwiftSource.functionBody(named: "pollExternalState", in: source) + let closePanel = try SwiftSource.functionBody(named: "closePanel", in: source) + + XCTAssertTrue(clickRefresh.contains("brightnessHeartbeatController.schedule(")) + XCTAssertTrue(timerHeartbeat.contains("brightnessHeartbeatController.schedule(")) + XCTAssertTrue(closePanel.contains("brightnessHeartbeatController.cancel()")) + } + + func testFunctionBodyExtractionDoesNotAcceptWiringFromSiblingOrComment() throws { + let fixture = """ + private func clickRefresh() { + controller.schedule() + } + private func timerHeartbeat() { + // controller.schedule() + noop() + } + private func stringOnlyHeartbeat() { + let marker = "controller.schedule()" + consume(marker) + } + """ + + let clickRefresh = try SwiftSource.functionBody(named: "clickRefresh", in: fixture) + let timerHeartbeat = try SwiftSource.functionBody(named: "timerHeartbeat", in: fixture) + let stringOnlyHeartbeat = try SwiftSource.functionBody(named: "stringOnlyHeartbeat", in: fixture) + XCTAssertTrue(clickRefresh.contains("controller.schedule()")) + XCTAssertFalse(timerHeartbeat.contains("controller.schedule()")) + XCTAssertFalse(stringOnlyHeartbeat.contains("controller.schedule()")) + } +} + +private enum SwiftSource { + enum ParseError: Error { + case functionNotFound(String) + case malformedBody(String) + } + + static func functionBody(named name: String, in source: String) throws -> String { + let uncommented = removingComments(from: source) + let signature = "func \(name)(" + guard let signatureRange = uncommented.range(of: signature) else { + throw ParseError.functionNotFound(name) + } + let chars = Array(uncommented) + let signatureOffset = uncommented.distance( + from: uncommented.startIndex, + to: signatureRange.lowerBound + ) + guard let openBrace = chars[signatureOffset...].firstIndex(of: "{") else { + throw ParseError.malformedBody(name) + } + + var depth = 0 + var inString = false + var escaped = false + var bodyStart = openBrace + 1 + for index in openBrace.. String { + let chars = Array(source) + var state = CommentRemovalState() + while state.index < chars.count { + let character = chars[state.index] + let hasNext = state.index + 1 < chars.count + let next = hasNext ? chars[state.index + 1] : "\0" + let third = state.index + 2 < chars.count ? chars[state.index + 2] : "\0" + switch state.mode { + case .code: + consumeCode(character, next: next, third: third, state: &state) + case .string: + consumeString(character, hasNext: hasNext, state: &state) + case .lineComment: + consumeLineComment(character, state: &state) + case .blockComment: + consumeBlockComment(character, next: next, state: &state) + case .multilineString: + consumeMultilineString(character, next: next, third: third, state: &state) + } + } + return String(state.result) + } + + private static func consumeCode( + _ character: Character, + next: Character, + third: Character, + state: inout CommentRemovalState + ) { + if character == "\"", next == "\"", third == "\"" { + state.mode = .multilineString + state.result.append(contentsOf: [" ", " ", " "]) + state.index += 3 + } else if character == "\"" { + state.mode = .string + state.result.append(" ") + state.index += 1 + } else if character == "/", next == "/" { + state.mode = .lineComment + state.result.append(contentsOf: [" ", " "]) + state.index += 2 + } else if character == "/", next == "*" { + state.mode = .blockComment + state.blockDepth = 1 + state.result.append(contentsOf: [" ", " "]) + state.index += 2 + } else { + state.result.append(character) + state.index += 1 + } + } + + private static func consumeString( + _ character: Character, + hasNext: Bool, + state: inout CommentRemovalState + ) { + if character == "\\", hasNext { + state.result.append(contentsOf: [" ", " "]) + state.index += 2 + } else { + state.result.append(character == "\n" ? "\n" : " ") + if character == "\"" { state.mode = .code } + state.index += 1 + } + } + + private static func consumeLineComment( + _ character: Character, + state: inout CommentRemovalState + ) { + state.result.append(character == "\n" ? "\n" : " ") + if character == "\n" { state.mode = .code } + state.index += 1 + } + + private static func consumeBlockComment( + _ character: Character, + next: Character, + state: inout CommentRemovalState + ) { + if character == "/", next == "*" { + state.blockDepth += 1 + state.result.append(contentsOf: [" ", " "]) + state.index += 2 + } else if character == "*", next == "/" { + state.blockDepth -= 1 + state.result.append(contentsOf: [" ", " "]) + state.index += 2 + if state.blockDepth == 0 { state.mode = .code } + } else { + state.result.append(character == "\n" ? "\n" : " ") + state.index += 1 + } + } + + private static func consumeMultilineString( + _ character: Character, + next: Character, + third: Character, + state: inout CommentRemovalState + ) { + if character == "\"", next == "\"", third == "\"" { + state.result.append(contentsOf: [" ", " ", " "]) + state.index += 3 + state.mode = .code + } else { + state.result.append(character == "\n" ? "\n" : " ") + state.index += 1 + } + } +} diff --git a/Makefile b/Makefile index c88c355..e4bae62 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ endif # swiftc invocation kept in sync with dev.sh's compile step. SWIFT_SOURCES := Crisp/App/*.swift Crisp/Models/*.swift Crisp/Services/*.swift \ - Crisp/Views/*.swift Crisp/Utilities/*.swift + Crisp/Views/*.swift Crisp/Utilities/*.swift Crisp/Automation/*.swift \ + Sources/CrispControlCore/*.swift SWIFTC_FLAGS := -O -swift-version 5 -strict-concurrency=minimal -parse-as-library \ -import-objc-header Crisp/Crisp-Bridging-Header.h \ -framework AppKit -framework SwiftUI -framework IOKit -framework CoreAudio \ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..336330b --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CrispControl", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CrispControlCore", targets: ["CrispControlCore"]), + .library(name: "CrispControlCLI", targets: ["CrispControlCLI"]), + .executable(name: "crispctl", targets: ["crispctl"]), + .executable(name: "crisp-control-test-host", targets: ["CrispControlTestHost"]) + ], + targets: [ + .target(name: "CrispControlCore"), + .target(name: "CrispControlCLI", dependencies: ["CrispControlCore"]), + .executableTarget(name: "crispctl", dependencies: ["CrispControlCLI", "CrispControlCore"]), + .executableTarget(name: "CrispControlTestHost", dependencies: ["CrispControlCore"]), + .testTarget(name: "CrispControlCoreTests", dependencies: ["CrispControlCore"]), + .testTarget(name: "CrispControlCLITests", dependencies: ["CrispControlCLI", "CrispControlCore"]) + ] +) diff --git a/README.md b/README.md index 945e15c..7bbf88b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ brew install --cask didriksg/tap/crisp Or download [`Crisp.dmg`](https://github.com/didriksg/Crisp/releases/latest/download/Crisp.dmg) and drag Crisp to Applications. Every release is signed and notarized by Apple, so it opens with a normal double-click. +Public installs gain `crispctl` only with the first Crisp release that contains +this distribution change. Crisp 1.5.0 does not contain the bundled CLI. Source +checkout users can build it. In a release that includes it, Homebrew cask +installs expose `crispctl` on `PATH`; a manual DMG install can run the same +bundled executable directly at `/Applications/Crisp.app/Contents/MacOS/crispctl` +without a privileged symlink. + ## Features - **Sharp, Retina-quality scaling on any display**: HiDPI scaled resolutions that make external monitors crisp instead of blurry or undersized, set up automatically for 1440p and larger displays, and always at the panel's full refresh rate (no more 1080p stuck at 50Hz on a 144Hz monitor) @@ -81,6 +88,49 @@ Thank you to the people chipping in toward keeping Crisp signed and notarized: - **Administrator password** (one time, per monitor): needed only when you turn on smooth scaling, which installs a display override file into `/Library/Displays/Contents/Resources/Overrides` that macOS protects. Regular HiDPI scaling and everything else are password-free. - **Accessibility** (System Settings > Privacy & Security > Accessibility): needed only if you turn on Brightness Keys, which routes the keyboard brightness keys to other displays (follow the pointer, all connected, or a chosen subset). Without it, everything else still works; the keys just control the built-in display as usual. +## Automation + +The `crispctl` target provides a versioned JSON interface for display discovery, +capabilities, brightness, Extra Brightness, and writable external HDR toggles +through the running Crisp app. The additive P1 source slice also exposes +fail-closed physical display disconnect/reconnect on supported Apple Silicon +systems; this is source availability, not a Crisp 1.5.0 release claim. + +| Crisp GUI capability | `crispctl` command | +|---|---| +| Display inventory and live capabilities | `displays list`, `displays get`, `displays capabilities` | +| Intentionally disconnected physical displays | `displays disconnected` | +| Physical display connection state | `displays disconnect `, `displays reconnect ` | +| One display's logical brightness | `brightness get`, `brightness set` | +| All physical displays, same logical percent per display | `brightness get-all`, `brightness set-all` | +| Extra Brightness / live EDR range | `extra-brightness get`, `extra-brightness set` | +| Explicit external HDR toggle | `hdr get`, `hdr set` | + +`brightness set-all` remains strict by default: every target must provide a +readable pre-write restore snapshot or the command makes no changes. A human who +explicitly accepts an unrestorable/write-only target and manual restoration can +opt in with `--allow-unrestorable`. That mode is still non-atomic, performs no +rollback, and reports unverified writes and manual-restoration UUIDs separately. + +Every connection write requires explicit authorization, fresh capability or +disconnected inventory, and the exact same UUID. Disconnect accepts only a UUID +copied unchanged from a fresh `displays list`/capabilities response; reconnect +accepts only one copied from a fresh `displays disconnected` response. Names, +`main`, and `builtin` are not connection-write inputs. Apple Silicon/macOS 13+, +positive hardware-backed physical proof, and last-viewable-display gates fail +closed. Every indeterminate connection timeout identifies the exact +`displayUUID` and requested state and is never retried automatically; read and +reconcile before a fresh decision. No real display write is exercised by the +headless suite. + +Built-in displays intentionally have no `hdr set`; use Extra Brightness when its +live capability is writable. Resolution, presets, arrangement, +virtual-display creation, and other remaining P1/P2 settings remain GUI-only. See +[docs/crispctl.md](docs/crispctl.md) for commands, response schema, security, +and headless verification. AI agents can install the repository's standard +Skill from [skills/crispctl/SKILL.md](skills/crispctl/SKILL.md) after reviewing +its capability-before-write and indeterminate-write safeguards. + ## Building ```sh diff --git a/Sources/CrispControlCLI/CLIParser.swift b/Sources/CrispControlCLI/CLIParser.swift new file mode 100644 index 0000000..d438acf --- /dev/null +++ b/Sources/CrispControlCLI/CLIParser.swift @@ -0,0 +1,217 @@ +import Foundation +import CrispControlCore + +public struct CLIInvocation: Equatable, Sendable { + public let request: ControlRequest + public let noStart: Bool + public let socketPath: String + + public init(request: ControlRequest, noStart: Bool, socketPath: String) { + self.request = request + self.noStart = noStart + self.socketPath = socketPath + } +} + +public enum CLIParseError: Error, Equatable, LocalizedError { + case invalid(String) + + public var errorDescription: String? { + guard case let .invalid(message) = self else { return nil } + return message + } +} + +public struct CLIParser: Sendable { + private let requestID: @Sendable () -> String + + public init(requestID: @escaping @Sendable () -> String = { UUID().uuidString }) { + self.requestID = requestID + } + + public func parse(_ arguments: [String]) throws -> CLIInvocation { + let options = try parseOptions(arguments) + let parsed = try parseCommand(options.positional) + var requestArguments = parsed.arguments + if options.allowUnrestorable { + guard parsed.command == "brightness.set-all" else { + throw CLIParseError.invalid("--allow-unrestorable is only valid with brightness set-all") + } + requestArguments["allowUnrestorable"] = .bool(true) + } + return CLIInvocation( + request: ControlRequest( + requestID: requestID(), + command: parsed.command, + arguments: requestArguments + ), + noStart: options.noStart, + socketPath: options.socketPath + ) + } + + private func parseOptions(_ arguments: [String]) throws -> ParsedOptions { + var positional: [String] = [] + var noStart = false + var allowUnrestorable = false + var socketPath = ControlSocket.defaultPath + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--json": break + case "--no-start": noStart = true + case "--allow-unrestorable": allowUnrestorable = true + case "--socket": + index += 1 + guard index < arguments.count, !arguments[index].hasPrefix("--") else { + throw CLIParseError.invalid("--socket requires a path") + } + socketPath = arguments[index] + case let option where option.hasPrefix("--"): + throw CLIParseError.invalid("unknown option: \(option)") + default: positional.append(arguments[index]) + } + index += 1 + } + return ParsedOptions( + positional: positional, + noStart: noStart, + allowUnrestorable: allowUnrestorable, + socketPath: socketPath + ) + } + + private func parseCommand(_ positional: [String]) throws -> ParsedCommand { + if positional == ["version"] { + return ParsedCommand(command: "version") + } else if positional == ["status"] { + return ParsedCommand(command: "status") + } else if positional == ["displays", "list"] { + return ParsedCommand(command: "displays.list") + } + if let display = parseDisplayCommand(positional) { return display } + if let brightness = try parseBrightnessCommand(positional) { return brightness } + if let toggle = try parseToggleCommand(positional) { return toggle } + throw CLIParseError.invalid("invalid command; run crispctl --help for usage") + } + + private func parseDisplayCommand(_ positional: [String]) -> ParsedCommand? { + if positional == ["displays", "disconnected"] { + return ParsedCommand(command: "displays.disconnected") + } + guard positional.count == 3, positional[0] == "displays" else { return nil } + if positional[1] == "disconnect" || positional[1] == "reconnect" { + guard ControlRequest.isExactDisplayUUID(positional[2]) else { return nil } + return ParsedCommand( + command: "displays.\(positional[1])", + arguments: ["uuid": .string(positional[2])] + ) + } + guard positional[1] == "get" || positional[1] == "capabilities" else { return nil } + return ParsedCommand( + command: "displays.\(positional[1])", + arguments: ["selector": .string(positional[2])] + ) + } + + private func parseBrightnessCommand(_ positional: [String]) throws -> ParsedCommand? { + guard positional.first == "brightness", positional.count >= 2 else { return nil } + if positional == ["brightness", "get-all"] { + return ParsedCommand(command: "brightness.get-all") + } + if positional.count == 3, positional[1] == "get" { + return ParsedCommand( + command: "brightness.get", + arguments: ["selector": .string(positional[2])] + ) + } + if positional.count == 4, positional[1] == "set" { + let percent = try parsePercent(positional[3]) + return ParsedCommand( + command: "brightness.set", + arguments: ["selector": .string(positional[2]), "percent": .number(percent)] + ) + } + if positional.count == 3, positional[1] == "set-all" { + return ParsedCommand( + command: "brightness.set-all", + arguments: ["percent": .number(try parsePercent(positional[2]))] + ) + } + return nil + } + + private func parseToggleCommand(_ positional: [String]) throws -> ParsedCommand? { + guard positional.count == 3 || positional.count == 4, + positional[0] == "extra-brightness" || positional[0] == "hdr" else { return nil } + if positional.count == 3, positional[1] == "get" { + return ParsedCommand( + command: "\(positional[0]).get", + arguments: ["selector": .string(positional[2])] + ) + } + guard positional.count == 4, positional[1] == "set", + let enabled = Self.parseOnOff(positional[3]) else { return nil } + return ParsedCommand( + command: "\(positional[0]).set", + arguments: ["selector": .string(positional[2]), "enabled": .bool(enabled)] + ) + } + + private func parsePercent(_ value: String) throws -> Double { + guard let percent = Double(value), percent.isFinite else { + throw CLIParseError.invalid("brightness percent must be a finite number") + } + return percent + } + + private static func parseOnOff(_ value: String) -> Bool? { + switch value.lowercased() { + case "on": true + case "off": false + default: nil + } + } +} + +private struct ParsedOptions { + let positional: [String] + let noStart: Bool + let allowUnrestorable: Bool + let socketPath: String +} + +private struct ParsedCommand { + let command: String + let arguments: [String: JSONValue] + + init(command: String, arguments: [String: JSONValue] = [:]) { + self.command = command + self.arguments = arguments + } +} + +public let crispctlHelp = """ +Usage: + crispctl version --json + crispctl status --json [--no-start] + crispctl displays list --json [--no-start] + crispctl displays get --json [--no-start] + crispctl displays capabilities --json [--no-start] + crispctl displays disconnected --json [--no-start] + crispctl displays disconnect --json [--no-start] + crispctl displays reconnect --json [--no-start] + crispctl brightness get --json [--no-start] + crispctl brightness set --json [--no-start] + crispctl brightness get-all --json [--no-start] + crispctl brightness set-all --json [--allow-unrestorable] [--no-start] + crispctl extra-brightness get --json [--no-start] + crispctl extra-brightness set on|off --json [--no-start] + crispctl hdr get --json [--no-start] + crispctl hdr set on|off --json [--no-start] + +Options: + --allow-unrestorable For brightness set-all only: allow writes without a readable + pre-write restore snapshot. Such displays may be unverified + and require manual restoration. +""" diff --git a/Sources/CrispControlCLI/CLIRunner.swift b/Sources/CrispControlCLI/CLIRunner.swift new file mode 100644 index 0000000..1e42a6b --- /dev/null +++ b/Sources/CrispControlCLI/CLIRunner.swift @@ -0,0 +1,124 @@ +import Foundation +import CrispControlCore + +public protocol ControlTransport: Sendable { + func send(_ request: ControlRequest) throws -> ControlResponse +} + +extension UnixSocketClient: ControlTransport {} + +public protocol AppLaunching: Sendable { + func launch() throws +} + +public enum LauncherError: Error, Equatable { + case notFound + case untrustedBundle + case launchFailed +} + +public struct CLIRunResult: Sendable { + public let response: ControlResponse + public let exitCode: Int32 + + public init(response: ControlResponse) { + self.response = response + self.exitCode = response.ok ? 0 : response.error?.code.exitCode ?? 1 + } +} + +public struct CLIRunner: Sendable { + private let transport: any ControlTransport + private let launcher: any AppLaunching + private let readinessTimeout: TimeInterval + private let pollInterval: TimeInterval + + public init( + transport: any ControlTransport, + launcher: any AppLaunching, + readinessTimeout: TimeInterval = 5, + pollInterval: TimeInterval = 0.05 + ) { + self.transport = transport + self.launcher = launcher + self.readinessTimeout = readinessTimeout + self.pollInterval = pollInterval + } + + public func run(_ invocation: CLIInvocation) -> CLIRunResult { + do { + return CLIRunResult(response: try transport.send(invocation.request)) + } catch let error as IPCError where error == .unavailable { + if invocation.noStart { + return failure(invocation, .appNotRunning, "Crisp is not running") + } + } catch { + return transportFailure(invocation, error) + } + + do { + try launcher.launch() + } catch { + return failure( + invocation, + .appLaunchFailed, + "Crisp could not be launched by bundle identity", + details: .object(["bundleIdentifier": .string("com.crisp.app")]) + ) + } + + let deadline = Date().addingTimeInterval(readinessTimeout) + while Date() < deadline { + if pollInterval > 0 { Thread.sleep(forTimeInterval: pollInterval) } + do { + return CLIRunResult(response: try transport.send(invocation.request)) + } catch let error as IPCError where error == .unavailable { + continue + } catch { + return transportFailure(invocation, error) + } + } + return failure( + invocation, + .appReadinessTimeout, + "Crisp launched but its control socket did not become ready", + details: .object(["timeoutSeconds": .number(readinessTimeout)]) + ) + } + + private func transportFailure(_ invocation: CLIInvocation, _ error: Error) -> CLIRunResult { + if let ipcError = error as? IPCError, ipcError == .timeout { + if invocation.request.isMutating { + return CLIRunResult(response: .timeout(for: invocation.request)) + } + return failure(invocation, .timeout, "Crisp control request timed out") + } + if let ipcError = error as? IPCError, + ipcError == .responseProtocolMismatch || ipcError == .responseRequestMismatch { + return failure(invocation, .protocolMismatch, ipcError.localizedDescription) + } + return failure(invocation, .transportError, "Crisp control transport failed") + } + + private func failure( + _ invocation: CLIInvocation, + _ code: ControlErrorCode, + _ message: String, + details: JSONValue? = nil + ) -> CLIRunResult { + CLIRunResult(response: .failure(requestID: invocation.request.requestID, + code: code, message: message, details: details)) + } +} + +public enum CLIOutput { + public static func jsonLine(_ response: ControlResponse) throws -> String { + let data = try ControlJSON.encoder.encode(response) + guard let output = String(data: data, encoding: .utf8) else { throw CLIOutputError.invalidUTF8 } + return output + "\n" + } +} + +public enum CLIOutputError: Error { + case invalidUTF8 +} diff --git a/Sources/CrispControlCLI/DefaultAppLauncher.swift b/Sources/CrispControlCLI/DefaultAppLauncher.swift new file mode 100644 index 0000000..fe8eab7 --- /dev/null +++ b/Sources/CrispControlCLI/DefaultAppLauncher.swift @@ -0,0 +1,16 @@ +import AppKit + +public struct DefaultCrispAppLauncher: AppLaunching { + public init() {} + + public func launch() throws { + guard let applicationURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.crisp.app") else { + throw LauncherError.notFound + } + guard applicationURL.pathExtension == "app", + Bundle(url: applicationURL)?.bundleIdentifier == "com.crisp.app" else { + throw LauncherError.untrustedBundle + } + guard NSWorkspace.shared.open(applicationURL) else { throw LauncherError.launchFailed } + } +} diff --git a/Sources/CrispControlCore/AppliedFactorCommitCoordinator.swift b/Sources/CrispControlCore/AppliedFactorCommitCoordinator.swift new file mode 100644 index 0000000..5b10d21 --- /dev/null +++ b/Sources/CrispControlCore/AppliedFactorCommitCoordinator.swift @@ -0,0 +1,76 @@ +public struct AppliedFactorCommitToken: Equatable, Sendable { + public let uuid: String + public let identity: String + public let generation: UInt64 + public let factor: Double +} + +/// Publishes app-owned boost state only after the queued transfer-table write +/// confirms that it ran for the same stable display identity. +public struct AppliedFactorCommitCoordinator: Sendable { + private struct Entry: Sendable { + var identity: String + var generation: UInt64 + var appliedFactor: Double? + } + + private var entries: [String: Entry] = [:] + + public init() {} + + public mutating func begin( + uuid: String, + identity: String, + factor: Double + ) -> AppliedFactorCommitToken { + let existing = entries[uuid] + let generation = (existing?.generation ?? 0) &+ 1 + entries[uuid] = Entry( + identity: identity, + generation: generation, + appliedFactor: existing?.identity == identity ? existing?.appliedFactor : nil + ) + return AppliedFactorCommitToken( + uuid: uuid, + identity: identity, + generation: generation, + factor: factor + ) + } + + @discardableResult + public mutating func complete( + _ token: AppliedFactorCommitToken, + queueAccepted: Bool, + currentUUID: String, + currentIdentity: String + ) -> Bool { + guard queueAccepted, + token.uuid == currentUUID, + token.identity == currentIdentity, + let entry = entries[token.uuid], + entry.identity == token.identity, + entry.generation == token.generation else { return false } + entries[token.uuid]?.appliedFactor = token.factor + return true + } + + public func appliedFactor(uuid: String, identity: String) -> Double? { + guard let entry = entries[uuid], entry.identity == identity else { return nil } + return entry.appliedFactor + } + + public func isCommitted( + factor: Double, + uuid: String, + identity: String, + tolerance: Double + ) -> Bool { + guard let applied = appliedFactor(uuid: uuid, identity: identity) else { return false } + return abs(applied - factor) <= tolerance + } + + public mutating func removeAll() { + entries.removeAll() + } +} diff --git a/Sources/CrispControlCore/BoostTransitionCoordinator.swift b/Sources/CrispControlCore/BoostTransitionCoordinator.swift new file mode 100644 index 0000000..d6827bd --- /dev/null +++ b/Sources/CrispControlCore/BoostTransitionCoordinator.swift @@ -0,0 +1,130 @@ +import Foundation + +public enum BoostTransitionPhase: String, Equatable, Sendable { + case enabling + case enabled + case collapsing + case disabled +} + +/// App-control outcome at the boundary where a legacy Boolean completion is +/// reconciled with fresh same-display state. Cancellation is intentionally not +/// represented here: it continues to throw so the dispatcher reports an +/// indeterminate write. +public enum ExtraBrightnessControlMutationOutcome: Equatable, Sendable { + case rejectedBeforeAcceptance + case accepted + case settling + case indeterminate + + public static func classify( + mutationAccepted: Bool, + operationCompleted: Bool, + identityMatches: Bool, + persistedEnabled: Bool, + liveEnabled: Bool, + maxBrightness: Double, + cleanupInProgress: Bool + ) -> Self { + guard mutationAccepted else { return .rejectedBeforeAcceptance } + guard identityMatches else { return .indeterminate } + if operationCompleted { return .accepted } + guard !persistedEnabled else { return .indeterminate } + guard cleanupInProgress else { return .indeterminate } + return .settling + } + + public func resolvedControlResult( + capability: @autoclosure () -> ExtraBrightnessCapability + ) throws -> ExtraBrightnessSetResult? { + switch self { + case .rejectedBeforeAcceptance: + throw ControlServiceError.writeFailed( + "Extra Brightness request was rejected by the live app service" + ) + case .indeterminate: + throw ControlServiceError.writeIndeterminate( + "Extra Brightness was accepted but terminal app state is unknown; " + + "read back before another write" + ) + case .settling: + return ExtraBrightnessSetResult( + capability: capability(), + verification: .settling, + warnings: [ + "Extra Brightness disable was accepted and persisted off, " + + "but terminal app/overlay cleanup is still settling" + ] + ) + case .accepted: + return nil + } + } +} + +public struct BoostTransitionToken: Equatable, Sendable { + public let uuid: String + public let identity: String + public let generation: UInt64 +} + +/// Generation guard shared by GUI and automation boost transitions. An opaque +/// object identity prevents a re-used display ID from accepting stale callbacks. +public struct BoostTransitionCoordinator: Sendable { + private struct Entry: Sendable { + var identity: String + var generation: UInt64 + var phase: BoostTransitionPhase + } + + private var entries: [String: Entry] = [:] + + public init() {} + + public mutating func begin(uuid: String, identity: String, enabled: Bool) -> BoostTransitionToken { + let generation = (entries[uuid]?.generation ?? 0) &+ 1 + entries[uuid] = Entry( + identity: identity, + generation: generation, + phase: enabled ? .enabling : .collapsing + ) + return BoostTransitionToken(uuid: uuid, identity: identity, generation: generation) + } + + public func accepts( + _ token: BoostTransitionToken, + currentUUID: String, + currentIdentity: String + ) -> Bool { + guard token.uuid == currentUUID, token.identity == currentIdentity, + let entry = entries[token.uuid] else { return false } + return entry.identity == token.identity && entry.generation == token.generation + } + + public mutating func completeEnable(_ token: BoostTransitionToken) -> Bool { + guard accepts(token, currentUUID: token.uuid, currentIdentity: token.identity) else { return false } + entries[token.uuid]?.phase = .enabled + return true + } + + public mutating func completeDisable(_ token: BoostTransitionToken, atIdentity: Bool) -> Bool { + guard atIdentity, + accepts(token, currentUUID: token.uuid, currentIdentity: token.identity) else { return false } + entries[token.uuid]?.phase = .disabled + return true + } + + public func phase(uuid: String, identity: String) -> BoostTransitionPhase? { + guard let entry = entries[uuid], entry.identity == identity else { return nil } + return entry.phase + } + + public func headroomMaySync(uuid: String, identity: String) -> Bool { + phase(uuid: uuid, identity: identity) != .collapsing + } + + public mutating func invalidate(uuid: String, identity: String) { + guard entries[uuid]?.identity == identity else { return } + entries.removeValue(forKey: uuid) + } +} diff --git a/Sources/CrispControlCore/BrightnessHeartbeatController.swift b/Sources/CrispControlCore/BrightnessHeartbeatController.swift new file mode 100644 index 0000000..1a93855 --- /dev/null +++ b/Sources/CrispControlCore/BrightnessHeartbeatController.swift @@ -0,0 +1,53 @@ +import Foundation + +@MainActor +public final class BrightnessHeartbeatController { + private var operation: Task? + + public init() {} + + public var isRefreshing: Bool { operation != nil } + + @discardableResult + public func schedule( + displays: [Display], + panelVisible: Bool, + autoBrightnessEnabled: Bool, + lastManualAdjustment: Date?, + now: Date = Date(), + isBuiltin: @escaping @MainActor (Display) -> Bool, + prepare: @escaping @MainActor () -> Void, + refresh: @escaping @MainActor (Display) async -> Void + ) -> Bool { + guard operation == nil else { return false } + let task = Task { @MainActor [weak self] in + defer { self?.operation = nil } + guard !Task.isCancelled else { return } + await BrightnessHeartbeat.refresh( + displays: displays, + panelVisible: panelVisible, + autoBrightnessEnabled: autoBrightnessEnabled, + lastManualAdjustment: lastManualAdjustment, + now: now, + isBuiltin: isBuiltin, + prepare: prepare, + refresh: refresh + ) + } + operation = task + return true + } + + public func cancel() { + // Keep the task occupying the single-flight slot until it actually + // returns. Callback-backed display reads may not honor cancellation; + // allowing a replacement task immediately would reintroduce stale + // completion ordering on a quick close/reopen. + operation?.cancel() + } + + public func waitUntilIdle() async { + let current = operation + await current?.value + } +} diff --git a/Sources/CrispControlCore/BrightnessPollPolicy.swift b/Sources/CrispControlCore/BrightnessPollPolicy.swift new file mode 100644 index 0000000..09db906 --- /dev/null +++ b/Sources/CrispControlCore/BrightnessPollPolicy.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum BrightnessPollPolicy { + public static func shouldRefresh(isBuiltin: Bool, autoBrightnessEnabled: Bool) -> Bool { + isBuiltin || !autoBrightnessEnabled + } +} + +public enum BrightnessHeartbeat { + public static let manualAdjustmentSuppression: TimeInterval = 3 + + @MainActor + public static func refresh( + displays: [Display], + panelVisible: Bool, + autoBrightnessEnabled: Bool, + lastManualAdjustment: Date?, + now: Date = Date(), + isBuiltin: @MainActor (Display) -> Bool, + prepare: @MainActor () -> Void = {}, + refresh: @MainActor (Display) async -> Void + ) async { + guard !Task.isCancelled else { return } + guard panelVisible else { return } + if let lastManualAdjustment, + now.timeIntervalSince(lastManualAdjustment) < manualAdjustmentSuppression { + return + } + guard !Task.isCancelled else { return } + prepare() + for display in displays where BrightnessPollPolicy.shouldRefresh( + isBuiltin: isBuiltin(display), + autoBrightnessEnabled: autoBrightnessEnabled + ) { + guard !Task.isCancelled else { return } + await refresh(display) + } + } +} diff --git a/Sources/CrispControlCore/BrightnessWriteCommit.swift b/Sources/CrispControlCore/BrightnessWriteCommit.swift new file mode 100644 index 0000000..bbde163 --- /dev/null +++ b/Sources/CrispControlCore/BrightnessWriteCommit.swift @@ -0,0 +1,13 @@ +public enum BrightnessWriteCommit { + @MainActor + public static func perform( + write: @MainActor () async throws -> Value, + commit: @MainActor (Value) -> Void + ) async throws -> Value { + try Task.checkCancellation() + let value = try await write() + try Task.checkCancellation() + commit(value) + return value + } +} diff --git a/Sources/CrispControlCore/CommandDispatcher.swift b/Sources/CrispControlCore/CommandDispatcher.swift new file mode 100644 index 0000000..3597ee3 --- /dev/null +++ b/Sources/CrispControlCore/CommandDispatcher.swift @@ -0,0 +1,1360 @@ +import Foundation + +public protocol ControlCommandService: Sendable { + func displays() async throws -> [ControlDisplay] + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] + func disconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult + func readBrightness(displayUUID: String) async throws -> Double? + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double + func readBrightnessState(displayUUID: String) async throws -> BrightnessReadSnapshot? + func setExtraBrightness(displayUUID: String, enabled: Bool) async throws -> ExtraBrightnessSetResult + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult +} + +public extension ControlCommandService { + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { [] } + + func disconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + throw DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: displayUUID, + requestedConnectionState: .disconnected, + message: "physical display disconnect is unavailable" + ) + } + + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + throw DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: displayUUID, + requestedConnectionState: .connected, + message: "physical display reconnect is unavailable" + ) + } + + func readBrightnessState(displayUUID: String) async throws -> BrightnessReadSnapshot? { + try await readBrightness(displayUUID: displayUUID).map { + BrightnessReadSnapshot(logicalPercent: $0, hardwareReadbackPercent: nil) + } + } + + func setExtraBrightness(displayUUID: String, enabled: Bool) async throws -> ExtraBrightnessSetResult { + throw ControlServiceError.unsupported("Extra Brightness control is unavailable") + } + + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult { + throw ControlServiceError.unsupported("HDR control is unavailable") + } +} + +public enum ControlServiceError: Error, Sendable { + case unsupported(String) + case readFailed(String) + case writeFailed(String) + case writeIndeterminate(String) +} + +public struct ControlCommandDispatcher: Sendable { + private let service: any ControlCommandService + private let appVersion: String + private let batchExecutionTimeout: TimeInterval + private let batchMonotonicNow: @Sendable () -> TimeInterval + private let batchMemberBoundary: @Sendable (Int) async -> Void + + public init( + service: any ControlCommandService, + appVersion: String, + batchExecutionTimeout: TimeInterval = 10, + batchMonotonicNow: @escaping @Sendable () -> TimeInterval = { + ProcessInfo.processInfo.systemUptime + }, + batchMemberBoundary: @escaping @Sendable (Int) async -> Void = { _ in } + ) { + self.service = service + self.appVersion = appVersion + self.batchExecutionTimeout = batchExecutionTimeout + self.batchMonotonicNow = batchMonotonicNow + self.batchMemberBoundary = batchMemberBoundary + } + + public func handle(_ request: ControlRequest) async -> ControlResponse { + do { + let result = try await execute(request) + return .success(requestID: request.requestID, result: result) + } catch let failure as CommandFailure { + return .failure(requestID: request.requestID, code: failure.code, + message: failure.message, details: failure.details) + } catch let error as ControlServiceError { + switch error { + case let .unsupported(message): + return .failure(requestID: request.requestID, code: .unsupportedCapability, message: message) + case let .readFailed(message): + return .failure(requestID: request.requestID, code: .internalError, message: message) + case let .writeFailed(message): + return .failure(requestID: request.requestID, code: .writeVerificationFailed, message: message) + case let .writeIndeterminate(message): + return .failure( + requestID: request.requestID, code: .writeOutcomeIndeterminate, message: message, + details: .object(["retrySafe": .bool(false), "outcome": .string("unknown")]) + ) + } + } catch is CancellationError { + if request.isMutating { + let timeoutResponse = ControlResponse.timeout(for: request) + return .failure( + requestID: request.requestID, + code: .writeOutcomeIndeterminate, + message: "control write wait ended after cancellation; the underlying outcome is unknown", + details: timeoutResponse.error?.details + ) + } + return .failure( + requestID: request.requestID, + code: .timeout, + message: "control read was cancelled before completion", + details: .object(["cancelled": .bool(true), "retrySafe": .bool(true)]) + ) + } catch { + return .failure(requestID: request.requestID, code: .internalError, + message: "Crisp could not complete the command") + } + } + + private func execute(_ request: ControlRequest) async throws -> JSONValue { + switch request.command { + case "version": + return .object([ + "appVersion": .string(appVersion), + "protocolVersion": .number(Double(crispControlProtocolVersion)) + ]) + case "status": + return .object(["running": .bool(true), "appVersion": .string(appVersion)]) + case "displays.list", "displays.get", "displays.capabilities": + return try await executeDisplayRead(request) + case "displays.disconnected", "displays.disconnect", "displays.reconnect": + return try await executeDisplayConnection(request) + case "brightness.get": + let display = try await selectedDisplay(for: request) + try requireSupported(display.brightness) + guard let snapshot = try await service.readBrightnessState(displayUUID: display.uuid) else { + throw CommandFailure(code: .unsupportedCapability, + message: "brightness read-back is unavailable", + details: .object(["displayUUID": .string(display.uuid)])) + } + return .object([ + "displayUUID": .string(display.uuid), + "percent": .number(snapshot.logicalPercent), + "logicalPercent": .number(snapshot.logicalPercent), + "hardwareReadbackPercent": snapshot.hardwareReadbackPercent.map(JSONValue.number) ?? .null, + "backend": .string(display.brightness.backend.rawValue), + "readback": .string(display.brightness.readback.rawValue) + ]) + case "brightness.set": + return try await setBrightness(request) + case "brightness.get-all": + return try await getAllBrightness() + case "brightness.set-all": + return try await setAllBrightness(request) + case "extra-brightness.get": + let display = try await selectedDisplay(for: request) + try requireSupported(display.extraBrightness.state, capability: display.extraBrightness, + defaultMessage: "Extra Brightness is unsupported") + return try capabilityResult(displayUUID: display.uuid, capability: display.extraBrightness) + case "extra-brightness.set": + return try await setExtraBrightness(request) + case "hdr.get": + let display = try await selectedDisplay(for: request) + try requireSupported(display.hdr.state, capability: display.hdr, + defaultMessage: "HDR is unsupported") + return try capabilityResult(displayUUID: display.uuid, capability: display.hdr) + case "hdr.set": + return try await setHDR(request) + default: + throw CommandFailure(code: .invalidArguments, message: "unknown command: \(request.command)") + } + } + + private func selectedDisplay(for request: ControlRequest) async throws -> ControlDisplay { + guard case let .string(selector)? = request.arguments["selector"], !selector.isEmpty else { + throw CommandFailure(code: .invalidArguments, message: "a display selector is required") + } + do { + return try DisplaySelector.resolve(selector, in: await service.displays()) + } catch let error as SelectorError { + switch error { + case .notFound: + throw CommandFailure(code: .selectorNotFound, message: "display selector did not match", + details: .object(["selector": .string(selector)])) + case let .ambiguous(candidates): + throw CommandFailure(code: .ambiguousSelector, message: "display selector is ambiguous", + details: .object(["candidates": try jsonValue(candidates)])) + } + } + } + + private func setBrightness(_ request: ControlRequest) async throws -> JSONValue { + let display = try await selectedDisplay(for: request) + let capability = display.brightness + guard capability.state == .writable else { + var details: [String: JSONValue] = ["state": .string(capability.state.rawValue)] + if let reason = capability.reason { details["reason"] = .string(reason) } + if let remediation = capability.remediation { details["remediation"] = .string(remediation) } + throw CommandFailure(code: .unsupportedCapability, + message: "brightness is not writable", + details: .object(details)) + } + guard case let .number(requested)? = request.arguments["percent"], requested.isFinite else { + throw CommandFailure(code: .invalidArguments, message: "brightness percent must be a number") + } + guard requested >= capability.range.min, requested <= capability.range.max else { + throw CommandFailure( + code: .invalidArguments, + message: "brightness percent is outside the supported range", + details: .object([ + "min": .number(capability.range.min), + "max": .number(capability.range.max), + "received": .number(requested) + ]) + ) + } + + return try await performBrightnessSet(display: display, requested: requested) + } + +} + +extension ControlCommandDispatcher { + private func executeDisplayRead(_ request: ControlRequest) async throws -> JSONValue { + switch request.command { + case "displays.list": + return .object(["displays": try jsonValue(await service.displays())]) + case "displays.get": + return .object(["display": try jsonValue(await selectedDisplay(for: request))]) + case "displays.capabilities": + let display = try await selectedDisplay(for: request) + return .object([ + "displayUUID": .string(display.uuid), + "brightness": try jsonValue(display.brightness), + "extraBrightness": try jsonValue(display.extraBrightness), + "hdr": try jsonValue(display.hdr), + "connection": try jsonValue(display.connection) + ]) + default: + throw CommandFailure( + code: .invalidArguments, + message: "unknown display read command: \(request.command)" + ) + } + } + + private func executeDisplayConnection(_ request: ControlRequest) async throws -> JSONValue { + switch request.command { + case "displays.disconnected": + let displays = try await service.disconnectedDisplays().sorted { $0.uuid < $1.uuid } + return .object(["displays": try jsonValue(displays)]) + case "displays.disconnect": + return try await disconnectDisplay(request) + case "displays.reconnect": + return try await reconnectDisplay(request) + default: + throw CommandFailure( + code: .invalidArguments, + message: "unknown display connection command: \(request.command)" + ) + } + } + + private func disconnectDisplay(_ request: ControlRequest) async throws -> JSONValue { + guard let uuid = request.exactDisplayConnectionUUID else { + throw CommandFailure( + code: .invalidArguments, + message: "disconnect requires an exact UUID from a fresh display inventory" + ) + } + let discoveredMatches = try await service.displays().filter { $0.uuid == uuid } + guard discoveredMatches.count == 1, let discovered = discoveredMatches.first else { + throw CommandFailure( + code: .selectorNotFound, + message: "display UUID is absent or non-unique in the fresh online inventory", + details: .object([ + "phase": .string("preflight"), + "displayUUID": .string(uuid), + "retrySafe": .bool(true), + "mutationDispatched": .bool(false) + ]) + ) + } + try requireDisconnectAllowed(discovered) + + let freshMatches = try await service.displays().filter { $0.uuid == uuid } + guard freshMatches.count == 1, let fresh = freshMatches.first else { + throw CommandFailure( + code: .selectorNotFound, + message: "display UUID disappeared or changed before disconnect", + details: .object([ + "phase": .string("preflight"), + "displayUUID": .string(uuid), + "retrySafe": .bool(true), + "mutationDispatched": .bool(false) + ]) + ) + } + try requireDisconnectAllowed(fresh) + + do { + let result = try await service.disconnectDisplay(displayUUID: fresh.uuid) + try Task.checkCancellation() + return try validatedConnectionResult( + result, + command: request.command, + expectedUUID: fresh.uuid, + requestedState: .disconnected + ) + } catch let error as DisplayConnectionMutationError { + throw connectionFailure(error, command: request.command) + } catch is CancellationError { + throw indeterminateConnectionFailure( + command: request.command, + uuid: fresh.uuid, + state: .disconnected, + message: "disconnect wait was cancelled after service dispatch" + ) + } + } + + private func reconnectDisplay(_ request: ControlRequest) async throws -> JSONValue { + guard let uuid = request.exactDisplayConnectionUUID else { + throw CommandFailure( + code: .invalidArguments, + message: "reconnect requires an exact UUID from a fresh disconnected inventory" + ) + } + let matches = try await service.disconnectedDisplays().filter { $0.uuid == uuid } + guard !matches.isEmpty else { + throw CommandFailure( + code: .selectorNotFound, + message: "UUID is absent from the fresh disconnected inventory", + details: .object([ + "phase": .string("preflight"), + "displayUUID": .string(uuid), + "retrySafe": .bool(true), + "mutationDispatched": .bool(false) + ]) + ) + } + guard matches.count == 1 else { + throw CommandFailure( + code: .ambiguousSelector, + message: "duplicate disconnected records make the UUID unsafe to reconnect", + details: .object([ + "phase": .string("preflight"), + "displayUUID": .string(uuid), + "retrySafe": .bool(true), + "mutationDispatched": .bool(false) + ]) + ) + } + do { + let result = try await service.reconnectDisplay(displayUUID: uuid) + try Task.checkCancellation() + return try validatedConnectionResult( + result, + command: request.command, + expectedUUID: uuid, + requestedState: .connected + ) + } catch let error as DisplayConnectionMutationError { + throw connectionFailure(error, command: request.command) + } catch is CancellationError { + throw indeterminateConnectionFailure( + command: request.command, + uuid: uuid, + state: .connected, + message: "reconnect wait was cancelled after service dispatch" + ) + } + } + + private func requireDisconnectAllowed(_ display: ControlDisplay) throws { + let capability = display.connection + guard !display.isVirtual, + capability.state == .writable, + capability.connected, + capability.disconnectAllowed, + capability.platformSupported else { + try connectionCapabilityFailure( + uuid: display.uuid, + message: "physical display disconnect is not allowed now", + capability: capability + ) + } + } + + private func connectionCapabilityFailure( + uuid: String, + message: String, + capability: DisplayConnectionCapability + ) throws -> Never { + guard case var .object(details) = try jsonValue(capability) else { + throw CommandFailure(code: .unsupportedCapability, message: message) + } + details["phase"] = .string("preflight") + details["displayUUID"] = .string(uuid) + details["retrySafe"] = .bool(true) + details["mutationDispatched"] = .bool(false) + throw CommandFailure(code: .unsupportedCapability, message: message, details: .object(details)) + } + + private func validatedConnectionResult( + _ result: DisplayConnectionSetResult, + command: String, + expectedUUID: String, + requestedState: DisplayConnectionState + ) throws -> JSONValue { + guard result.displayUUID == expectedUUID, + result.requestedConnectionState == requestedState, + result.observedConnectionState == requestedState, + result.verification == .sameUUIDEnumeration else { + throw indeterminateConnectionFailure( + command: command, + uuid: expectedUUID, + state: requestedState, + message: "service result could not prove the exact post-mutation UUID state" + ) + } + return try jsonValue(result) + } + + private func connectionFailure( + _ error: DisplayConnectionMutationError, + command: String + ) -> CommandFailure { + let isIndeterminate = error.classification == .indeterminate + let details: JSONValue = .object([ + "phase": .string(error.mutationDispatched ? "post_dispatch" : "preflight"), + "retrySafe": .bool(error.retrySafe), + "mutationDispatched": .bool(error.mutationDispatched), + "outcome": .string(isIndeterminate ? "unknown" : "definite"), + "command": .string(command), + "displayUUID": .string(error.displayUUID), + "requestedConnectionState": .string(error.requestedConnectionState.rawValue) + ]) + let code: ControlErrorCode = switch error.classification { + case .preflightRejected: .unsupportedCapability + case .definiteFailure: .writeVerificationFailed + case .indeterminate: .writeOutcomeIndeterminate + } + return CommandFailure(code: code, message: error.message, details: details) + } + + private func indeterminateConnectionFailure( + command: String, + uuid: String, + state: DisplayConnectionState, + message: String + ) -> CommandFailure { + connectionFailure( + DisplayConnectionMutationError( + classification: .indeterminate, + displayUUID: uuid, + requestedConnectionState: state, + message: message + ), + command: command + ) + } + + private func performBrightnessSet( + display: ControlDisplay, + requested: Double, + originalSnapshot: BrightnessReadSnapshot? = nil, + allowUnrestorable: Bool = false + ) async throws -> JSONValue { + let capability = display.brightness + let original: BrightnessReadSnapshot? + if let originalSnapshot { + original = originalSnapshot + } else if allowUnrestorable { + original = nil + } else { + original = try await service.readBrightnessState(displayUUID: display.uuid) + } + let applied = try await service.writeBrightness(displayUUID: display.uuid, percent: requested) + try Task.checkCancellation() + var readback: BrightnessReadSnapshot? + var verification = "unavailable" + var warnings: [JSONValue] = [] + + if capability.readback == .unavailable { + if requested > capability.hardwareRange.max { + readback = try await service.readBrightnessState(displayUUID: display.uuid) + } else { + readback = nil + } + if requested > capability.hardwareRange.max, let readback { + let tolerance = max(capability.logicalRange.precision * 2, 0.25) + guard abs(readback.logicalPercent - applied) <= tolerance else { + throw CommandFailure(code: .writeVerificationFailed, + message: "committed app brightness did not match the applied value") + } + verification = "app_state_verified" + warnings.append(.string( + "logical EDR state is verified in Crisp; " + + "no independent hardware-authoritative EDR read-back is available" + )) + } else { + verification = "unavailable" + warnings.append(.string("backend does not provide read-back; applied value is not independently verified")) + } + } else { + readback = try await service.readBrightnessState(displayUUID: display.uuid) + guard let verifiedReadback = readback else { + throw CommandFailure( + code: .writeVerificationFailed, + message: "brightness write completed but read-back failed" + ) + } + let tolerance = max(capability.range.precision * 2, 0.25) + guard abs(verifiedReadback.logicalPercent - applied) <= tolerance else { + throw CommandFailure( + code: .writeVerificationFailed, + message: "brightness read-back did not match the applied value", + details: .object([ + "requestedPercent": .number(requested), + "appliedPercent": .number(applied), + "readbackPercent": .number(verifiedReadback.logicalPercent), + "tolerance": .number(tolerance) + ]) + ) + } + if requested > capability.hardwareRange.max { + verification = "app_state_verified" + warnings.append(.string( + "logical EDR state is verified in Crisp; hardware read-back covers only 0...100" + )) + } else { + verification = capability.readback == .authoritative ? "verified" : "approximate" + } + } + try Task.checkCancellation() + + if allowUnrestorable { + warnings.append(.string("no readable pre-write restore snapshot is available; restore manually")) + } + + return .object([ + "displayUUID": .string(display.uuid), + "requestedPercent": .number(requested), + "originalPercent": original.map { .number($0.logicalPercent) } ?? .null, + "appliedPercent": .number(applied), + "readbackPercent": readback.map { .number($0.logicalPercent) } ?? .null, + "logicalPercent": readback.map { .number($0.logicalPercent) } ?? .number(applied), + "hardwareReadbackPercent": readback?.hardwareReadbackPercent.map(JSONValue.number) ?? .null, + "verification": .string(verification), + "backend": .string(capability.backend.rawValue), + "warnings": .array(warnings) + ]) + } + + private func physicalDisplays() async throws -> [ControlDisplay] { + let displays = try await service.displays().filter { !$0.isVirtual }.sorted { $0.uuid < $1.uuid } + guard !displays.isEmpty else { + throw CommandFailure( + code: .emptyPhysicalInventory, + message: "no connected non-virtual physical displays were found" + ) + } + return displays + } + + private func getAllBrightness() async throws -> JSONValue { + let displays = try await physicalDisplays() + var results: [JSONValue] = [] + for display in displays { + do { + try requireSupported(display.brightness) + guard let snapshot = try await service.readBrightnessState(displayUUID: display.uuid) else { + throw CommandFailure(code: .unsupportedCapability, + message: "brightness read-back is unavailable") + } + results.append(.object([ + "displayUUID": .string(display.uuid), + "ok": .bool(true), + "percent": .number(snapshot.logicalPercent), + "logicalPercent": .number(snapshot.logicalPercent), + "hardwareReadbackPercent": snapshot.hardwareReadbackPercent.map(JSONValue.number) ?? .null, + "brightness": try jsonValue(display.brightness), + "extraBrightness": try jsonValue(display.extraBrightness), + "hdr": try jsonValue(display.hdr) + ])) + } catch let failure as CommandFailure { + results.append(batchReadFailure(displayUUID: display.uuid, failure: failure)) + } catch { + results.append(batchReadFailure( + displayUUID: display.uuid, + failure: CommandFailure(code: .internalError, message: "brightness read failed") + )) + } + } + return .object([ + "semantics": .string("same_logical_percent_per_display"), + "displays": .array(results) + ]) + } + + private func batchReadFailure(displayUUID: String, failure: CommandFailure) -> JSONValue { + var error: [String: JSONValue] = [ + "code": .string(failure.code.rawValue), + "message": .string(failure.message) + ] + if let details = failure.details { error["details"] = details } + return .object([ + "displayUUID": .string(displayUUID), "ok": .bool(false), "error": .object(error) + ]) + } + + private func setAllBrightness(_ request: ControlRequest) async throws -> JSONValue { + guard case let .number(requested)? = request.arguments["percent"], requested.isFinite else { + throw CommandFailure(code: .invalidArguments, message: "brightness percent must be a number") + } + guard let restoreMode = request.brightnessBatchRestoreMode else { + throw CommandFailure(code: .invalidArguments, + message: "allowUnrestorable must be a boolean when provided") + } + let commandDeadline = BatchDeadline( + timeout: batchExecutionTimeout, now: batchMonotonicNow + ) + let displays = try await batchDisplays(deadline: commandDeadline) + let preflight = try await batchSnapshots( + displays: displays, + requested: requested, + restoreMode: restoreMode, + deadline: commandDeadline + ) + + let execution = await executeBatch( + displays: displays, + preflight: preflight, + requested: requested, + deadline: commandDeadline + ) + return try execution.response( + requested: requested, restoreMode: restoreMode, + missingRestoreSnapshotUUIDs: preflight.missingRestoreSnapshotUUIDs + ) + } + + private func batchPreflightDeadlineFailure( + phase: String, + reason: String, + cancelled: Bool = false + ) -> CommandFailure { + CommandFailure( + code: .batchPreflightFailed, + message: "batch brightness preflight failed; no writes were attempted", + details: .object([ + "retrySafe": .bool(true), + "phase": .string(phase), + "reason": .string(reason), + "cancelled": .bool(cancelled) + ]) + ) + } + + private func performBatchInventory(timeout: TimeInterval) async -> BatchInventoryOutcome { + let race = BatchInventoryRace() + let operation = Task { + do { + await race.resolve(.success(try await service.displays())) + } catch is CancellationError { + await race.resolve(.cancelled("display inventory wait was cancelled")) + } catch { + await race.resolve(.failed("display inventory failed")) + } + } + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + guard !Task.isCancelled else { return } + await race.resolve(.timedOut("display inventory exceeded the batch deadline")) + } + let outcome = await withTaskCancellationHandler { + await race.wait() + } onCancel: { + deadline.cancel() + Task { + await race.resolve(.cancelled("display inventory wait was cancelled")) + operation.cancel() + } + } + deadline.cancel() + if case .timedOut = outcome { operation.cancel() } + return outcome + } + + private func performBatchMember( + display: ControlDisplay, + requested: Double, + originalSnapshot: BrightnessReadSnapshot?, + allowUnrestorable: Bool, + timeout: TimeInterval + ) async -> BatchMemberOutcome { + let race = BatchMemberRace() + let operation = Task { + let outcome: BatchMemberOutcome + do { + outcome = .success(try await performBrightnessSet( + display: display, requested: requested, + originalSnapshot: originalSnapshot, allowUnrestorable: allowUnrestorable + )) + } catch let error as ControlServiceError { + switch error { + case let .writeIndeterminate(message): outcome = .indeterminate(message) + case let .writeFailed(message): outcome = .failed(.writeVerificationFailed, message) + case let .readFailed(message): outcome = .failed(.internalError, message) + case let .unsupported(message): outcome = .failed(.unsupportedCapability, message) + } + } catch let failure as CommandFailure { + outcome = .failed(failure.code, failure.message) + } catch is CancellationError { + outcome = .indeterminate("batch member wait was cancelled; the write outcome is unknown") + } catch { + outcome = .failed(.internalError, "brightness write failed") + } + await race.resolve(outcome) + } + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + guard !Task.isCancelled else { return } + await race.resolve(.indeterminate("batch member exceeded its bounded execution deadline")) + } + let outcome = await withTaskCancellationHandler { + await race.wait() + } onCancel: { + deadline.cancel() + Task { + await race.resolve(.indeterminate( + "batch member wait was cancelled; the write outcome is unknown" + )) + operation.cancel() + } + } + deadline.cancel() + if case .indeterminate = outcome { operation.cancel() } + return outcome + } + + private func performBatchSnapshot( + displayUUID: String, + timeout: TimeInterval + ) async -> BatchSnapshotOutcome { + let race = BatchSnapshotRace() + let operation = Task { + do { + guard let snapshot = try await service.readBrightnessState(displayUUID: displayUUID) else { + await race.resolve(.unavailable("brightness snapshot unavailable")) + return + } + await race.resolve(.success(snapshot)) + } catch is CancellationError { + await race.resolve(.cancelled("brightness snapshot wait was cancelled")) + } catch { + await race.resolve(.failed("brightness snapshot failed")) + } + } + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + guard !Task.isCancelled else { return } + await race.resolve(.timedOut("brightness snapshot exceeded the batch deadline")) + } + let outcome = await withTaskCancellationHandler { + await race.wait() + } onCancel: { + deadline.cancel() + Task { + await race.resolve(.cancelled("brightness snapshot wait was cancelled")) + operation.cancel() + } + } + deadline.cancel() + if case .timedOut = outcome { operation.cancel() } + return outcome + } + + private func setExtraBrightness(_ request: ControlRequest) async throws -> JSONValue { + let display = try await selectedDisplay(for: request) + let enabled = try requestedEnabled(request) + if enabled || !display.needsExtraBrightnessDisableCleanup { + try requireWritable(display.extraBrightness.state, capability: display.extraBrightness, + message: "Extra Brightness is not writable") + } + let result = try await service.setExtraBrightness(displayUUID: display.uuid, enabled: enabled) + try Task.checkCancellation() + return try setCapabilityResult(displayUUID: display.uuid, requestedEnabled: enabled, + capability: result.capability, verification: result.verification, + warnings: result.warnings) + } + + private func setHDR(_ request: ControlRequest) async throws -> JSONValue { + let display = try await selectedDisplay(for: request) + try requireWritable(display.hdr.state, capability: display.hdr, message: "HDR is not writable") + let enabled = try requestedEnabled(request) + let result = try await service.setHDR(displayUUID: display.uuid, enabled: enabled) + try Task.checkCancellation() + return try setCapabilityResult(displayUUID: display.uuid, requestedEnabled: enabled, + capability: result.capability, verification: result.verification, + warnings: result.warnings) + } + + private func requestedEnabled(_ request: ControlRequest) throws -> Bool { + guard case let .bool(enabled)? = request.arguments["enabled"] else { + throw CommandFailure(code: .invalidArguments, message: "state must be on or off") + } + return enabled + } + + private func capabilityResult(displayUUID: String, capability: T) throws -> JSONValue { + guard case var .object(fields) = try jsonValue(capability) else { return .null } + fields["displayUUID"] = .string(displayUUID) + return .object(fields) + } + + private func setCapabilityResult( + displayUUID: String, + requestedEnabled: Bool, + capability: T, + verification: AppStateVerificationQuality, + warnings: [String] + ) throws -> JSONValue { + guard case var .object(fields) = try capabilityResult(displayUUID: displayUUID, capability: capability) else { + return .null + } + fields["requestedEnabled"] = .bool(requestedEnabled) + fields["verification"] = .string(verification.rawValue) + fields["warnings"] = .array(warnings.map(JSONValue.string)) + return .object(fields) + } + + private func requireWritable( + _ state: CapabilityState, capability: T, message: String + ) throws { + guard state == .writable else { + throw CommandFailure(code: .unsupportedCapability, message: message, + details: try jsonValue(capability)) + } + } + + private func requireSupported( + _ state: CapabilityState, capability: T, defaultMessage: String + ) throws { + guard state == .readable || state == .writable else { + throw CommandFailure(code: .unsupportedCapability, message: defaultMessage, + details: try jsonValue(capability)) + } + } + + private func requireSupported(_ capability: BrightnessCapability) throws { + guard capability.state == .readable || capability.state == .writable else { + var details: [String: JSONValue] = ["state": .string(capability.state.rawValue)] + if let reason = capability.reason { details["reason"] = .string(reason) } + if let remediation = capability.remediation { details["remediation"] = .string(remediation) } + throw CommandFailure(code: .unsupportedCapability, + message: capability.reason ?? "brightness is unsupported", + details: .object(details)) + } + } +} + +private extension ControlCommandDispatcher { + func batchDisplays(deadline: BatchDeadline) async throws -> [ControlDisplay] { + let displays: [ControlDisplay] + switch await performBatchInventory(timeout: deadline.remaining) { + case let .success(inventory): + displays = inventory.filter { !$0.isVirtual }.sorted { $0.uuid < $1.uuid } + case let .failed(reason), let .timedOut(reason): + throw batchPreflightDeadlineFailure(phase: "inventory", reason: reason) + case let .cancelled(reason): + throw batchPreflightDeadlineFailure( + phase: "inventory", + reason: reason, + cancelled: true + ) + } + guard !displays.isEmpty else { + throw CommandFailure( + code: .emptyPhysicalInventory, + message: "no connected non-virtual physical displays were found" + ) + } + guard deadline.remaining > 0 else { + throw batchPreflightDeadlineFailure( + phase: "inventory", + reason: "batch inventory deadline elapsed" + ) + } + return displays + } + + func batchSnapshots( + displays: [ControlDisplay], + requested: Double, + restoreMode: BrightnessBatchRestoreMode, + deadline: BatchDeadline + ) async throws -> BatchPreflightResult { + var preflight = BatchPreflightAccumulator() + for display in displays { + guard display.brightness.accepts(requested) else { + preflight.rejectCapability(display: display, requested: requested) + continue + } + let remaining = deadline.remaining + guard remaining > 0 else { + preflight.reject(display: display, reason: "batch snapshot deadline elapsed") + continue + } + let snapshot = await performBatchSnapshot(displayUUID: display.uuid, timeout: remaining) + preflight.record( + snapshot, display: display, restoreMode: restoreMode, + beforeDeadline: deadline.remaining > 0 + ) + } + if let failure = preflight.failure(displays: displays, restoreMode: restoreMode) { throw failure } + return preflight.result + } + + func executeBatch( + displays: [ControlDisplay], + preflight: BatchPreflightResult, + requested: Double, + deadline: BatchDeadline + ) async -> BatchExecutionAccumulator { + var execution = BatchExecutionAccumulator() + for (index, display) in displays.enumerated() { + if Task.isCancelled { + execution.appendNotAttempted( + displays.dropFirst(index), + message: "batch execution wait was cancelled before this member", + missingRestoreSnapshotUUIDs: preflight.missingRestoreSnapshotUUIDSet + ) + break + } + let remaining = deadline.remaining + if remaining <= 0 { + execution.appendNotAttempted( + displays.dropFirst(index), + message: "batch execution deadline elapsed before this member", + missingRestoreSnapshotUUIDs: preflight.missingRestoreSnapshotUUIDSet + ) + break + } + let member = await performBatchMember( + display: display, + requested: requested, + originalSnapshot: preflight.snapshots[display.uuid], + allowUnrestorable: preflight.missingRestoreSnapshotUUIDSet.contains(display.uuid), + timeout: remaining + ) + let classifiedMember: BatchMemberOutcome = if deadline.isExpired { + .indeterminate( + "batch member completed after the absolute execution deadline; " + + "the write outcome is unknown" + ) + } else { + member + } + if execution.record( + classifiedMember, + display: display, + missingRestoreSnapshot: preflight.missingRestoreSnapshotUUIDSet.contains(display.uuid) + ) { + execution.appendNotAttempted( + displays.dropFirst(index + 1), + message: "batch stopped after an indeterminate member", + missingRestoreSnapshotUUIDs: preflight.missingRestoreSnapshotUUIDSet + ) + break + } + await batchMemberBoundary(index) + } + return execution + } +} + +private extension BrightnessCapability { + func accepts(_ requested: Double) -> Bool { + state == .writable && requested >= logicalRange.min && requested <= logicalRange.max + } +} + +private extension ControlDisplay { + var needsExtraBrightnessDisableCleanup: Bool { + extraBrightness.persistedEnabled + || extraBrightness.enabled == true + || extraBrightness.maxBrightness > 100 + || (brightnessPercent ?? 0) > 100 + } +} + +private struct BatchPreflightAccumulator { + var snapshots: [String: BrightnessReadSnapshot] = [:] + private(set) var missingRestoreSnapshotUUIDs: [String] = [] + private var failures: [JSONValue] = [] + private var failedUUIDs: [JSONValue] = [] + private var failedUUIDSet: Set = [] + private var missingRestoreSnapshotUUIDSet: Set = [] + private var cancelled = false + + var result: BatchPreflightResult { + BatchPreflightResult(snapshots: snapshots, missingRestoreSnapshotUUIDs: missingRestoreSnapshotUUIDs) + } + + mutating func rejectCapability(display: ControlDisplay, requested: Double) { + reject(display: display, details: [ + "displayUUID": .string(display.uuid), + "state": .string(display.brightness.state.rawValue), + "min": .number(display.brightness.logicalRange.min), + "max": .number(display.brightness.logicalRange.max), + "received": .number(requested) + ]) + } + + mutating func reject(display: ControlDisplay, reason: String, cancelled: Bool = false) { + var details: [String: JSONValue] = [ + "displayUUID": .string(display.uuid), + "reason": .string(reason) + ] + if cancelled { details["cancelled"] = .bool(true) } + reject(display: display, details: details) + self.cancelled = self.cancelled || cancelled + } + + mutating func record( + _ outcome: BatchSnapshotOutcome, + display: ControlDisplay, + restoreMode: BrightnessBatchRestoreMode, + beforeDeadline: Bool + ) { + switch outcome { + case let .success(snapshot) where beforeDeadline: + snapshots[display.uuid] = snapshot + case .success: + reject(display: display, reason: "batch snapshot deadline elapsed") + case let .unavailable(reason) where beforeDeadline: + recordMissingRestoreSnapshot(display.uuid) + if restoreMode == .strict { reject(display: display, reason: reason) } + case .unavailable: + recordMissingRestoreSnapshot(display.uuid) + reject(display: display, reason: "batch snapshot deadline elapsed") + case let .failed(reason), let .timedOut(reason): + reject(display: display, reason: reason) + case let .cancelled(reason): + reject(display: display, reason: reason, cancelled: true) + } + } + + func failure(displays: [ControlDisplay], restoreMode: BrightnessBatchRestoreMode) -> CommandFailure? { + guard !failures.isEmpty else { return nil } + let outcomes = displays.map { display in + return JSONValue.object([ + "displayUUID": .string(display.uuid), + "ok": .bool(false), + "attempted": .bool(false), + "outcome": .string(failedUUIDSet.contains(display.uuid) ? "preflight_failed" : "not_attempted"), + "status": .string( + failedUUIDSet.contains(display.uuid) + ? BrightnessBatchDisplayStatus.failed.rawValue + : BrightnessBatchDisplayStatus.notAttempted.rawValue + ), + "verification": .string("unavailable"), + "code": .string(ControlErrorCode.batchPreflightFailed.rawValue), + "retrySafe": .bool(true) + ]) + } + return CommandFailure( + code: .batchPreflightFailed, + message: "batch brightness preflight failed; no writes were attempted", + details: .object([ + "retrySafe": .bool(true), + "phase": .string("snapshot"), + "cancelled": .bool(cancelled), + "restoreMode": .string(restoreMode.rawValue), + "missingRestoreSnapshotUUIDs": .array( + missingRestoreSnapshotUUIDs.map(JSONValue.string) + ), + "manualRestorationRequired": .bool(false), + "failedUUIDs": .array(failedUUIDs), + "failures": .array(failures), + "outcomes": .array(outcomes) + ]) + ) + } + + private mutating func recordMissingRestoreSnapshot(_ uuid: String) { + guard missingRestoreSnapshotUUIDSet.insert(uuid).inserted else { return } + missingRestoreSnapshotUUIDs.append(uuid) + } + + private mutating func reject(display: ControlDisplay, details: [String: JSONValue]) { + failedUUIDs.append(.string(display.uuid)) + failedUUIDSet.insert(display.uuid) + failures.append(.object(details)) + } +} + +private struct BatchPreflightResult { + let snapshots: [String: BrightnessReadSnapshot] + let missingRestoreSnapshotUUIDs: [String] + var missingRestoreSnapshotUUIDSet: Set { Set(missingRestoreSnapshotUUIDs) } +} + +private struct BatchExecutionAccumulator { + private var outcomes: [JSONValue] = [] + private var appliedUUIDs: [JSONValue] = [] + private var failedUUIDs: [JSONValue] = [] + private var indeterminateUUIDs: [JSONValue] = [] + private var notAttemptedUUIDs: [JSONValue] = [] + private var manualRestorationUUIDs: [JSONValue] = [] + + /// Returns true when later members must not be attempted. + mutating func record( + _ member: BatchMemberOutcome, display: ControlDisplay, missingRestoreSnapshot: Bool + ) -> Bool { + switch member { + case let .success(value): + guard case var .object(fields) = value else { return false } + let status: BrightnessBatchDisplayStatus = fields["verification"] == .string("unavailable") + ? .writtenUnverified : .writtenVerified + fields["ok"] = .bool(true) + fields["attempted"] = .bool(true) + fields["outcome"] = .string("applied") + fields["status"] = .string(status.rawValue) + fields["code"] = .null + fields["retrySafe"] = .bool(false) + fields["restoreSnapshotAvailable"] = .bool(!missingRestoreSnapshot) + fields["manualRestorationRequired"] = .bool(missingRestoreSnapshot) + outcomes.append(.object(fields)) + appliedUUIDs.append(.string(display.uuid)) + if missingRestoreSnapshot { manualRestorationUUIDs.append(.string(display.uuid)) } + return false + case let .failed(code, message): + failedUUIDs.append(.string(display.uuid)) + outcomes.append(failedOutcome( + display: display, + code: code, + message: message, + missingRestoreSnapshot: missingRestoreSnapshot + )) + if missingRestoreSnapshot { manualRestorationUUIDs.append(.string(display.uuid)) } + return false + case let .indeterminate(message): + failedUUIDs.append(.string(display.uuid)) + indeterminateUUIDs.append(.string(display.uuid)) + outcomes.append(failedOutcome( + display: display, + code: .writeOutcomeIndeterminate, + message: message, + outcome: "indeterminate", + status: .writeIndeterminate, + missingRestoreSnapshot: missingRestoreSnapshot + )) + if missingRestoreSnapshot { manualRestorationUUIDs.append(.string(display.uuid)) } + return true + } + } + + mutating func appendNotAttempted( + _ displays: ArraySlice, + message: String, + missingRestoreSnapshotUUIDs: Set + ) { + for display in displays { + let missingRestoreSnapshot = missingRestoreSnapshotUUIDs.contains(display.uuid) + notAttemptedUUIDs.append(.string(display.uuid)) + outcomes.append(.object([ + "displayUUID": .string(display.uuid), + "ok": .bool(false), + "attempted": .bool(false), + "outcome": .string("not_attempted"), + "status": .string(BrightnessBatchDisplayStatus.notAttempted.rawValue), + "verification": .string("unavailable"), + "code": .string(ControlErrorCode.batchPartialFailure.rawValue), + "message": .string(message), + "retrySafe": .bool(true), + "restoreSnapshotAvailable": .bool(!missingRestoreSnapshot), + "manualRestorationRequired": .bool(false), + "warnings": .array(missingRestoreSnapshot ? [ + .string("no readable pre-write restore snapshot is available; no write was attempted") + ] : []) + ])) + } + } + + func response( + requested: Double, + restoreMode: BrightnessBatchRestoreMode, + missingRestoreSnapshotUUIDs: [String] + ) throws -> JSONValue { + var warnings: [JSONValue] = missingRestoreSnapshotUUIDs.isEmpty ? [] : [.string( + "override accepted one or more displays without a readable pre-write restore snapshot" + )] + if !manualRestorationUUIDs.isEmpty { + warnings.append(.string("manual restoration is required for UUIDs in manualRestorationUUIDs")) + } + let summary: [String: JSONValue] = [ + "semantics": .string("same_logical_percent_per_display"), + "requestedPercent": .number(requested), + "atomic": .bool(false), + "rollbackAttempted": .bool(false), + "restoreMode": .string(restoreMode.rawValue), + "restoreSnapshotsComplete": .bool(missingRestoreSnapshotUUIDs.isEmpty), + "missingRestoreSnapshotUUIDs": .array( + missingRestoreSnapshotUUIDs.map(JSONValue.string) + ), + "manualRestorationRequired": .bool(!manualRestorationUUIDs.isEmpty), + "manualRestorationUUIDs": .array(manualRestorationUUIDs), + "outcomes": .array(outcomes), + "appliedUUIDs": .array(appliedUUIDs), + "failedUUIDs": .array(failedUUIDs), + "indeterminateUUIDs": .array(indeterminateUUIDs), + "notAttemptedUUIDs": .array(notAttemptedUUIDs), + "retrySafe": .bool(false), + "warnings": .array(warnings) + ] + guard failedUUIDs.isEmpty, notAttemptedUUIDs.isEmpty else { + throw CommandFailure( + code: .batchPartialFailure, + message: "batch brightness write partially failed; " + + "successful and indeterminate members must not be retried", + details: .object(summary) + ) + } + return .object(summary) + } + + private func failedOutcome( + display: ControlDisplay, + code: ControlErrorCode, + message: String, + outcome: String = "failed", + status: BrightnessBatchDisplayStatus = .failed, + missingRestoreSnapshot: Bool + ) -> JSONValue { + .object([ + "displayUUID": .string(display.uuid), + "ok": .bool(false), + "attempted": .bool(true), + "outcome": .string(outcome), + "status": .string(status.rawValue), + "verification": .string("unavailable"), + "code": .string(code.rawValue), + "message": .string(message), + "retrySafe": .bool(false), + "restoreSnapshotAvailable": .bool(!missingRestoreSnapshot), + "manualRestorationRequired": .bool(missingRestoreSnapshot), + "warnings": .array(missingRestoreSnapshot ? [ + .string("no readable pre-write restore snapshot is available; restore this display manually") + ] : []) + ]) + } +} + +private struct CommandFailure: Error { + let code: ControlErrorCode + let message: String + let details: JSONValue? + + init(code: ControlErrorCode, message: String, details: JSONValue? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +private struct BatchDeadline: Sendable { + private let expiresAt: TimeInterval + private let now: @Sendable () -> TimeInterval + + init(timeout: TimeInterval, now: @escaping @Sendable () -> TimeInterval) { + self.now = now + expiresAt = now() + max(0, timeout) + } + + var remaining: TimeInterval { max(0, expiresAt - now()) } + var isExpired: Bool { now() >= expiresAt } +} + +private enum BatchInventoryOutcome: Sendable { + case success([ControlDisplay]) + case failed(String) + case timedOut(String) + case cancelled(String) +} + +private actor BatchInventoryRace { + private var outcome: BatchInventoryOutcome? + private var continuation: CheckedContinuation? + + func wait() async -> BatchInventoryOutcome { + if let outcome { return outcome } + return await withCheckedContinuation { continuation = $0 } + } + + func resolve(_ value: BatchInventoryOutcome) { + guard outcome == nil else { return } + outcome = value + continuation?.resume(returning: value) + continuation = nil + } +} + +private enum BatchMemberOutcome: Sendable { + case success(JSONValue) + case failed(ControlErrorCode, String) + case indeterminate(String) +} + +private actor BatchMemberRace { + private var outcome: BatchMemberOutcome? + private var continuation: CheckedContinuation? + + func wait() async -> BatchMemberOutcome { + if let outcome { return outcome } + return await withCheckedContinuation { continuation = $0 } + } + + func resolve(_ value: BatchMemberOutcome) { + guard outcome == nil else { return } + outcome = value + continuation?.resume(returning: value) + continuation = nil + } +} + +private enum BatchSnapshotOutcome: Sendable { + case success(BrightnessReadSnapshot) + case unavailable(String) + case failed(String) + case timedOut(String) + case cancelled(String) +} + +private actor BatchSnapshotRace { + private var outcome: BatchSnapshotOutcome? + private var continuation: CheckedContinuation? + + func wait() async -> BatchSnapshotOutcome { + if let outcome { return outcome } + return await withCheckedContinuation { continuation = $0 } + } + + func resolve(_ value: BatchSnapshotOutcome) { + guard outcome == nil else { return } + outcome = value + continuation?.resume(returning: value) + continuation = nil + } +} + +private func jsonValue(_ value: T) throws -> JSONValue { + try ControlJSON.decoder.decode(JSONValue.self, from: ControlJSON.encoder.encode(value)) +} diff --git a/Sources/CrispControlCore/DisplayConnectionCoordinator.swift b/Sources/CrispControlCore/DisplayConnectionCoordinator.swift new file mode 100644 index 0000000..bc4a8cd --- /dev/null +++ b/Sources/CrispControlCore/DisplayConnectionCoordinator.swift @@ -0,0 +1,957 @@ +import Foundation + +public enum DisplayConnectionState: String, Codable, Equatable, Sendable { + case connected + case disconnected +} + +public enum DisplayConnectionVerificationQuality: String, Codable, Equatable, Sendable { + case sameUUIDEnumeration = "same_uuid_enumeration" + case retainedBindingHardwareContinuity = "retained_binding_hardware_continuity" +} + +public struct DisplayConnectionSetResult: Codable, Equatable, Sendable { + public let displayUUID: String + public let requestedConnectionState: DisplayConnectionState + public let observedConnectionState: DisplayConnectionState + public let verification: DisplayConnectionVerificationQuality + public let warnings: [String] + + public init( + displayUUID: String, + requestedConnectionState: DisplayConnectionState, + observedConnectionState: DisplayConnectionState, + verification: DisplayConnectionVerificationQuality, + warnings: [String] = [] + ) { + self.displayUUID = displayUUID + self.requestedConnectionState = requestedConnectionState + self.observedConnectionState = observedConnectionState + self.verification = verification + self.warnings = warnings + } +} + +public struct DisplayConnectionTarget: Equatable, Sendable { + public let uuid: String + public let displayID: UInt32? + public let name: String + public let width: Int + public let height: Int + public let isHardwareBackedPhysical: Bool + + public init( + uuid: String, + displayID: UInt32? = nil, + name: String, + width: Int, + height: Int, + isHardwareBackedPhysical: Bool + ) { + self.uuid = uuid + self.displayID = displayID + self.name = name + self.width = width + self.height = height + self.isHardwareBackedPhysical = isHardwareBackedPhysical + } +} + +public struct DisplayConnectionObservation: Equatable, Sendable { + public let platformSupported: Bool + public let allUUIDs: Set + public let onlineUUIDs: Set + public let intentionalDisconnectedUUIDs: Set + public let pendingDisconnectUUIDs: Set + public let reconnectReservationUUIDs: Set + public let reconnectPersistenceUncertainUUIDs: Set + public let persistenceStateIsAuthoritative: Bool + public let virtualUUIDs: Set + public let activePhysicalViewableUUIDs: Set + public let candidates: [DisplayConnectionCandidate] + public let recoveryCapabilities: [DisplayConnectionRecoveryCapability] + public let bootSessionID: String? + public let loginSessionID: String? + public let wakeSessionID: String? + public let topologyFingerprint: String? + + public init( + platformSupported: Bool, + allUUIDs: Set, + onlineUUIDs: Set, + intentionalDisconnectedUUIDs: Set, + pendingDisconnectUUIDs: Set = [], + reconnectReservationUUIDs: Set = [], + reconnectPersistenceUncertainUUIDs: Set = [], + persistenceStateIsAuthoritative: Bool = true, + virtualUUIDs: Set, + activePhysicalViewableUUIDs: Set, + candidates: [DisplayConnectionCandidate] = [], + recoveryCapabilities: [DisplayConnectionRecoveryCapability] = [], + bootSessionID: String? = nil, + loginSessionID: String? = nil, + wakeSessionID: String? = nil, + topologyFingerprint: String? = nil + ) { + self.platformSupported = platformSupported + self.allUUIDs = allUUIDs + self.onlineUUIDs = onlineUUIDs + self.intentionalDisconnectedUUIDs = intentionalDisconnectedUUIDs + self.pendingDisconnectUUIDs = pendingDisconnectUUIDs + self.reconnectReservationUUIDs = reconnectReservationUUIDs + self.reconnectPersistenceUncertainUUIDs = reconnectPersistenceUncertainUUIDs + self.persistenceStateIsAuthoritative = persistenceStateIsAuthoritative + self.virtualUUIDs = virtualUUIDs + self.activePhysicalViewableUUIDs = activePhysicalViewableUUIDs + self.candidates = candidates + self.recoveryCapabilities = recoveryCapabilities + self.bootSessionID = bootSessionID + self.loginSessionID = loginSessionID + self.wakeSessionID = wakeSessionID + self.topologyFingerprint = topologyFingerprint + } + + public init( + persistenceSnapshot: DisplayConnectionPersistenceSnapshot, + platformSupported: Bool, + allUUIDs: Set, + onlineUUIDs: Set, + virtualUUIDs: Set, + activePhysicalViewableUUIDs: Set, + candidates: [DisplayConnectionCandidate] = [], + bootSessionID: String? = nil, + loginSessionID: String? = nil, + wakeSessionID: String? = nil, + topologyFingerprint: String? = nil + ) { + let envelope = persistenceSnapshot.envelope + self.init( + platformSupported: platformSupported, + allUUIDs: allUUIDs, + onlineUUIDs: onlineUUIDs, + intentionalDisconnectedUUIDs: Set(envelope.records.map(\.uuid)), + pendingDisconnectUUIDs: envelope.pendingSet, + reconnectReservationUUIDs: envelope.reconnectReservationSet, + reconnectPersistenceUncertainUUIDs: envelope.reconnectPersistenceUncertainSet, + persistenceStateIsAuthoritative: persistenceSnapshot.authority == .durable, + virtualUUIDs: virtualUUIDs, + activePhysicalViewableUUIDs: activePhysicalViewableUUIDs, + candidates: candidates, + recoveryCapabilities: envelope.records.compactMap(\.recoveryCapability), + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint + ) + } +} + +public enum DisplayConnectionDispatchOutcome: Equatable, Sendable { + case completed + case rejectedBeforeDispatch(String) + case failedAfterDispatch(String) + case timedOut + case cancelled +} + +public enum DisplayConnectionFailureClassification: String, Equatable, Sendable { + case preflightRejected = "preflight_rejected" + case definiteFailure = "definite_failure" + case indeterminate +} + +public enum DisplayReconnectOrphanReconciliation: Equatable, Sendable { + case reconciled + case alreadyOnline + case liveAttempt + case unavailable +} + +public enum DisplayReconnectQuarantineReconciliation: Equatable, Sendable { + case reconciled + case alreadyOnline + case liveAttempt + case unavailable +} + +public struct DisplayConnectionMutationError: Error, Equatable, Sendable { + public let classification: DisplayConnectionFailureClassification + public let displayUUID: String + public let requestedConnectionState: DisplayConnectionState + public let mutationDispatched: Bool + public let message: String + + public var retrySafe: Bool { classification != .indeterminate } + + public init( + classification: DisplayConnectionFailureClassification, + displayUUID: String, + requestedConnectionState: DisplayConnectionState, + mutationDispatched: Bool? = nil, + message: String + ) { + self.classification = classification + self.displayUUID = displayUUID + self.requestedConnectionState = requestedConnectionState + self.mutationDispatched = mutationDispatched ?? (classification == .indeterminate) + self.message = message + } +} + +@MainActor +public protocol DisplayConnectionMutationAdapter: AnyObject { + func connectionObservation() throws -> DisplayConnectionObservation + func retainDisconnectedRecord( + _ target: DisplayConnectionTarget + ) throws -> DisplayConnectionRecoveryCapability? + func confirmDisconnectedRecord(uuid: String) throws + func removeDisconnectedRecord(uuid: String) throws + func consumeRecoveryCapability( + _ capability: DisplayConnectionRecoveryCapability + ) throws -> DisplayConnectionRecoveryCapability + func reserveReconnect(uuid: String) throws + func releaseReconnectReservation(uuid: String) throws + func rollbackRejectedReconnectBeforeDispatch( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) throws + func reconcileOrphanedReconnectAttempt( + uuid: String + ) throws -> DisplayReconnectOrphanReconciliation + func reconcileQuarantinedReconnectAttempt( + uuid: String + ) async throws -> DisplayReconnectQuarantineReconciliation + func finishQuarantinedReconnectAttempt(uuid: String) + func markReconnectAttemptIndeterminate(uuid: String) throws + func markRecoveryCapabilityIndeterminate(uuid: String) throws + func dispatchConnectionChange( + _ request: DisplayConnectionDispatchRequest + ) async -> DisplayConnectionDispatchOutcome +} + +@MainActor +public struct DisplayConnectionMutationCoordinator { + public typealias Sleep = @Sendable (Duration) async throws -> Void + + private let adapter: any DisplayConnectionMutationAdapter + private let settlementAttempts: Int + private let settlementInterval: Duration + private let sleep: Sleep + + public init( + adapter: any DisplayConnectionMutationAdapter, + settlementAttempts: Int = 20, + settlementInterval: Duration = .milliseconds(100), + sleep: @escaping Sleep = { duration in try await Task.sleep(for: duration) } + ) { + self.adapter = adapter + self.settlementAttempts = max(1, settlementAttempts) + self.settlementInterval = settlementInterval + self.sleep = sleep + } + + public func disconnect(_ target: DisplayConnectionTarget) async throws -> DisplayConnectionSetResult { + let requestedState = DisplayConnectionState.disconnected + let initial = try preflightObservation(uuid: target.uuid, requestedState: requestedState) + try requireStableUUID(target.uuid, requestedState: requestedState) + try require(initial.platformSupported, uuid: target.uuid, state: requestedState, + message: "physical display disconnect requires Apple Silicon and macOS 13 or later") + let exactCandidates = initial.candidates.filter { $0.stableUUID == target.uuid } + try require(exactCandidates.count == 1, uuid: target.uuid, state: requestedState, + message: "target UUID did not resolve to exactly one display") + guard let exactCandidate = exactCandidates.first else { + throw failure( + .preflightRejected, uuid: target.uuid, state: requestedState, + message: "target UUID did not resolve to exactly one display" + ) + } + try require( + target.displayID == nil || target.displayID == exactCandidate.displayID, + uuid: target.uuid, + state: requestedState, + message: "target display ID changed before disconnect" + ) + try require( + target.isHardwareBackedPhysical + && exactCandidate.isHardwareBackedPhysical + && !initial.virtualUUIDs.contains(target.uuid), + uuid: target.uuid, state: requestedState, + message: "target cannot be positively proven as a hardware-backed physical display" + ) + try require( + exactCandidate.isOnline + && initial.allUUIDs.contains(target.uuid) + && initial.onlineUUIDs.contains(target.uuid), + uuid: target.uuid, state: requestedState, + message: "target UUID is no longer present in the fresh online inventory" + ) + try require(!initial.intentionalDisconnectedUUIDs.contains(target.uuid), + uuid: target.uuid, state: requestedState, + message: "target UUID already has an intentional-disconnected record") + try require( + initial.activePhysicalViewableUUIDs.contains(target.uuid) + && initial.activePhysicalViewableUUIDs.count > 1, + uuid: target.uuid, + state: requestedState, + message: "refusing to disconnect the last active physical viewable display" + ) + + let retainedCapabilityCandidate: DisplayConnectionRecoveryCapability? + do { + retainedCapabilityCandidate = try adapter.retainDisconnectedRecord(target) + } catch { + do { + try adapter.removeDisconnectedRecord(uuid: target.uuid) + } catch { + throw failure( + .definiteFailure, uuid: target.uuid, state: requestedState, + message: "recovery state retention failed and partial state could not be cleaned up" + ) + } + throw failure( + .definiteFailure, uuid: target.uuid, state: requestedState, + message: "could not retain UUID-scoped recovery state before mutation" + ) + } + guard let retainedCapability = retainedCapabilityCandidate, + DisplayConnectionRecoveryResolver.authorizesPreparedDisconnectCapability( + retainedCapability, + uuid: target.uuid, + displayID: exactCandidate.displayID, + observation: initial + ) else { + do { + try adapter.removeDisconnectedRecord(uuid: target.uuid) + } catch { + throw failure( + .definiteFailure, uuid: target.uuid, state: requestedState, + message: "incomplete recovery capability could not be cleaned up before mutation" + ) + } + throw failure( + .preflightRejected, uuid: target.uuid, state: requestedState, + message: "disconnect requires a complete persisted recovery capability" + ) + } + + let request = DisplayConnectionDispatchRequest( + uuid: target.uuid, + displayID: exactCandidate.displayID, + requestedState: requestedState, + authorization: .exactUUID + ) + switch await adapter.dispatchConnectionChange(request) { + case .completed: + break + case let .rejectedBeforeDispatch(message): + do { + try adapter.removeDisconnectedRecord(uuid: target.uuid) + } catch { + throw failure( + .definiteFailure, uuid: target.uuid, state: requestedState, + message: "\(message); the recovery record could not be cleaned up" + ) + } + throw failure(.definiteFailure, uuid: target.uuid, state: requestedState, message: message) + case let .failedAfterDispatch(message): + markRecoveryIndeterminate(uuid: target.uuid) + throw failure(.indeterminate, uuid: target.uuid, state: requestedState, message: message) + case .timedOut: + markRecoveryIndeterminate(uuid: target.uuid) + throw failure( + .indeterminate, uuid: target.uuid, state: requestedState, + message: "display configuration timed out after dispatch and may still complete" + ) + case .cancelled: + markRecoveryIndeterminate(uuid: target.uuid) + throw failure( + .indeterminate, uuid: target.uuid, state: requestedState, + message: "display configuration wait was cancelled after dispatch" + ) + } + + for attempt in 0.. DisplayConnectionSetResult { + let requestedState = DisplayConnectionState.connected + try requireStableUUID(uuid, requestedState: requestedState) + let initial = try preflightObservation(uuid: uuid, requestedState: requestedState) + try require(initial.platformSupported, uuid: uuid, state: requestedState, + message: "physical display reconnect requires Apple Silicon and macOS 13 or later") + try require(initial.intentionalDisconnectedUUIDs.contains(uuid), + uuid: uuid, state: requestedState, + message: "UUID is absent from the fresh intentional-disconnected inventory") + + if let result = try await reconcileQuarantineIfNeeded( + uuid: uuid, + state: requestedState, + initial: initial + ) { + return result + } + + return try await reconnectAfterQuarantinePreflight( + uuid: uuid, + state: requestedState, + initial: initial + ) + } + + private func reconnectAfterQuarantinePreflight( + uuid: String, + state requestedState: DisplayConnectionState, + initial: DisplayConnectionObservation + ) async throws -> DisplayConnectionSetResult { + let resolution = DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: uuid, + observation: initial + ) + if initial.reconnectReservationUUIDs.contains(uuid) { + let reconciliation: DisplayReconnectOrphanReconciliation + do { + reconciliation = try adapter.reconcileOrphanedReconnectAttempt(uuid: uuid) + } catch { + throw failure( + .indeterminate, uuid: uuid, state: requestedState, + mutationDispatched: false, + message: "the prior reconnect attempt's display-write outcome is unknown; " + + "fresh read-back and persisted recovery reconciliation could not be " + + "verified, so no display write was issued" + ) + } + switch reconciliation { + case .reconciled: + throw failure( + .indeterminate, uuid: uuid, state: requestedState, + mutationDispatched: false, + message: "the prior reconnect attempt's display-write outcome is unknown; " + + "fresh read-back still proves this display offline, so no display write " + + "was issued and a fresh explicit user decision is required before " + + "another reconnect" + ) + case .alreadyOnline: + return try cleanAlreadyOnlineRecoveryState(uuid: uuid, state: requestedState) + case .liveAttempt: + throw failure( + .preflightRejected, uuid: uuid, state: requestedState, + message: "another reconnect attempt still owns the persisted reservation" + ) + case .unavailable: + throw failure( + .indeterminate, uuid: uuid, state: requestedState, + mutationDispatched: false, + message: "the prior reconnect attempt's display-write outcome remains unknown; " + + "fresh exact-UUID or recovery-continuity proof was insufficient, so " + + "the reservation was retained and no display write was issued" + ) + } + } + if case .alreadyOnline = resolution { + return try cleanAlreadyOnlineRecoveryState(uuid: uuid, state: requestedState) + } + + do { + try adapter.reserveReconnect(uuid: uuid) + } catch { + throw failure( + .preflightRejected, uuid: uuid, state: requestedState, + message: "another reconnect attempt is already reserved or reservation could not persist" + ) + } + + let dispatchPlan: ( + request: DisplayConnectionDispatchRequest, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) + do { + dispatchPlan = try reconnectDispatchPlan( + uuid: uuid, + state: requestedState, + resolution: resolution + ) + } catch { + try releaseReconnectReservationAndVerify(uuid: uuid, state: requestedState) + throw error + } + try requireReconnectDispatchCompletion( + await adapter.dispatchConnectionChange(dispatchPlan.request), + uuid: uuid, + state: requestedState, + consumedRecoveryCapability: dispatchPlan.consumedRecoveryCapability + ) + + for attempt in 0.. DisplayConnectionSetResult? { + guard initial.reconnectPersistenceUncertainUUIDs.contains(uuid) else { return nil } + let reconciliation: DisplayReconnectQuarantineReconciliation + do { + reconciliation = try await adapter.reconcileQuarantinedReconnectAttempt(uuid: uuid) + } catch { + adapter.finishQuarantinedReconnectAttempt(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "quarantined reconnect recovery could not be atomically persisted " + + "and verified, so no display write was issued" + ) + } + switch reconciliation { + case .reconciled: + adapter.finishQuarantinedReconnectAttempt(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "fresh read-back still proves this quarantined display offline; " + + "recovery metadata was reconciled with zero display writes, and a fresh " + + "explicit user decision is required before another reconnect" + ) + case .alreadyOnline: + defer { adapter.finishQuarantinedReconnectAttempt(uuid: uuid) } + let final: DisplayConnectionObservation + do { + try Task.checkCancellation() + final = try adapter.connectionObservation() + } catch is CancellationError { + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "metadata cleanup occurred but the final exact-online proof or " + + "decision was cancelled, so no display write was issued" + ) + } catch { + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "metadata cleanup occurred but final exact-online proof could not " + + "be verified, so no display write was issued" + ) + } + guard DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate( + uuid: uuid, + observation: final + ) != nil, recoveryStateIsAbsent(uuid: uuid, observation: final) else { + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "metadata cleanup occurred but final exact-online proof could not " + + "be verified, so no display write was issued" + ) + } + do { + try Task.checkCancellation() + } catch { + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "metadata cleanup occurred but the final exact-online proof or " + + "decision was cancelled, so no display write was issued" + ) + } + return success(uuid: uuid, state: state) + case .liveAttempt: + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "another explicit request is reconciling this quarantined reconnect; " + + "no display write was issued" + ) + case .unavailable: + adapter.finishQuarantinedReconnectAttempt(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "quarantined reconnect recovery lacks fresh exact-UUID or strict " + + "same-session hardware proof; quarantine was retained and no display " + + "write was issued" + ) + } + } + + private func reconnectDispatchPlan( + uuid: String, + state: DisplayConnectionState, + resolution: DisplayConnectionReconnectResolution + ) throws -> ( + request: DisplayConnectionDispatchRequest, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) { + switch resolution { + case .alreadyOnline: + preconditionFailure("already-online recovery should have returned") + case let .exactUUID(displayID): + return ( + DisplayConnectionDispatchRequest( + uuid: uuid, + displayID: displayID, + requestedState: state, + authorization: .exactUUID + ), + nil + ) + case let .oneShotRecovery(capability): + let consumed: DisplayConnectionRecoveryCapability + do { + consumed = try adapter.consumeRecoveryCapability(capability) + } catch { + throw failure( + .preflightRejected, uuid: uuid, state: state, + message: "one-shot recovery capability could not be consumed and verified" + ) + } + try require( + consumed == capability.changingState(to: .consumed), + uuid: uuid, + state: state, + message: "one-shot recovery capability consumption did not persist exactly" + ) + return ( + DisplayConnectionDispatchRequest( + uuid: uuid, + displayID: capability.displayID, + requestedState: state, + authorization: .oneShotRecovery + ), + consumed + ) + case .unavailable: + throw failure( + .preflightRejected, uuid: uuid, state: state, + message: "disconnected UUID has no unique safe reconnect resolution" + ) + } + } + + private func requireReconnectDispatchCompletion( + _ outcome: DisplayConnectionDispatchOutcome, + uuid: String, + state: DisplayConnectionState, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) throws { + switch outcome { + case .completed: + return + case let .rejectedBeforeDispatch(message): + do { + try adapter.rollbackRejectedReconnectBeforeDispatch( + uuid: uuid, + consumedRecoveryCapability: consumedRecoveryCapability + ) + } catch { + throw failure( + .indeterminate, + uuid: uuid, + state: state, + mutationDispatched: false, + message: "\(message); zero display dispatch is known but the reconnect " + + "reservation rollback could not be atomically persisted and verified" + ) + } + throw failure(.definiteFailure, uuid: uuid, state: state, message: message) + case let .failedAfterDispatch(message): + markReconnectIndeterminate(uuid: uuid) + throw failure(.indeterminate, uuid: uuid, state: state, message: message) + case .timedOut: + markReconnectIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "display configuration timed out after dispatch and may still complete" + ) + case .cancelled: + markReconnectIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "display configuration wait was cancelled after dispatch" + ) + } + } + + private func cleanAlreadyOnlineRecoveryState( + uuid: String, + state: DisplayConnectionState + ) throws -> DisplayConnectionSetResult { + do { + try adapter.removeDisconnectedRecord(uuid: uuid) + let final = try adapter.connectionObservation() + guard DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate( + uuid: uuid, + observation: final + ) != nil, recoveryStateIsAbsent(uuid: uuid, observation: final) else { + throw failure( + .definiteFailure, uuid: uuid, state: state, + message: "online UUID recovery-state cleanup could not be verified" + ) + } + return success(uuid: uuid, state: state) + } catch let error as DisplayConnectionMutationError { + throw error + } catch { + throw failure( + .definiteFailure, uuid: uuid, state: state, + message: "online UUID recovery state could not be removed" + ) + } + } + + private func recoveryStateIsAbsent( + uuid: String, + observation: DisplayConnectionObservation + ) -> Bool { + !observation.intentionalDisconnectedUUIDs.contains(uuid) + && !observation.pendingDisconnectUUIDs.contains(uuid) + && !observation.reconnectReservationUUIDs.contains(uuid) + && !observation.reconnectPersistenceUncertainUUIDs.contains(uuid) + && !observation.recoveryCapabilities.contains { $0.uuid == uuid } + } + + private func releaseReconnectReservationAndVerify( + uuid: String, + state: DisplayConnectionState + ) throws { + let observation: DisplayConnectionObservation + do { + try adapter.releaseReconnectReservation(uuid: uuid) + observation = try adapter.connectionObservation() + } catch { + markReconnectIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "zero display dispatch is known but reconnect reservation release could not be verified" + ) + } + guard !observation.reconnectReservationUUIDs.contains(uuid) else { + throw failure( + .indeterminate, uuid: uuid, state: state, + mutationDispatched: false, + message: "zero display dispatch is known but reconnect reservation remained persisted" + ) + } + } + + private func preflightObservation( + uuid: String, + requestedState: DisplayConnectionState + ) throws -> DisplayConnectionObservation { + do { + try Task.checkCancellation() + return try adapter.connectionObservation() + } catch { + throw failure( + .preflightRejected, uuid: uuid, state: requestedState, + message: "fresh display enumeration failed before mutation" + ) + } + } + + private func postDispatchObservation( + uuid: String, + state: DisplayConnectionState + ) throws -> DisplayConnectionObservation { + do { + try Task.checkCancellation() + return try adapter.connectionObservation() + } catch { + markRecoveryIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "fresh display enumeration could not prove the post-mutation state" + ) + } + } + + private func postReconnectObservation( + uuid: String, + state: DisplayConnectionState + ) throws -> DisplayConnectionObservation { + do { + try Task.checkCancellation() + return try adapter.connectionObservation() + } catch { + markReconnectIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "fresh display enumeration could not prove the post-mutation state" + ) + } + } + + private func pauseIfNeeded( + after attempt: Int, + uuid: String, + state: DisplayConnectionState + ) async throws { + guard attempt + 1 < settlementAttempts else { return } + do { + try await sleep(settlementInterval) + } catch { + markRecoveryIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "post-mutation settlement wait was cancelled" + ) + } + } + + private func pauseAfterReconnectIfNeeded( + after attempt: Int, + uuid: String, + state: DisplayConnectionState + ) async throws { + guard attempt + 1 < settlementAttempts else { return } + do { + try await sleep(settlementInterval) + } catch { + markReconnectIndeterminate(uuid: uuid) + throw failure( + .indeterminate, uuid: uuid, state: state, + message: "post-mutation settlement wait was cancelled" + ) + } + } + + private func requireStableUUID( + _ uuid: String, + requestedState: DisplayConnectionState + ) throws { + let characters = Array(uuid) + let exactShape = uuid.utf8.count == 36 + && UUID(uuidString: uuid) != nil + && [8, 13, 18, 23].allSatisfy { characters.indices.contains($0) && characters[$0] == "-" } + try require( + exactShape, + uuid: uuid, + state: requestedState, + message: "an exact stable UUID from a fresh inventory is required" + ) + } + + private func require( + _ condition: @autoclosure () -> Bool, + uuid: String, + state: DisplayConnectionState, + message: String + ) throws { + guard condition() else { + throw failure(.preflightRejected, uuid: uuid, state: state, message: message) + } + } + + private func success( + uuid: String, + state: DisplayConnectionState, + verification: DisplayConnectionVerificationQuality = .sameUUIDEnumeration + ) -> DisplayConnectionSetResult { + DisplayConnectionSetResult( + displayUUID: uuid, + requestedConnectionState: state, + observedConnectionState: state, + verification: verification + ) + } + + private func markRecoveryIndeterminate(uuid: String) { + try? adapter.markRecoveryCapabilityIndeterminate(uuid: uuid) + } + + private func markReconnectIndeterminate(uuid: String) { + try? adapter.markReconnectAttemptIndeterminate(uuid: uuid) + } + + private func failure( + _ classification: DisplayConnectionFailureClassification, + uuid: String, + state: DisplayConnectionState, + mutationDispatched: Bool? = nil, + message: String + ) -> DisplayConnectionMutationError { + DisplayConnectionMutationError( + classification: classification, + displayUUID: uuid, + requestedConnectionState: state, + mutationDispatched: mutationDispatched, + message: message + ) + } +} diff --git a/Sources/CrispControlCore/DisplayConnectionPersistence.swift b/Sources/CrispControlCore/DisplayConnectionPersistence.swift new file mode 100644 index 0000000..fc8291d --- /dev/null +++ b/Sources/CrispControlCore/DisplayConnectionPersistence.swift @@ -0,0 +1,480 @@ +import Foundation + +public enum DisplayConnectionPersistenceError: Error, Equatable, Sendable { + case missing + case corrupt + case invalidState + case encodingFailed +} + +public enum ConnectionPersistenceAuthority: Equatable, Sendable { + case durable + case syntheticQuarantine +} + +public struct DisplayConnectionPersistenceSnapshot: Equatable, Sendable { + public let envelope: DisplayConnectionPersistenceEnvelope + public let authority: ConnectionPersistenceAuthority + + public init( + envelope: DisplayConnectionPersistenceEnvelope, + authority: ConnectionPersistenceAuthority + ) { + self.envelope = envelope + self.authority = authority + } + + public var authorizesConnectionMutation: Bool { + authority == .durable && envelope.reconnectPersistenceUncertainSet.isEmpty + } +} + +public enum ConnectionPersistenceDisposition: Equatable, Sendable { + case committedProposed + case preservedOld + case quarantined +} + +public struct DisplayConnectionPersistenceWriteResult: Equatable, Sendable { + public let disposition: ConnectionPersistenceDisposition + public let snapshot: DisplayConnectionPersistenceSnapshot + + public init( + disposition: ConnectionPersistenceDisposition, + snapshot: DisplayConnectionPersistenceSnapshot + ) { + self.disposition = disposition + self.snapshot = snapshot + } +} + +/// A single-value persistence boundary. Replacement and read-back are deliberately modeled as +/// separate operations: the backing store is not assumed to be transactional. +public final class DisplayConnectionPersistenceBoundary { + public typealias Read = () -> Data? + public typealias Write = (Data) -> Void + + private enum DecodedRead { + case envelope(DisplayConnectionPersistenceEnvelope) + case missing + case corrupt + } + + private struct RecoveryContext { + let oldState: DisplayConnectionPersistenceEnvelope + let proposedState: DisplayConnectionPersistenceEnvelope + let quarantineState: DisplayConnectionPersistenceEnvelope + } + + private let read: Read + private let write: Write + private var recoveryContext: RecoveryContext? + public private(set) var publishedRecords: [DisplayConnectionPersistedRecord] + + public init( + initialPublishedRecords: [DisplayConnectionPersistedRecord] = [], + read: @escaping Read, + write: @escaping Write + ) { + publishedRecords = initialPublishedRecords + self.read = read + self.write = write + } + + public func adoptPublishedRecords(from snapshot: DisplayConnectionPersistenceSnapshot) { + publishedRecords = snapshot.envelope.records + if snapshot.authority == .durable, let recoveryContext, + snapshot.envelope == recoveryContext.oldState + || snapshot.envelope == recoveryContext.proposedState + || snapshot.envelope == recoveryContext.quarantineState { + self.recoveryContext = nil + } + } + + /// Reads the one authoritative value exactly once. If a prior compensation could not be + /// verified, only a known old/proposed/quarantine value can replace the synthetic quarantine. + public func snapshot() throws -> DisplayConnectionPersistenceSnapshot { + let decoded = decodeOneRead() + if let recoveryContext { + switch decoded { + case let .envelope(envelope) where envelope == recoveryContext.oldState + || envelope == recoveryContext.proposedState + || envelope == recoveryContext.quarantineState: + return durableSnapshot(envelope) + case .envelope, .missing, .corrupt: + return DisplayConnectionPersistenceSnapshot( + envelope: recoveryContext.quarantineState, + authority: .syntheticQuarantine + ) + } + } + switch decoded { + case let .envelope(envelope): + return durableSnapshot(envelope) + case .missing: + throw DisplayConnectionPersistenceError.missing + case .corrupt: + throw DisplayConnectionPersistenceError.corrupt + } + } + + /// Writes the proposal once and classifies its single read-back. Missing, corrupt, or + /// unexpected valid read-back triggers one explicit compensation write of a fail-closed + /// quarantine envelope. The normal success path remains one write. + public func replace( + oldState: DisplayConnectionPersistenceEnvelope, + proposedState: DisplayConnectionPersistenceEnvelope, + quarantiningUUIDs: Set + ) throws -> DisplayConnectionPersistenceWriteResult { + let baseQuarantineState = try makeQuarantine( + oldState: oldState, + proposedState: proposedState, + uncertainUUIDs: quarantiningUUIDs + ) + try writeEnvelope(proposedState) + let quarantineState: DisplayConnectionPersistenceEnvelope + switch decodeOneRead() { + case let .envelope(readBack) where readBack == proposedState: + recoveryContext = nil + return result(.committedProposed, envelope: readBack) + case let .envelope(readBack) where readBack == oldState: + recoveryContext = nil + return result(.preservedOld, envelope: readBack) + case let .envelope(readBack): + quarantineState = try makeQuarantine( + oldState: oldState, + proposedState: proposedState, + observedState: readBack, + uncertainUUIDs: quarantiningUUIDs + ) + case .missing, .corrupt: + quarantineState = baseQuarantineState + } + + try writeEnvelope(quarantineState) + switch decodeOneRead() { + case let .envelope(readBack) where readBack == proposedState: + recoveryContext = nil + return result(.committedProposed, envelope: readBack) + case let .envelope(readBack) where readBack == oldState: + recoveryContext = nil + return result(.preservedOld, envelope: readBack) + case let .envelope(readBack) where readBack == quarantineState: + recoveryContext = nil + return result(.quarantined, envelope: readBack) + case .envelope, .missing, .corrupt: + recoveryContext = RecoveryContext( + oldState: oldState, + proposedState: proposedState, + quarantineState: quarantineState + ) + publishedRecords = quarantineState.records + return DisplayConnectionPersistenceWriteResult( + disposition: .quarantined, + snapshot: DisplayConnectionPersistenceSnapshot( + envelope: quarantineState, + authority: .syntheticQuarantine + ) + ) + } + } + + public func reconcileTopologyMetadata( + snapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation? + ) throws -> DisplayConnectionPersistenceWriteResult? { + guard let observation, + let transition = ConnectionMetadataReconciler.transition( + snapshot: snapshot, + observation: observation + ) else { return nil } + return try replace( + oldState: snapshot.envelope, + proposedState: transition.proposedState, + quarantiningUUIDs: transition.affectedUUIDs + ) + } + + public func reconcileQuarantinedReconnect( + uuid: String, + snapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation + ) throws -> QuarantineReconnectPersistenceResult? { + guard let transition = DisplayReconnectQuarantineReconciler.transition( + uuid: uuid, + snapshot: snapshot, + observation: observation + ) else { return nil } + let writeResult = try replace( + oldState: snapshot.envelope, + proposedState: transition.proposedState, + quarantiningUUIDs: [uuid] + ) + return QuarantineReconnectPersistenceResult( + kind: transition.kind, + writeResult: writeResult + ) + } + + private func decodeOneRead() -> DecodedRead { + guard let data = read() else { return .missing } + guard let envelope = try? JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: data + ) else { return .corrupt } + return .envelope(envelope) + } + + private func writeEnvelope(_ envelope: DisplayConnectionPersistenceEnvelope) throws { + guard let data = try? JSONEncoder().encode(envelope) else { + throw DisplayConnectionPersistenceError.encodingFailed + } + write(data) + } + + private func makeQuarantine( + oldState: DisplayConnectionPersistenceEnvelope, + proposedState: DisplayConnectionPersistenceEnvelope, + observedState: DisplayConnectionPersistenceEnvelope? = nil, + uncertainUUIDs: Set + ) throws -> DisplayConnectionPersistenceEnvelope { + var records = oldState.records + var recordUUIDs = Set(records.map(\.uuid)) + for state in [proposedState, observedState].compactMap({ $0 }) { + for record in state.records where !recordUUIDs.contains(record.uuid) { + records.append(record) + recordUUIDs.insert(record.uuid) + } + } + guard uncertainUUIDs.isSubset(of: recordUUIDs) else { + throw DisplayConnectionPersistenceError.invalidState + } + let observedPending = observedState?.pendingSet ?? [] + let observedReservations = observedState?.reconnectReservationSet ?? [] + let observedUncertain = observedState?.reconnectPersistenceUncertainSet ?? [] + return DisplayConnectionPersistenceEnvelope( + records: records, + pendingUUIDs: oldState.pendingSet + .union(proposedState.pendingSet) + .union(observedPending), + reconnectReservationUUIDs: oldState.reconnectReservationSet.union( + proposedState.reconnectReservationSet + ).union(observedReservations), + reconnectPersistenceUncertainUUIDs: oldState.reconnectPersistenceUncertainSet + .union(proposedState.reconnectPersistenceUncertainSet) + .union(observedUncertain) + .union(uncertainUUIDs) + ) + } + + private func durableSnapshot( + _ envelope: DisplayConnectionPersistenceEnvelope + ) -> DisplayConnectionPersistenceSnapshot { + DisplayConnectionPersistenceSnapshot(envelope: envelope, authority: .durable) + } + + private func result( + _ disposition: ConnectionPersistenceDisposition, + envelope: DisplayConnectionPersistenceEnvelope + ) -> DisplayConnectionPersistenceWriteResult { + publishedRecords = envelope.records + return DisplayConnectionPersistenceWriteResult( + disposition: disposition, + snapshot: durableSnapshot(envelope) + ) + } +} + +public enum DisplayReconnectQuarantineTransitionKind: Equatable, Sendable { + case reconciledOffline + case alreadyOnline +} + +public struct DisplayReconnectQuarantineTransition: Equatable, Sendable { + public let kind: DisplayReconnectQuarantineTransitionKind + public let proposedState: DisplayConnectionPersistenceEnvelope + + public init( + kind: DisplayReconnectQuarantineTransitionKind, + proposedState: DisplayConnectionPersistenceEnvelope + ) { + self.kind = kind + self.proposedState = proposedState + } +} + +public struct QuarantineReconnectPersistenceResult: Equatable, Sendable { + public let kind: DisplayReconnectQuarantineTransitionKind + public let writeResult: DisplayConnectionPersistenceWriteResult + + public init( + kind: DisplayReconnectQuarantineTransitionKind, + writeResult: DisplayConnectionPersistenceWriteResult + ) { + self.kind = kind + self.writeResult = writeResult + } +} + +public enum DisplayReconnectQuarantineReconciler { + public static func transition( + uuid: String, + snapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation + ) -> DisplayReconnectQuarantineTransition? { + let current = snapshot.envelope + let matches = current.records.indices.filter { current.records[$0].uuid == uuid } + guard matches.count == 1, let recordIndex = matches.first, + current.reconnectPersistenceUncertainSet.contains(uuid), + DisplayConnectionRecoveryResolver.persistenceProjectionMatches( + snapshot: snapshot, + observation: observation + ), + DisplayConnectionRecoveryResolver.inventoryIsConsistent(observation) else { + return nil + } + + var records = current.records + let kind: DisplayReconnectQuarantineTransitionKind + switch DisplayConnectionRecoveryResolver.quarantinedReconnectResolution( + uuid: uuid, + observation: observation + ) { + case .alreadyOnline: + records.remove(at: recordIndex) + kind = .alreadyOnline + case let .exactUUID(displayID): + records[recordIndex].displayID = displayID + if let restorable = DisplayConnectionRecoveryResolver + .restorableRecoveryCapabilityForExactQuarantine( + uuid: uuid, + observation: observation + ) { + records[recordIndex].recoveryCapability = restorable.changingState(to: .available) + } else { + records[recordIndex].recoveryCapability = nil + } + kind = .reconciledOffline + case let .oneShotRecovery(capability): + guard records[recordIndex].recoveryCapability == capability else { return nil } + records[recordIndex].recoveryCapability = capability.changingState(to: .available) + kind = .reconciledOffline + case .unavailable: + return nil + } + + var pending = current.pendingSet + pending.remove(uuid) + var reservations = current.reconnectReservationSet + reservations.remove(uuid) + var uncertain = current.reconnectPersistenceUncertainSet + uncertain.remove(uuid) + return DisplayReconnectQuarantineTransition( + kind: kind, + proposedState: DisplayConnectionPersistenceEnvelope( + records: records, + pendingUUIDs: pending, + reconnectReservationUUIDs: reservations, + reconnectPersistenceUncertainUUIDs: uncertain + ) + ) + } +} + +public struct ConnectionMetadataTransition: Equatable, Sendable { + public let proposedState: DisplayConnectionPersistenceEnvelope + public let affectedUUIDs: Set + + public init( + proposedState: DisplayConnectionPersistenceEnvelope, + affectedUUIDs: Set + ) { + self.proposedState = proposedState + self.affectedUUIDs = affectedUUIDs + } +} + +public enum ConnectionMetadataReconciler { + public static func transition( + snapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation + ) -> ConnectionMetadataTransition? { + let current = snapshot.envelope + guard snapshot.authority == .durable, + observation.persistenceStateIsAuthoritative, + persistenceProjectionMatches(current, observation: observation), + DisplayConnectionRecoveryResolver.inventoryIsConsistent(observation) else { + return nil + } + + var records = current.records + var pending = current.pendingSet + var reservations = current.reconnectReservationSet + var uncertain = current.reconnectPersistenceUncertainSet + var removedUUIDs: Set = [] + var confirmedUUIDs: Set = [] + + for record in current.records { + if DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate( + uuid: record.uuid, + observation: observation + ) != nil { + removedUUIDs.insert(record.uuid) + continue + } + guard pending.contains(record.uuid), + !uncertain.contains(record.uuid) else { continue } + let exactCandidates = observation.candidates.filter { + $0.stableUUID == record.uuid + } + let exactOffline = exactCandidates.count == 1 + && exactCandidates[0].isHardwareBackedPhysical + && !exactCandidates[0].isOnline + let retainedOffline = record.recoveryCapability.map { + DisplayConnectionRecoveryResolver.authorizesOfflineConfirmation( + $0, + observation: observation + ) + } ?? false + if exactOffline || retainedOffline { + confirmedUUIDs.insert(record.uuid) + } + } + + guard !removedUUIDs.isEmpty || !confirmedUUIDs.isEmpty else { return nil } + records.removeAll { removedUUIDs.contains($0.uuid) } + pending.subtract(removedUUIDs) + reservations.subtract(removedUUIDs) + uncertain.subtract(removedUUIDs) + + for index in records.indices where confirmedUUIDs.contains(records[index].uuid) { + pending.remove(records[index].uuid) + if let capability = records[index].recoveryCapability, + capability.state == .prepared { + records[index].recoveryCapability = capability.changingState(to: .available) + } + } + return ConnectionMetadataTransition( + proposedState: DisplayConnectionPersistenceEnvelope( + records: records, + pendingUUIDs: pending, + reconnectReservationUUIDs: reservations, + reconnectPersistenceUncertainUUIDs: uncertain + ), + affectedUUIDs: removedUUIDs.union(confirmedUUIDs) + ) + } + + private static func persistenceProjectionMatches( + _ envelope: DisplayConnectionPersistenceEnvelope, + observation: DisplayConnectionObservation + ) -> Bool { + Set(envelope.records.map(\.uuid)) == observation.intentionalDisconnectedUUIDs + && envelope.pendingSet == observation.pendingDisconnectUUIDs + && envelope.reconnectReservationSet == observation.reconnectReservationUUIDs + && envelope.reconnectPersistenceUncertainSet + == observation.reconnectPersistenceUncertainUUIDs + && envelope.records.compactMap(\.recoveryCapability) + == observation.recoveryCapabilities + } +} diff --git a/Sources/CrispControlCore/DisplayConnectionReadOnlyQueries.swift b/Sources/CrispControlCore/DisplayConnectionReadOnlyQueries.swift new file mode 100644 index 0000000..72dc027 --- /dev/null +++ b/Sources/CrispControlCore/DisplayConnectionReadOnlyQueries.swift @@ -0,0 +1,284 @@ +import Foundation + +public struct DisplayConnectionCapabilitySubject: Equatable, Sendable { + public let uuid: String + public let staticUnsupportedCapability: DisplayConnectionCapability? + + public init( + uuid: String, + staticUnsupportedCapability: DisplayConnectionCapability? = nil + ) { + self.uuid = uuid + self.staticUnsupportedCapability = staticUnsupportedCapability + } +} + +public enum DisplayConnectionReadOnlyQueries { + public static func connectedCapabilities( + subjects: [DisplayConnectionCapabilitySubject], + loadSnapshot: () throws -> DisplayConnectionPersistenceSnapshot, + buildObservation: ( + DisplayConnectionPersistenceSnapshot + ) throws -> DisplayConnectionObservation + ) -> [String: DisplayConnectionCapability] { + let snapshot: DisplayConnectionPersistenceSnapshot + do { + snapshot = try loadSnapshot() + } catch { + return connectedCapabilities( + subjects: subjects, + observation: nil, + unavailableReason: "persisted connection recovery state is unavailable or corrupt", + unavailableRemediation: "repair persistence before making a connection change" + ) + } + + let observation: DisplayConnectionObservation + do { + observation = try buildObservation(snapshot) + } catch { + return connectedCapabilities( + subjects: subjects, + observation: nil, + unavailableReason: "fresh WindowServer enumeration is unavailable", + unavailableRemediation: + "refresh displays and retry only after capability becomes writable" + ) + } + + return connectedCapabilities( + subjects: subjects, + observation: observation, + unavailableReason: "", + unavailableRemediation: "" + ) + } + + public static func connectedCapability( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionCapability { + let connected = observation.onlineUUIDs.contains(uuid) + guard observation.persistenceStateIsAuthoritative else { + return .unsupported( + connected: connected, + platformSupported: observation.platformSupported, + reason: "persisted connection recovery state is unavailable or corrupt", + remediation: "repair persistence before making a connection change" + ) + } + guard observation.platformSupported else { + return .unsupported( + connected: connected, + reason: "physical display disconnect requires Apple Silicon and macOS 13 or later", + remediation: "use a supported Apple Silicon Mac or leave connection changes to the GUI" + ) + } + if observation.reconnectPersistenceUncertainUUIDs.contains(uuid) { + return .unsupported( + connected: connected, + platformSupported: true, + reason: "connection recovery persistence is quarantined after an unverified write", + remediation: "wait for a topology event or make a fresh explicit recovery decision" + ) + } + if observation.pendingDisconnectUUIDs.contains(uuid) { + return .unsupported( + connected: connected, + platformSupported: true, + reason: "the disconnect outcome is indeterminate and may still change", + remediation: "wait for topology reconciliation; never retry the write automatically" + ) + } + if observation.reconnectReservationUUIDs.contains(uuid) { + return .unsupported( + connected: connected, + platformSupported: true, + reason: "a prior reconnect attempt still requires explicit reconciliation", + remediation: "use an explicit reconnect request; inventory queries do not reconcile" + ) + } + if observation.intentionalDisconnectedUUIDs.contains(uuid) { + return .unsupported( + connected: connected, + platformSupported: true, + reason: "stale recovery metadata awaits topology-event reconciliation", + remediation: "refresh the display topology before making a connection change" + ) + } + guard DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate( + uuid: uuid, + observation: observation + ) != nil else { + return .unsupported( + connected: connected, + platformSupported: true, + reason: "the exact UUID is absent from the fresh online inventory", + remediation: "run displays list again and make a fresh decision" + ) + } + guard observation.activePhysicalViewableUUIDs.contains(uuid), + observation.activePhysicalViewableUUIDs.count > 1 else { + return .unsupported( + connected: true, + platformSupported: true, + reason: "disconnect would leave no active physical viewable display", + remediation: "connect another physical display before disconnecting this one" + ) + } + return DisplayConnectionCapability( + state: .writable, + connected: true, + disconnectAllowed: true, + reconnectAllowed: false, + platformSupported: true, + reason: "another active physical viewable display remains" + ) + } + + public static func disconnectedDisplays( + persistenceSnapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation + ) -> [ControlDisconnectedDisplay] { + let envelope = persistenceSnapshot.envelope + let snapshotMatchesObservation = + Set(envelope.records.map(\.uuid)) == observation.intentionalDisconnectedUUIDs + && envelope.pendingSet == observation.pendingDisconnectUUIDs + && envelope.reconnectReservationSet == observation.reconnectReservationUUIDs + && envelope.reconnectPersistenceUncertainSet + == observation.reconnectPersistenceUncertainUUIDs + && envelope.records.compactMap(\.recoveryCapability) + == observation.recoveryCapabilities + + return envelope.records.compactMap { record in + guard ControlRequest.isExactDisplayUUID(record.uuid) else { return nil } + let connected = observation.onlineUUIDs.contains(record.uuid) + let capability: DisplayConnectionCapability + if !snapshotMatchesObservation || !observation.persistenceStateIsAuthoritative { + capability = .unsupported( + connected: connected, + platformSupported: observation.platformSupported, + reason: "one authoritative recovery snapshot is unavailable", + remediation: "refresh after persistence becomes readable" + ) + } else if envelope.reconnectPersistenceUncertainSet.contains(record.uuid) { + capability = .unsupported( + connected: connected, + platformSupported: observation.platformSupported, + reason: "connection recovery persistence is quarantined after an unverified write", + remediation: "use topology reconciliation or a fresh explicit recovery decision" + ) + } else if envelope.pendingSet.contains(record.uuid) { + capability = .unsupported( + connected: connected, + platformSupported: observation.platformSupported, + reason: "the disconnect outcome is indeterminate and may still change", + remediation: "wait for topology reconciliation; never retry the write automatically" + ) + } else if envelope.reconnectReservationSet.contains(record.uuid) { + capability = .unsupported( + connected: connected, + platformSupported: observation.platformSupported, + reason: "a prior reconnect attempt still requires explicit reconciliation", + remediation: "use an explicit reconnect request; inventory queries do not reconcile" + ) + } else if !observation.platformSupported { + capability = .unsupported( + connected: connected, + reason: "physical display reconnect requires Apple Silicon and macOS 13 or later", + remediation: "use a supported Apple Silicon Mac" + ) + } else { + capability = reconnectCapability(record: record, observation: observation) + } + return ControlDisconnectedDisplay( + uuid: record.uuid, + name: record.name, + width: record.width, + height: record.height, + connection: capability + ) + }.sorted { $0.uuid < $1.uuid } + } + + private static func connectedCapabilities( + subjects: [DisplayConnectionCapabilitySubject], + observation: DisplayConnectionObservation?, + unavailableReason: String, + unavailableRemediation: String + ) -> [String: DisplayConnectionCapability] { + let duplicateUUIDs = Set( + Dictionary(grouping: subjects, by: \.uuid).compactMap { uuid, matches in + matches.count > 1 ? uuid : nil + } + ) + var capabilities: [String: DisplayConnectionCapability] = [:] + for subject in subjects { + let capability: DisplayConnectionCapability + if duplicateUUIDs.contains(subject.uuid) { + capability = .unsupported( + connected: true, + platformSupported: observation?.platformSupported ?? true, + reason: "the connected display UUID is not unique", + remediation: "refresh displays after reconnecting the physical cable" + ) + } else if let staticUnsupported = subject.staticUnsupportedCapability { + capability = staticUnsupported + } else if let observation { + capability = connectedCapability(uuid: subject.uuid, observation: observation) + } else { + capability = .unsupported( + connected: true, + platformSupported: true, + reason: unavailableReason, + remediation: unavailableRemediation + ) + } + capabilities[subject.uuid] = capability + } + return capabilities + } + + private static func reconnectCapability( + record: DisplayConnectionPersistedRecord, + observation: DisplayConnectionObservation + ) -> DisplayConnectionCapability { + switch DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: record.uuid, + observation: observation + ) { + case .exactUUID: + return DisplayConnectionCapability( + state: .writable, + connected: false, + disconnectAllowed: false, + reconnectAllowed: true, + platformSupported: true, + reason: "exact UUID is uniquely resolved and hardware-backed" + ) + case .oneShotRecovery: + return DisplayConnectionCapability( + state: .writable, + connected: false, + disconnectAllowed: false, + reconnectAllowed: true, + platformSupported: true, + reason: "same-session one-shot recovery capability is available" + ) + case .alreadyOnline: + return .unsupported( + connected: true, + platformSupported: true, + reason: "stale online recovery metadata awaits topology-event reconciliation", + remediation: "refresh the display topology" + ) + case .unavailable: + return .unsupported( + connected: false, + platformSupported: true, + reason: "the disconnected display cannot be resolved without ambiguity", + remediation: "check session, wake, and topology continuity, then refresh" + ) + } + } +} diff --git a/Sources/CrispControlCore/DisplayConnectionRecovery.swift b/Sources/CrispControlCore/DisplayConnectionRecovery.swift new file mode 100644 index 0000000..50c6f65 --- /dev/null +++ b/Sources/CrispControlCore/DisplayConnectionRecovery.swift @@ -0,0 +1,852 @@ +import Foundation + +public enum DisplayConnectionMachSleepOffsetToken { + /// A tight clock sandwich should finish well below this bound. Longer samples fail closed + /// because they may have crossed a suspend or scheduler stall. + public static let maximumSamplingIntervalNanoseconds: UInt64 = 1_000_000 + /// Two milliseconds covers only the bounded call-order estimate error. A real sleep/wake + /// cycle changes the continuous-minus-absolute offset by far more and invalidates fallback. + public static let matchingToleranceNanoseconds: UInt64 = 2_000_000 + + private static let prefix = "mach-sleep-offset-v1:" + + public static func make( + continuousBeforeTicks: UInt64, + absoluteTicks: UInt64, + continuousAfterTicks: UInt64, + timebaseNumerator: UInt32, + timebaseDenominator: UInt32 + ) -> String? { + guard continuousAfterTicks >= continuousBeforeTicks else { return nil } + let samplingTicks = continuousAfterTicks - continuousBeforeTicks + guard let samplingNanoseconds = nanoseconds( + fromTicks: samplingTicks, + numerator: timebaseNumerator, + denominator: timebaseDenominator + ), samplingNanoseconds <= maximumSamplingIntervalNanoseconds else { return nil } + + let continuousMidpoint = continuousBeforeTicks + samplingTicks / 2 + let offsetTicks: UInt64 + if continuousMidpoint >= absoluteTicks { + offsetTicks = continuousMidpoint - absoluteTicks + } else { + let negativeJitterTicks = absoluteTicks - continuousMidpoint + guard let negativeJitterNanoseconds = nanoseconds( + fromTicks: negativeJitterTicks, + numerator: timebaseNumerator, + denominator: timebaseDenominator + ), negativeJitterNanoseconds <= maximumSamplingIntervalNanoseconds else { return nil } + offsetTicks = 0 + } + guard let offsetNanoseconds = nanoseconds( + fromTicks: offsetTicks, + numerator: timebaseNumerator, + denominator: timebaseDenominator + ) else { return nil } + return encode(nanoseconds: offsetNanoseconds) + } + + public static func encode(nanoseconds: UInt64) -> String { + "\(prefix)\(nanoseconds)" + } + + public static func parse(_ token: String) -> UInt64? { + guard token.hasPrefix(prefix) else { return nil } + let encodedNanoseconds = token.dropFirst(prefix.count) + guard !encodedNanoseconds.isEmpty, + encodedNanoseconds.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + let nanoseconds = UInt64(encodedNanoseconds), + token == encode(nanoseconds: nanoseconds) else { return nil } + return nanoseconds + } + + public static func matches(persisted: String, current: String) -> Bool { + guard let persistedNanoseconds = parse(persisted), + let currentNanoseconds = parse(current) else { return false } + let difference = persistedNanoseconds >= currentNanoseconds + ? persistedNanoseconds - currentNanoseconds + : currentNanoseconds - persistedNanoseconds + return difference <= matchingToleranceNanoseconds + } + + private static func nanoseconds( + fromTicks ticks: UInt64, + numerator: UInt32, + denominator: UInt32 + ) -> UInt64? { + guard numerator != 0, denominator != 0 else { return nil } + let multiplier = UInt64(numerator) + let divisor = UInt64(denominator) + let quotient = ticks / divisor + let remainder = ticks % divisor + let (wholeNanoseconds, wholeOverflow) = quotient.multipliedReportingOverflow( + by: multiplier + ) + let (remainderProduct, remainderOverflow) = remainder.multipliedReportingOverflow( + by: multiplier + ) + guard !wholeOverflow, !remainderOverflow else { return nil } + let fractionalNanoseconds = remainderProduct / divisor + let (totalNanoseconds, totalOverflow) = wholeNanoseconds.addingReportingOverflow( + fractionalNanoseconds + ) + return totalOverflow ? nil : totalNanoseconds + } +} + +public struct DisplayConnectionRecoveryHardwareProof: Codable, Equatable, Sendable { + public let isBuiltIn: Bool + public let identity: HardwareDisplayIdentity? + + public init(isBuiltIn: Bool, identity: HardwareDisplayIdentity?) { + self.isBuiltIn = isBuiltIn + self.identity = identity + } + + var isComplete: Bool { + isBuiltIn || identity?.isComplete == true + } +} + +public enum DisplayConnectionRecoveryProofBinder { + public static func isDirectlyBound( + retainedProof: DisplayConnectionRecoveryHardwareProof, + currentIsBuiltIn: Bool, + currentIdentity: HardwareDisplayIdentity?, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> Bool { + if retainedProof.isBuiltIn { + return currentIsBuiltIn && retainedProof.identity == nil + } + guard !currentIsBuiltIn, + let retainedIdentity = retainedProof.identity, + retainedIdentity.isComplete, + currentIdentity == retainedIdentity else { return false } + return HardwareFramebufferIdentityMatcher.hasUniqueExactMatch( + target: retainedIdentity, + framebufferSnapshot: framebufferSnapshot + ) + } +} + +public enum DisplayConnectionTopologyFingerprint { + public static func make( + displayIDs: [UInt32], + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> String? { + guard !displayIDs.isEmpty, + !displayIDs.contains(0), + Set(displayIDs).count == displayIDs.count, + let framebufferSnapshot, + !framebufferSnapshot.isEmpty else { return nil } + let registryEntryIDs = framebufferSnapshot.compactMap(\.registryEntryID) + guard registryEntryIDs.count == framebufferSnapshot.count, + Set(registryEntryIDs).count == registryEntryIDs.count else { return nil } + let displayComponent = displayIDs.sorted().map(String.init).joined(separator: ",") + let framebufferComponent = framebufferSnapshot.sorted { + ($0.registryEntryID ?? 0) < ($1.registryEntryID ?? 0) + }.map { evidence in + let identity = evidence.identity + return [ + String(evidence.registryEntryID ?? 0), + evidence.hasEDIDUUID ? "1" : "0", + String(identity?.vendorID ?? 0), + String(identity?.productID ?? 0), + String(identity?.serialNumber ?? 0) + ].joined(separator: ":") + }.joined(separator: ",") + return "sls:\(displayComponent)|framebuffers:\(framebufferComponent)" + } +} + +public struct DisplayConnectionCandidate: Equatable, Sendable { + public let displayID: UInt32 + public let stableUUID: String? + public let isOnline: Bool + public let isHardwareBackedPhysical: Bool + public let recoveryHardwareProof: DisplayConnectionRecoveryHardwareProof? + + public init( + displayID: UInt32, + stableUUID: String?, + isOnline: Bool, + isHardwareBackedPhysical: Bool, + recoveryHardwareProof: DisplayConnectionRecoveryHardwareProof? + ) { + self.displayID = displayID + self.stableUUID = stableUUID + self.isOnline = isOnline + self.isHardwareBackedPhysical = isHardwareBackedPhysical + self.recoveryHardwareProof = recoveryHardwareProof + } +} + +public enum DisplayConnectionRecoveryCapabilityState: String, Codable, Equatable, Sendable { + case prepared + case available + case invalidatedByWake = "invalidated_by_wake" + case consumed + case indeterminate +} + +public struct DisplayConnectionRecoveryCapability: Codable, Equatable, Sendable { + public let uuid: String + public let displayID: UInt32 + public let hardwareProof: DisplayConnectionRecoveryHardwareProof + public let bootSessionID: String + public let loginSessionID: String + public let wakeSessionID: String + public let topologyFingerprint: String + public let state: DisplayConnectionRecoveryCapabilityState + + public init( + uuid: String, + displayID: UInt32, + hardwareProof: DisplayConnectionRecoveryHardwareProof, + bootSessionID: String, + loginSessionID: String, + wakeSessionID: String, + topologyFingerprint: String, + state: DisplayConnectionRecoveryCapabilityState + ) { + self.uuid = uuid + self.displayID = displayID + self.hardwareProof = hardwareProof + self.bootSessionID = bootSessionID + self.loginSessionID = loginSessionID + self.wakeSessionID = wakeSessionID + self.topologyFingerprint = topologyFingerprint + self.state = state + } + + public func changingState( + to state: DisplayConnectionRecoveryCapabilityState + ) -> DisplayConnectionRecoveryCapability { + DisplayConnectionRecoveryCapability( + uuid: uuid, + displayID: displayID, + hardwareProof: hardwareProof, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint, + state: state + ) + } +} + +public struct DisplayConnectionPersistedRecord: Identifiable, Codable, Equatable, Sendable { + public let uuid: String + public var displayID: UInt32 + public var name: String + public var width: Int + public var height: Int + public var recoveryCapability: DisplayConnectionRecoveryCapability? + public var id: String { uuid } + + public init( + uuid: String, + displayID: UInt32, + name: String, + width: Int, + height: Int, + recoveryCapability: DisplayConnectionRecoveryCapability? = nil + ) { + self.uuid = uuid + self.displayID = displayID + self.name = name + self.width = width + self.height = height + self.recoveryCapability = recoveryCapability + } +} + +public struct DisplayConnectionPersistenceEnvelope: Codable, Equatable, Sendable { + public let records: [DisplayConnectionPersistedRecord] + public let pendingUUIDs: [String] + public let reconnectReservationUUIDs: [String] + public let reconnectPersistenceUncertainUUIDs: [String] + + public init( + records: [DisplayConnectionPersistedRecord], + pendingUUIDs: Set, + reconnectReservationUUIDs: Set, + reconnectPersistenceUncertainUUIDs: Set = [] + ) { + self.records = records + self.pendingUUIDs = pendingUUIDs.sorted() + self.reconnectReservationUUIDs = reconnectReservationUUIDs.sorted() + self.reconnectPersistenceUncertainUUIDs = reconnectPersistenceUncertainUUIDs.sorted() + } + + public var pendingSet: Set { Set(pendingUUIDs) } + public var reconnectReservationSet: Set { Set(reconnectReservationUUIDs) } + public var reconnectPersistenceUncertainSet: Set { + Set(reconnectPersistenceUncertainUUIDs) + } + + private enum CodingKeys: String, CodingKey { + case records + case pendingUUIDs + case reconnectReservationUUIDs + case reconnectPersistenceUncertainUUIDs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + records = try container.decode( + [DisplayConnectionPersistedRecord].self, + forKey: .records + ) + pendingUUIDs = try container.decodeIfPresent( + [String].self, + forKey: .pendingUUIDs + ) ?? [] + reconnectReservationUUIDs = try container.decodeIfPresent( + [String].self, + forKey: .reconnectReservationUUIDs + ) ?? [] + reconnectPersistenceUncertainUUIDs = try container.decodeIfPresent( + [String].self, + forKey: .reconnectPersistenceUncertainUUIDs + ) ?? [] + let recordUUIDs = records.map(\.uuid) + let recordSet = Set(recordUUIDs) + guard recordSet.count == recordUUIDs.count else { + throw DecodingError.dataCorruptedError( + forKey: .records, + in: container, + debugDescription: "persisted display UUIDs must be unique" + ) + } + guard Set(pendingUUIDs).count == pendingUUIDs.count, + Set(pendingUUIDs).isSubset(of: recordSet) else { + throw DecodingError.dataCorruptedError( + forKey: .pendingUUIDs, + in: container, + debugDescription: "pending UUIDs must be unique persisted records" + ) + } + guard Set(reconnectReservationUUIDs).count == reconnectReservationUUIDs.count, + Set(reconnectReservationUUIDs).isSubset(of: recordSet) else { + throw DecodingError.dataCorruptedError( + forKey: .reconnectReservationUUIDs, + in: container, + debugDescription: "reconnect reservations must be unique persisted records" + ) + } + guard Set(reconnectPersistenceUncertainUUIDs).count + == reconnectPersistenceUncertainUUIDs.count, + Set(reconnectPersistenceUncertainUUIDs).isSubset(of: recordSet) else { + throw DecodingError.dataCorruptedError( + forKey: .reconnectPersistenceUncertainUUIDs, + in: container, + debugDescription: "persistence-uncertain UUIDs must be unique persisted records" + ) + } + guard records.allSatisfy({ record in + guard let capability = record.recoveryCapability else { return true } + return capability.uuid == record.uuid && capability.displayID == record.displayID + }) else { + throw DecodingError.dataCorruptedError( + forKey: .records, + in: container, + debugDescription: "recovery capabilities must match their persisted records" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(records, forKey: .records) + try container.encode(pendingUUIDs, forKey: .pendingUUIDs) + try container.encode(reconnectReservationUUIDs, forKey: .reconnectReservationUUIDs) + try container.encode( + reconnectPersistenceUncertainUUIDs, + forKey: .reconnectPersistenceUncertainUUIDs + ) + } +} + +public enum RejectedReconnectRollbackError: Error, Equatable, Sendable { + case invalidState + case verificationFailed +} + +public enum RejectedReconnectRollback { + public static func proposedState( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability?, + currentState: DisplayConnectionPersistenceEnvelope + ) throws -> DisplayConnectionPersistenceEnvelope { + let matches = currentState.records.indices.filter { + currentState.records[$0].uuid == uuid + } + guard matches.count == 1, let index = matches.first, + currentState.reconnectReservationSet.contains(uuid), + !currentState.pendingSet.contains(uuid) else { + throw RejectedReconnectRollbackError.invalidState + } + + var nextRecords = currentState.records + if let consumedRecoveryCapability { + guard consumedRecoveryCapability.uuid == uuid, + consumedRecoveryCapability.state == .consumed, + nextRecords[index].recoveryCapability == consumedRecoveryCapability else { + throw RejectedReconnectRollbackError.invalidState + } + nextRecords[index].recoveryCapability = consumedRecoveryCapability.changingState( + to: .available + ) + } else if let capability = nextRecords[index].recoveryCapability { + guard ![.consumed, .indeterminate].contains(capability.state) else { + throw RejectedReconnectRollbackError.invalidState + } + } + + var nextReservations = currentState.reconnectReservationSet + guard nextReservations.remove(uuid) != nil else { + throw RejectedReconnectRollbackError.invalidState + } + let proposed = DisplayConnectionPersistenceEnvelope( + records: nextRecords, + pendingUUIDs: currentState.pendingSet, + reconnectReservationUUIDs: nextReservations, + reconnectPersistenceUncertainUUIDs: + currentState.reconnectPersistenceUncertainSet.subtracting([uuid]) + ) + return proposed + } + + public static func persistAndVerify( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability?, + currentState: DisplayConnectionPersistenceEnvelope, + persist: (DisplayConnectionPersistenceEnvelope) throws + -> DisplayConnectionPersistenceEnvelope + ) throws -> DisplayConnectionPersistenceEnvelope { + let proposed = try proposedState( + uuid: uuid, + consumedRecoveryCapability: consumedRecoveryCapability, + currentState: currentState + ) + let readBack = try persist(proposed) + guard readBack == proposed else { + throw RejectedReconnectRollbackError.verificationFailed + } + return readBack + } +} + +public enum DisplayConnectionDispatchAuthorization: Equatable, Sendable { + case exactUUID + case oneShotRecovery +} + +public struct DisplayConnectionDispatchRequest: Equatable, Sendable { + public let uuid: String + public let displayID: UInt32 + public let requestedState: DisplayConnectionState + public let authorization: DisplayConnectionDispatchAuthorization + + public init( + uuid: String, + displayID: UInt32, + requestedState: DisplayConnectionState, + authorization: DisplayConnectionDispatchAuthorization + ) { + self.uuid = uuid + self.displayID = displayID + self.requestedState = requestedState + self.authorization = authorization + } +} + +public enum DisplayConnectionReconnectResolution: Equatable, Sendable { + case alreadyOnline(displayID: UInt32) + case exactUUID(displayID: UInt32) + case oneShotRecovery(DisplayConnectionRecoveryCapability) + case unavailable +} + +public enum DisplayConnectionRecoveryResolver { + public static func authorizesPreparedDisconnectCapability( + _ capability: DisplayConnectionRecoveryCapability, + uuid: String, + displayID: UInt32, + observation: DisplayConnectionObservation + ) -> Bool { + guard capability.uuid == uuid, + capability.displayID == displayID, + capability.state == .prepared, + capability.hardwareProof.isComplete, + continuityMatches(capability, observation: observation) else { return false } + let candidates = observation.candidates.filter { $0.stableUUID == uuid } + guard candidates.count == 1, let candidate = candidates.first else { return false } + return candidate.displayID == displayID + && candidate.isOnline + && candidate.isHardwareBackedPhysical + && candidate.recoveryHardwareProof == capability.hardwareProof + } + + public static func authorizesExactDisconnect( + uuid: String, + displayID: UInt32, + observation: DisplayConnectionObservation + ) -> Bool { + guard inventoryIsConsistent(observation), + observation.intentionalDisconnectedUUIDs.contains(uuid), + observation.pendingDisconnectUUIDs.contains(uuid), + observation.activePhysicalViewableUUIDs.contains(uuid), + observation.activePhysicalViewableUUIDs.count > 1, + !observation.virtualUUIDs.contains(uuid) else { return false } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + guard capabilities.count == 1, let capability = capabilities.first else { return false } + return authorizesPreparedDisconnectCapability( + capability, + uuid: uuid, + displayID: displayID, + observation: observation + ) + } + + public static func reconnectResolution( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionReconnectResolution { + guard inventoryIsConsistent(observation), + observation.intentionalDisconnectedUUIDs.contains(uuid), + !observation.reconnectPersistenceUncertainUUIDs.contains(uuid) else { + return .unavailable + } + let exactCandidates = observation.candidates.filter { $0.stableUUID == uuid } + guard exactCandidates.count <= 1 else { return .unavailable } + if let exact = exactCandidates.first { + guard exact.isHardwareBackedPhysical else { return .unavailable } + if exact.isOnline { return .alreadyOnline(displayID: exact.displayID) } + let unsafeStates: Set = [ + .consumed, .indeterminate + ] + guard !observation.pendingDisconnectUUIDs.contains(uuid), + !observation.reconnectReservationUUIDs.contains(uuid), + !observation.recoveryCapabilities.contains(where: { + $0.uuid == uuid && unsafeStates.contains($0.state) + }) else { return .unavailable } + return .exactUUID(displayID: exact.displayID) + } + + guard !observation.reconnectReservationUUIDs.contains(uuid) else { + return .unavailable + } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + guard capabilities.count == 1, let capability = capabilities.first, + validRecoveryCandidate( + capability, + requiredState: .available, + observation: observation + ) else { return .unavailable } + return .oneShotRecovery(capability) + } + + public static func quarantinedReconnectResolution( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionReconnectResolution { + guard inventoryIsConsistent(observation), + observation.intentionalDisconnectedUUIDs.contains(uuid), + observation.reconnectPersistenceUncertainUUIDs.contains(uuid) else { + return .unavailable + } + let exactCandidates = observation.candidates.filter { $0.stableUUID == uuid } + guard exactCandidates.count <= 1 else { return .unavailable } + if let exact = exactCandidates.first { + guard exact.isHardwareBackedPhysical else { return .unavailable } + return exact.isOnline + ? .alreadyOnline(displayID: exact.displayID) + : .exactUUID(displayID: exact.displayID) + } + + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + let restorableStates: Set = [ + .prepared, .available, .consumed, .indeterminate + ] + guard capabilities.count == 1, let capability = capabilities.first, + restorableStates.contains(capability.state), + validDirectOfflineRecoveryCandidate( + capability, + observation: observation + ) else { return .unavailable } + return .oneShotRecovery(capability) + } + + public static func orphanedReconnectResolution( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionReconnectResolution { + guard inventoryIsConsistent(observation), + observation.intentionalDisconnectedUUIDs.contains(uuid), + observation.reconnectReservationUUIDs.contains(uuid), + !observation.pendingDisconnectUUIDs.contains(uuid) else { return .unavailable } + let exactCandidates = observation.candidates.filter { $0.stableUUID == uuid } + guard exactCandidates.count <= 1 else { return .unavailable } + if let exact = exactCandidates.first { + guard !exact.isOnline, + exact.isHardwareBackedPhysical else { return .unavailable } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + guard capabilities.count <= 1, + capabilities.first?.state != .prepared else { return .unavailable } + return .exactUUID(displayID: exact.displayID) + } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + let restorableStates: Set = [ + .available, .consumed, .indeterminate + ] + guard capabilities.count == 1, let capability = capabilities.first, + restorableStates.contains(capability.state), + validOrphanedRecoveryCandidate(capability, observation: observation) else { + return .unavailable + } + return .oneShotRecovery(capability) + } + + public static func restorableRecoveryCapabilityForExactOrphan( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionRecoveryCapability? { + guard case let .exactUUID(displayID) = orphanedReconnectResolution( + uuid: uuid, + observation: observation + ) else { return nil } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + let restorableStates: Set = [ + .available, .consumed, .indeterminate + ] + guard capabilities.count == 1, let capability = capabilities.first, + restorableStates.contains(capability.state), + capability.displayID == displayID, + capability.hardwareProof.isComplete, + continuityMatches(capability, observation: observation) else { return nil } + let candidates = observation.candidates.filter { $0.stableUUID == uuid } + guard candidates.count == 1, let candidate = candidates.first else { return nil } + return candidate.recoveryHardwareProof == capability.hardwareProof ? capability : nil + } + + public static func restorableRecoveryCapabilityForExactQuarantine( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionRecoveryCapability? { + guard case let .exactUUID(displayID) = quarantinedReconnectResolution( + uuid: uuid, + observation: observation + ) else { return nil } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + let restorableStates: Set = [ + .prepared, .available, .consumed, .indeterminate + ] + guard capabilities.count == 1, let capability = capabilities.first, + restorableStates.contains(capability.state), + capability.displayID == displayID, + capability.hardwareProof.isComplete, + continuityMatches(capability, observation: observation) else { return nil } + let candidates = observation.candidates.filter { $0.stableUUID == uuid } + guard candidates.count == 1, let candidate = candidates.first else { return nil } + return candidate.recoveryHardwareProof == capability.hardwareProof ? capability : nil + } + + public static func authorizesOfflineConfirmation( + _ capability: DisplayConnectionRecoveryCapability, + observation: DisplayConnectionObservation + ) -> Bool { + guard observation.intentionalDisconnectedUUIDs.contains(capability.uuid), + inventoryIsConsistent(observation) else { return false } + let persisted = observation.recoveryCapabilities.filter { $0.uuid == capability.uuid } + guard persisted == [capability] else { return false } + return validRecoveryCandidate( + capability, + requiredState: .prepared, + observation: observation + ) + } + + public static func authorizesConsumedRecoveryDispatch( + uuid: String, + displayID: UInt32, + observation: DisplayConnectionObservation + ) -> Bool { + guard inventoryIsConsistent(observation), + observation.reconnectReservationUUIDs.contains(uuid) else { return false } + let capabilities = observation.recoveryCapabilities.filter { $0.uuid == uuid } + guard capabilities.count == 1, let capability = capabilities.first, + capability.displayID == displayID else { return false } + return validRecoveryCandidate( + capability, + requiredState: .consumed, + observation: observation + ) + } + + public static func authorizesReservedExactReconnect( + uuid: String, + displayID: UInt32, + observation: DisplayConnectionObservation + ) -> Bool { + guard inventoryIsConsistent(observation), + observation.intentionalDisconnectedUUIDs.contains(uuid), + observation.reconnectReservationUUIDs.contains(uuid), + !observation.reconnectPersistenceUncertainUUIDs.contains(uuid), + !observation.pendingDisconnectUUIDs.contains(uuid) else { return false } + let candidates = observation.candidates.filter { $0.stableUUID == uuid } + guard candidates.count == 1, let candidate = candidates.first else { return false } + let unsafeStates: Set = [ + .consumed, .indeterminate + ] + return candidate.displayID == displayID + && !candidate.isOnline + && candidate.isHardwareBackedPhysical + && !observation.recoveryCapabilities.contains { + $0.uuid == uuid && unsafeStates.contains($0.state) + } + } + + public static func uniqueOnlineHardwareCandidate( + uuid: String, + observation: DisplayConnectionObservation + ) -> DisplayConnectionCandidate? { + guard inventoryIsConsistent(observation) else { return nil } + let candidates = observation.candidates.filter { $0.stableUUID == uuid } + guard candidates.count == 1, let candidate = candidates.first, + candidate.isOnline, + candidate.isHardwareBackedPhysical else { return nil } + return candidate + } + + private static func validRecoveryCandidate( + _ capability: DisplayConnectionRecoveryCapability, + requiredState: DisplayConnectionRecoveryCapabilityState, + observation: DisplayConnectionObservation + ) -> Bool { + guard capability.state == requiredState, + capability.displayID != 0, + capability.hardwareProof.isComplete, + observation.intentionalDisconnectedUUIDs.contains(capability.uuid), + !observation.reconnectPersistenceUncertainUUIDs.contains(capability.uuid), + continuityMatches(capability, observation: observation) else { return false } + switch requiredState { + case .prepared: + guard observation.pendingDisconnectUUIDs.contains(capability.uuid) else { return false } + case .available, .consumed: + guard !observation.pendingDisconnectUUIDs.contains(capability.uuid) else { return false } + case .invalidatedByWake, .indeterminate: + return false + } + let claims = observation.recoveryCapabilities.filter { + $0.displayID == capability.displayID + } + guard claims.count == 1 else { return false } + let candidates = observation.candidates.filter { $0.displayID == capability.displayID } + guard candidates.count == 1, let candidate = candidates.first, + !candidate.isOnline, + candidate.stableUUID == nil, + candidate.recoveryHardwareProof == capability.hardwareProof else { return false } + return observation.candidates.filter { + $0.recoveryHardwareProof == capability.hardwareProof + }.count == 1 + } + + private static func validOrphanedRecoveryCandidate( + _ capability: DisplayConnectionRecoveryCapability, + observation: DisplayConnectionObservation + ) -> Bool { + guard !observation.pendingDisconnectUUIDs.contains(capability.uuid) else { return false } + return validDirectOfflineRecoveryCandidate(capability, observation: observation) + } + + private static func validDirectOfflineRecoveryCandidate( + _ capability: DisplayConnectionRecoveryCapability, + observation: DisplayConnectionObservation + ) -> Bool { + guard capability.displayID != 0, + capability.hardwareProof.isComplete, + observation.intentionalDisconnectedUUIDs.contains(capability.uuid), + continuityMatches(capability, observation: observation) else { return false } + let claims = observation.recoveryCapabilities.filter { + $0.displayID == capability.displayID + } + guard claims == [capability] else { return false } + let candidates = observation.candidates.filter { $0.displayID == capability.displayID } + guard candidates.count == 1, let candidate = candidates.first, + !candidate.isOnline, + candidate.stableUUID == nil, + candidate.recoveryHardwareProof == capability.hardwareProof else { return false } + return observation.candidates.filter { + $0.recoveryHardwareProof == capability.hardwareProof + }.count == 1 + } + + private static func continuityMatches( + _ capability: DisplayConnectionRecoveryCapability, + observation: DisplayConnectionObservation + ) -> Bool { + guard !capability.bootSessionID.isEmpty, + !capability.loginSessionID.isEmpty, + !capability.topologyFingerprint.isEmpty, + let currentWakeSessionID = observation.wakeSessionID else { return false } + return observation.bootSessionID == capability.bootSessionID + && observation.loginSessionID == capability.loginSessionID + && DisplayConnectionMachSleepOffsetToken.matches( + persisted: capability.wakeSessionID, + current: currentWakeSessionID + ) + && observation.topologyFingerprint == capability.topologyFingerprint + } + + public static func persistenceProjectionMatches( + snapshot: DisplayConnectionPersistenceSnapshot, + observation: DisplayConnectionObservation + ) -> Bool { + let envelope = snapshot.envelope + return snapshot.authority == .durable + && observation.persistenceStateIsAuthoritative + && Set(envelope.records.map(\.uuid)) == observation.intentionalDisconnectedUUIDs + && envelope.pendingSet == observation.pendingDisconnectUUIDs + && envelope.reconnectReservationSet == observation.reconnectReservationUUIDs + && envelope.reconnectPersistenceUncertainSet + == observation.reconnectPersistenceUncertainUUIDs + && envelope.records.compactMap(\.recoveryCapability) + == observation.recoveryCapabilities + } + + public static func inventoryIsConsistent( + _ observation: DisplayConnectionObservation + ) -> Bool { + guard observation.persistenceStateIsAuthoritative, + observation.onlineUUIDs.isSubset(of: observation.allUUIDs), + observation.virtualUUIDs.isSubset(of: observation.allUUIDs), + observation.activePhysicalViewableUUIDs.isSubset(of: observation.onlineUUIDs), + observation.pendingDisconnectUUIDs.isSubset( + of: observation.intentionalDisconnectedUUIDs + ), + observation.reconnectReservationUUIDs.isSubset( + of: observation.intentionalDisconnectedUUIDs + ), + observation.reconnectPersistenceUncertainUUIDs.isSubset( + of: observation.intentionalDisconnectedUUIDs + ) else { return false } + let capabilityUUIDs = observation.recoveryCapabilities.map(\.uuid) + let capabilityDisplayIDs = observation.recoveryCapabilities.map(\.displayID) + guard Set(capabilityUUIDs).count == capabilityUUIDs.count, + Set(capabilityDisplayIDs).count == capabilityDisplayIDs.count, + Set(capabilityUUIDs).isSubset(of: observation.intentionalDisconnectedUUIDs), + capabilityDisplayIDs.allSatisfy({ $0 != 0 }) else { return false } + let ids = observation.candidates.map(\.displayID) + guard ids.allSatisfy({ $0 != 0 }), Set(ids).count == ids.count else { return false } + let identified = observation.candidates.compactMap(\.stableUUID) + guard Set(identified).count == identified.count, + Set(identified) == observation.allUUIDs else { return false } + let online = observation.candidates.compactMap { candidate in + candidate.isOnline ? candidate.stableUUID : nil + } + return online.count == observation.candidates.filter(\.isOnline).count + && Set(online) == observation.onlineUUIDs + } +} diff --git a/Sources/CrispControlCore/DisplayModel.swift b/Sources/CrispControlCore/DisplayModel.swift new file mode 100644 index 0000000..868041c --- /dev/null +++ b/Sources/CrispControlCore/DisplayModel.swift @@ -0,0 +1,385 @@ +import Foundation + +public enum CapabilityState: String, Codable, Equatable, Sendable { + case readable + case writable + case unsupported + case permissionRequired = "permission_required" +} + +public enum BrightnessBackend: String, Codable, Equatable, Sendable { + case displayServices = "DisplayServices" + case ioKit = "IOKit" + case ddc = "DDC" + case software = "software_gamma" + case unavailable +} + +public enum ReadbackQuality: String, Codable, Equatable, Sendable { + case authoritative + case approximate + case unavailable +} + +public struct ControlRange: Codable, Equatable, Sendable { + public let min: Double + public let max: Double + public let precision: Double + + public init(min: Double, max: Double, precision: Double) { + self.min = min + self.max = max + self.precision = precision + } +} + +public struct BrightnessCapability: Codable, Equatable, Sendable { + public let state: CapabilityState + public let backend: BrightnessBackend + public let range: ControlRange + public let readback: ReadbackQuality + /// The native/DDC hardware range. Values above this range are never sent to hardware. + public let hardwareRange: ControlRange + /// Crisp's current logical slider range, including live EDR headroom when usable. + public let logicalRange: ControlRange + public let reason: String? + public let remediation: String? + + public init( + state: CapabilityState, + backend: BrightnessBackend, + range: ControlRange, + readback: ReadbackQuality, + hardwareRange: ControlRange? = nil, + logicalRange: ControlRange? = nil, + reason: String? = nil, + remediation: String? = nil + ) { + self.state = state + self.backend = backend + self.range = range + self.readback = readback + self.hardwareRange = hardwareRange ?? ControlRange(min: 0, max: 100, precision: range.precision) + self.logicalRange = logicalRange ?? range + self.reason = reason + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case state, backend, range, readback, hardwareRange, logicalRange, reason, remediation + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + state = try values.decode(CapabilityState.self, forKey: .state) + backend = try values.decode(BrightnessBackend.self, forKey: .backend) + range = try values.decode(ControlRange.self, forKey: .range) + readback = try values.decode(ReadbackQuality.self, forKey: .readback) + hardwareRange = try values.decodeIfPresent(ControlRange.self, forKey: .hardwareRange) + ?? ControlRange(min: 0, max: 100, precision: range.precision) + logicalRange = try values.decodeIfPresent(ControlRange.self, forKey: .logicalRange) ?? range + reason = try values.decodeIfPresent(String.self, forKey: .reason) + remediation = try values.decodeIfPresent(String.self, forKey: .remediation) + } + + public static func unsupported(reason: String, remediation: String? = nil) -> Self { + Self(state: .unsupported, backend: .unavailable, + range: ControlRange(min: 0, max: 100, precision: 1), + readback: .unavailable, reason: reason, remediation: remediation) + } +} + +public struct EDRHeadroomSnapshot: Codable, Equatable, Sendable { + /// Relative component-value headroom reported by NSScreen; this is not absolute nits. + public let potential: Double + public let current: Double + public let source: String + /// Factor most recently committed by Crisp's overlay/transfer-table path. + /// This is app state, not an independent measurement of emitted light. + public let appliedFactor: Double? + public let factorVerification: String? + + public init( + potential: Double, + current: Double, + source: String = "NSScreen_EDR_component_values", + appliedFactor: Double? = nil, + factorVerification: String? = nil + ) { + self.potential = potential + self.current = current + self.source = source + self.appliedFactor = appliedFactor + self.factorVerification = factorVerification + } +} + +public struct ExtraBrightnessCapability: Codable, Equatable, Sendable { + public let state: CapabilityState + public let enabled: Bool? + public let persistedEnabled: Bool + public let maxBrightness: Double + public let headroom: EDRHeadroomSnapshot? + public let reason: String? + public let remediation: String? + + public init( + state: CapabilityState, + enabled: Bool?, + persistedEnabled: Bool, + maxBrightness: Double, + headroom: EDRHeadroomSnapshot? = nil, + reason: String? = nil, + remediation: String? = nil + ) { + self.state = state + self.enabled = enabled + self.persistedEnabled = persistedEnabled + self.maxBrightness = maxBrightness + self.headroom = headroom + self.reason = reason + self.remediation = remediation + } + + public static func unsupported( + enabled: Bool? = nil, + persistedEnabled: Bool = false, + maxBrightness: Double = 100, + headroom: EDRHeadroomSnapshot? = nil, + reason: String, + remediation: String? = nil + ) -> Self { + Self(state: .unsupported, enabled: enabled, persistedEnabled: persistedEnabled, + maxBrightness: maxBrightness, headroom: headroom, reason: reason, remediation: remediation) + } +} + +public struct HDRCapability: Codable, Equatable, Sendable { + public let state: CapabilityState + public let enabled: Bool? + public let reason: String? + public let remediation: String? + + public init( + state: CapabilityState, + enabled: Bool?, + reason: String? = nil, + remediation: String? = nil + ) { + self.state = state + self.enabled = enabled + self.reason = reason + self.remediation = remediation + } + + public static func unsupported( + enabled: Bool? = nil, + reason: String, + remediation: String? = nil + ) -> Self { + Self(state: .unsupported, enabled: enabled, reason: reason, remediation: remediation) + } +} + +public struct DisplayConnectionCapability: Codable, Equatable, Sendable { + public let state: CapabilityState + public let connected: Bool + public let disconnectAllowed: Bool + public let reconnectAllowed: Bool + public let platformSupported: Bool + public let reason: String? + public let remediation: String? + + public init( + state: CapabilityState, + connected: Bool, + disconnectAllowed: Bool, + reconnectAllowed: Bool, + platformSupported: Bool, + reason: String? = nil, + remediation: String? = nil + ) { + self.state = state + self.connected = connected + self.disconnectAllowed = disconnectAllowed + self.reconnectAllowed = reconnectAllowed + self.platformSupported = platformSupported + self.reason = reason + self.remediation = remediation + } + + public static func unsupported( + connected: Bool, + platformSupported: Bool = false, + reason: String, + remediation: String? = nil + ) -> Self { + Self( + state: .unsupported, + connected: connected, + disconnectAllowed: false, + reconnectAllowed: false, + platformSupported: platformSupported, + reason: reason, + remediation: remediation + ) + } +} + +public struct ControlDisconnectedDisplay: Codable, Equatable, Sendable { + public let uuid: String + public let name: String + public let width: Int + public let height: Int + public let connection: DisplayConnectionCapability + + public init( + uuid: String, + name: String, + width: Int, + height: Int, + connection: DisplayConnectionCapability + ) { + self.uuid = uuid + self.name = name + self.width = width + self.height = height + self.connection = connection + } +} + +public struct BrightnessReadSnapshot: Codable, Equatable, Sendable { + public let logicalPercent: Double + public let hardwareReadbackPercent: Double? + + public init(logicalPercent: Double, hardwareReadbackPercent: Double?) { + self.logicalPercent = logicalPercent + self.hardwareReadbackPercent = hardwareReadbackPercent + } +} + +public enum AppStateVerificationQuality: String, Codable, Equatable, Sendable { + case verified + case appStateVerified = "app_state_verified" + case settling +} + +public struct ExtraBrightnessSetResult: Codable, Equatable, Sendable { + public let capability: ExtraBrightnessCapability + public let verification: AppStateVerificationQuality + public let warnings: [String] + + public init( + capability: ExtraBrightnessCapability, + verification: AppStateVerificationQuality, + warnings: [String] = [] + ) { + self.capability = capability + self.verification = verification + self.warnings = warnings + } +} + +public struct HDRSetResult: Codable, Equatable, Sendable { + public let capability: HDRCapability + public let verification: AppStateVerificationQuality + public let warnings: [String] + + public init( + capability: HDRCapability, + verification: AppStateVerificationQuality, + warnings: [String] = [] + ) { + self.capability = capability + self.verification = verification + self.warnings = warnings + } +} + +public struct ControlDisplay: Codable, Equatable, Sendable { + public let uuid: String + public let name: String + public let isMain: Bool + public let isBuiltin: Bool + public let isVirtual: Bool + public let brightness: BrightnessCapability + public let brightnessPercent: Double? + public let extraBrightness: ExtraBrightnessCapability + public let hdr: HDRCapability + public let connection: DisplayConnectionCapability + + public init( + uuid: String, + name: String, + isMain: Bool, + isBuiltin: Bool, + isVirtual: Bool = false, + brightness: BrightnessCapability, + brightnessPercent: Double? = nil, + extraBrightness: ExtraBrightnessCapability = .unsupported(reason: "Extra Brightness capability is unavailable"), + hdr: HDRCapability = .unsupported(reason: "HDR capability is unavailable"), + connection: DisplayConnectionCapability = .unsupported( + connected: true, + reason: "display connection capability is unavailable in this response" + ) + ) { + self.uuid = uuid + self.name = name + self.isMain = isMain + self.isBuiltin = isBuiltin + self.isVirtual = isVirtual + self.brightness = brightness + self.brightnessPercent = brightnessPercent + self.extraBrightness = extraBrightness + self.hdr = hdr + self.connection = connection + } + private enum CodingKeys: String, CodingKey { + case uuid, name, isMain, isBuiltin, isVirtual, brightness, brightnessPercent + case extraBrightness, hdr, connection + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + uuid = try values.decode(String.self, forKey: .uuid) + name = try values.decode(String.self, forKey: .name) + isMain = try values.decode(Bool.self, forKey: .isMain) + isBuiltin = try values.decode(Bool.self, forKey: .isBuiltin) + isVirtual = try values.decodeIfPresent(Bool.self, forKey: .isVirtual) ?? false + brightness = try values.decode(BrightnessCapability.self, forKey: .brightness) + brightnessPercent = try values.decodeIfPresent(Double.self, forKey: .brightnessPercent) + extraBrightness = try values.decodeIfPresent(ExtraBrightnessCapability.self, forKey: .extraBrightness) + ?? .unsupported(reason: "Extra Brightness capability is unavailable") + hdr = try values.decodeIfPresent(HDRCapability.self, forKey: .hdr) + ?? .unsupported(reason: "HDR capability is unavailable") + connection = try values.decodeIfPresent(DisplayConnectionCapability.self, forKey: .connection) + ?? .unsupported( + connected: true, + reason: "display connection capability is unavailable in this response" + ) + } +} + +public enum SelectorError: Error, Equatable { + case notFound(String) + case ambiguous([ControlDisplay]) +} + +public enum DisplaySelector { + public static func resolve(_ selector: String, in displays: [ControlDisplay]) throws -> ControlDisplay { + let matches: [ControlDisplay] + switch selector.lowercased() { + case "main": matches = displays.filter(\.isMain) + case "builtin": matches = displays.filter(\.isBuiltin) + default: + matches = displays.filter { + $0.uuid.caseInsensitiveCompare(selector) == .orderedSame + || $0.name.caseInsensitiveCompare(selector) == .orderedSame + } + } + let ordered = matches.sorted { $0.uuid < $1.uuid } + guard !ordered.isEmpty else { throw SelectorError.notFound(selector) } + guard ordered.count == 1 else { throw SelectorError.ambiguous(ordered) } + return ordered[0] + } +} diff --git a/Sources/CrispControlCore/EDRHeadroomSettlement.swift b/Sources/CrispControlCore/EDRHeadroomSettlement.swift new file mode 100644 index 0000000..fa10ea4 --- /dev/null +++ b/Sources/CrispControlCore/EDRHeadroomSettlement.swift @@ -0,0 +1,36 @@ +public enum EDRHeadroomSettlementResult: Equatable, Sendable { + case ready(potentialHeadroom: Double) + case timedOut + case invalidated + case capabilityLost +} + +/// Keeps preference verification separate from the later EDR readiness ramp. +/// The caller supplies identity/generation and capability checks so this Core +/// helper remains AppKit-free. +public enum EDRHeadroomSettlement { + @MainActor + public static func wait( + maxSamples: Int, + threshold: Double, + isCurrent: () -> Bool, + isCapable: () -> Bool, + potentialHeadroom: () -> Double, + pause: () async throws -> Void + ) async rethrows -> EDRHeadroomSettlementResult { + for sampleIndex in 0.. threshold { + return .ready(potentialHeadroom: potential) + } + if sampleIndex + 1 < maxSamples { + try await pause() + } + } + guard isCurrent() else { return .invalidated } + guard isCapable() else { return .capabilityLost } + return .timedOut + } +} diff --git a/Sources/CrispControlCore/HDRMutationCoordinator.swift b/Sources/CrispControlCore/HDRMutationCoordinator.swift new file mode 100644 index 0000000..99d1ec2 --- /dev/null +++ b/Sources/CrispControlCore/HDRMutationCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct HDRMutationToken: Equatable, Sendable { + public let uuid: String + public let identity: String + public let generation: UInt64 + public let requested: Bool +} + +/// Tracks an HDR setter invocation separately from verified live read-back. +/// Brightness routing is exposed only after a matching, current-generation read. +public struct HDRMutationCoordinator: Sendable { + private struct Entry: Sendable { + let token: HDRMutationToken + var setterInvoked: Bool + var verified: Bool + } + + private var entries: [String: Entry] = [:] + + public init() {} + + public mutating func begin(uuid: String, identity: String, requested: Bool) -> HDRMutationToken { + let token = HDRMutationToken( + uuid: uuid, + identity: identity, + generation: (entries[uuid]?.token.generation ?? 0) &+ 1, + requested: requested + ) + entries[uuid] = Entry(token: token, setterInvoked: false, verified: false) + return token + } + + public mutating func recordSetterInvocation(_ token: HDRMutationToken) -> Bool { + guard entries[token.uuid]?.token == token else { return false } + entries[token.uuid]?.setterInvoked = true + return true + } + + public mutating func observe( + _ token: HDRMutationToken, + currentUUID: String, + currentIdentity: String, + readback: Bool + ) -> Bool { + guard token.uuid == currentUUID, token.identity == currentIdentity, + var entry = entries[token.uuid], entry.token == token, entry.setterInvoked, + readback == token.requested else { return false } + entry.verified = true + entries[token.uuid] = entry + return true + } + + public func verifiedRoutingState(for token: HDRMutationToken) -> Bool? { + guard let entry = entries[token.uuid], entry.token == token, entry.verified else { return nil } + return token.requested + } + + public mutating func invalidate(uuid: String, identity: String) { + guard entries[uuid]?.token.identity == identity else { return } + entries.removeValue(forKey: uuid) + } +} diff --git a/Sources/CrispControlCore/HDRPreferenceAdapter.swift b/Sources/CrispControlCore/HDRPreferenceAdapter.swift new file mode 100644 index 0000000..3fabc5c --- /dev/null +++ b/Sources/CrispControlCore/HDRPreferenceAdapter.swift @@ -0,0 +1,93 @@ +import Foundation + +public struct ObjectiveCMethodEncoding: Equatable, Sendable { + public let returnType: String + public let argumentTypes: [String] + + public init(returnType: String, argumentTypes: [String]) { + self.returnType = returnType + self.argumentTypes = argumentTypes + } +} + +public enum MonitorPanelABIMethod: Equatable, Sendable { + case displaysGetter + case displayIDGetter + case boolGetter + case boolSetter +} + +public enum MonitorPanelABISignatureValidator { + public static func isCompatible( + _ encoding: ObjectiveCMethodEncoding, + with method: MonitorPanelABIMethod + ) -> Bool { + let returnType = normalized(encoding.returnType) + let arguments = encoding.argumentTypes.map(normalized) + guard arguments.count >= 2, arguments[0] == "@", arguments[1] == ":" else { + return false + } + switch method { + case .displaysGetter: + return arguments.count == 2 && returnType == "@" + case .displayIDGetter: + return arguments.count == 2 && returnType == "I" + case .boolGetter: + return arguments.count == 2 && isBoolean(returnType) + case .boolSetter: + return arguments.count == 3 && returnType == "v" && isBoolean(arguments[2]) + } + } + + private static func isBoolean(_ encoding: String) -> Bool { + encoding == "B" || encoding == "c" + } + + private static func normalized(_ encoding: String) -> String { + let qualifiers = CharacterSet(charactersIn: "rnNoORV") + let trimmed = encoding.drop(while: { character in + character.unicodeScalars.allSatisfy { qualifiers.contains($0) } + }) + guard let first = trimmed.first else { return "" } + return first == "@" ? "@" : String(first) + } +} + +public struct HDRAdapterState: Equatable, Sendable { + public let supportsHDR: Bool + public let prefersHDR: Bool + public let canSet: Bool + public let identity: String + + public init(supportsHDR: Bool, prefersHDR: Bool, canSet: Bool, identity: String) { + self.supportsHDR = supportsHDR + self.prefersHDR = prefersHDR + self.canSet = canSet + self.identity = identity + } +} + +@MainActor +public protocol HDRPreferenceAdapting: AnyObject { + func readState(displayID: UInt32) -> HDRAdapterState? + func setPreference(_ enabled: Bool, displayID: UInt32, expectedIdentity: String) -> Bool +} + +public enum HDRPreferenceAdapterDriver { + /// Returns the stable adapter-object identity only after capability checks + /// and one setter invocation. Nil is fail-closed and never invokes a + /// setter when the manager/getters/setter are unavailable. + @MainActor + public static func beginSet( + using adapter: any HDRPreferenceAdapting, + displayID: UInt32, + requested: Bool + ) -> String? { + guard let state = adapter.readState(displayID: displayID), + state.supportsHDR, state.canSet, + adapter.setPreference( + requested, displayID: displayID, expectedIdentity: state.identity + ) else { return nil } + return state.identity + } +} diff --git a/Sources/CrispControlCore/HardwareBackedPhysicalDisplayClassifier.swift b/Sources/CrispControlCore/HardwareBackedPhysicalDisplayClassifier.swift new file mode 100644 index 0000000..b12da05 --- /dev/null +++ b/Sources/CrispControlCore/HardwareBackedPhysicalDisplayClassifier.swift @@ -0,0 +1,99 @@ +public struct HardwareDisplayIdentity: Codable, Equatable, Sendable { + public let vendorID: UInt32? + public let productID: UInt32? + public let serialNumber: UInt32? + + public init(vendorID: UInt32?, productID: UInt32?, serialNumber: UInt32?) { + self.vendorID = vendorID + self.productID = productID + self.serialNumber = serialNumber + } + + var isComplete: Bool { + guard let vendorID, let productID, let serialNumber else { return false } + return vendorID != 0 && productID != 0 && serialNumber != 0 + } +} + +public struct HardwareFramebufferIdentityEvidence: Equatable, Sendable { + public let registryEntryID: UInt64? + public let hasEDIDUUID: Bool + public let identity: HardwareDisplayIdentity? + + public init( + registryEntryID: UInt64? = nil, + hasEDIDUUID: Bool, + identity: HardwareDisplayIdentity? + ) { + self.registryEntryID = registryEntryID + self.hasEDIDUUID = hasEDIDUUID + self.identity = identity + } +} + +public enum HardwareFramebufferIdentityMatcher { + public static func hasUniqueExactMatch( + target: HardwareDisplayIdentity, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> Bool { + guard target.isComplete, let framebufferSnapshot else { return false } + let candidates = framebufferSnapshot.filter(\.hasEDIDUUID) + guard candidates.allSatisfy({ $0.identity?.isComplete == true }) else { return false } + return candidates.filter { $0.identity == target }.count == 1 + } +} + +public struct HardwareBackedPhysicalDisplayEvidence: Equatable, Sendable { + public let isBuiltin: Bool + public let isKnownVirtual: Bool + public let hasIOServicePort: Bool + public let ioServiceConformsToDisplayConnect: Bool + public let coreGraphicsIdentity: HardwareDisplayIdentity? + public let framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + + public init( + isBuiltin: Bool, + isKnownVirtual: Bool, + hasIOServicePort: Bool, + ioServiceConformsToDisplayConnect: Bool, + coreGraphicsIdentity: HardwareDisplayIdentity? = nil, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? = nil + ) { + self.isBuiltin = isBuiltin + self.isKnownVirtual = isKnownVirtual + self.hasIOServicePort = hasIOServicePort + self.ioServiceConformsToDisplayConnect = ioServiceConformsToDisplayConnect + self.coreGraphicsIdentity = coreGraphicsIdentity + self.framebufferSnapshot = framebufferSnapshot + } +} + +public enum HardwareBackedPhysicalDisplayClassifier { + public static func isHardwareBacked( + _ evidence: HardwareBackedPhysicalDisplayEvidence + ) -> Bool { + guard !evidence.isKnownVirtual else { return false } + if evidence.isBuiltin { return true } + if evidence.hasIOServicePort && evidence.ioServiceConformsToDisplayConnect { + return true + } + guard let target = evidence.coreGraphicsIdentity else { return false } + return HardwareFramebufferIdentityMatcher.hasUniqueExactMatch( + target: target, + framebufferSnapshot: evidence.framebufferSnapshot + ) + } + + public static func unsupportedConnectionCapability( + for evidence: HardwareBackedPhysicalDisplayEvidence, + connected: Bool + ) -> DisplayConnectionCapability? { + guard !isHardwareBacked(evidence) else { return nil } + return .unsupported( + connected: connected, + platformSupported: true, + reason: "display cannot be positively proven as hardware-backed physical", + remediation: "refresh after reconnecting a built-in or IOKit-backed physical display" + ) + } +} diff --git a/Sources/CrispControlCore/PhysicalDisplaySafetyPolicy.swift b/Sources/CrispControlCore/PhysicalDisplaySafetyPolicy.swift new file mode 100644 index 0000000..2e432b0 --- /dev/null +++ b/Sources/CrispControlCore/PhysicalDisplaySafetyPolicy.swift @@ -0,0 +1,32 @@ +public enum PhysicalDisplaySafetyPolicy { + public static func shouldRefuseDisconnect( + targetIsActive: Bool, + activePhysicalDisplayCount: Int? + ) -> Bool { + guard let activePhysicalDisplayCount else { return true } + return targetIsActive && activePhysicalDisplayCount <= 1 + } + + public static func authorizesEmergencyRecovery( + activePhysicalDisplayCount: Int? + ) -> Bool { + activePhysicalDisplayCount == 0 + } + + public static func uniqueExactUUIDDisplayIDs( + _ candidates: [(uuid: String?, displayID: UInt32)] + ) -> [String: UInt32] { + var grouped: [String: [UInt32]] = [:] + for candidate in candidates { + guard let uuid = candidate.uuid, + ControlRequest.isExactDisplayUUID(uuid) else { continue } + grouped[uuid, default: []].append(candidate.displayID) + } + + var unique: [String: UInt32] = [:] + for (uuid, displayIDs) in grouped where displayIDs.count == 1 { + unique[uuid] = displayIDs[0] + } + return unique + } +} diff --git a/Sources/CrispControlCore/Protocol.swift b/Sources/CrispControlCore/Protocol.swift new file mode 100644 index 0000000..2ea5e98 --- /dev/null +++ b/Sources/CrispControlCore/Protocol.swift @@ -0,0 +1,277 @@ +import Foundation + +public let crispControlProtocolVersion = 1 + +public enum JSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "invalid JSON value") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case let .bool(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .string(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .object(value): try container.encode(value) + } + } + + public subscript(key: String) -> JSONValue? { + guard case let .object(object) = self else { return nil } + return object[key] + } + + public subscript(index: Int) -> JSONValue? { + guard case let .array(array) = self, array.indices.contains(index) else { return nil } + return array[index] + } +} + +public enum ControlJSON { + public static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + public static var decoder: JSONDecoder { JSONDecoder() } +} + +public struct ControlRequest: Codable, Equatable, Sendable { + public let protocolVersion: Int + public let requestID: String + public let command: String + public let arguments: [String: JSONValue] + + public init( + protocolVersion: Int = crispControlProtocolVersion, + requestID: String = UUID().uuidString, + command: String, + arguments: [String: JSONValue] = [:] + ) { + self.protocolVersion = protocolVersion + self.requestID = requestID + self.command = command + self.arguments = arguments + } + + public var mutationKind: ControlMutationKind? { + switch command { + case "brightness.set": .brightness + case "brightness.set-all": .brightnessBatch + case "extra-brightness.set": .extraBrightness + case "hdr.set": .hdr + case "displays.disconnect", "displays.reconnect": .displayConnection + default: + command.hasSuffix(".set") || command.hasSuffix(".set-all") ? .unknown : nil + } + } + + public var isMutating: Bool { mutationKind != nil } + + /// Missing means the request supplied an invalid option type. Older v1 + /// clients omit the additive argument and therefore remain strict. + public var brightnessBatchRestoreMode: BrightnessBatchRestoreMode? { + guard command == "brightness.set-all" else { return nil } + switch arguments["allowUnrestorable"] { + case nil, .bool(false): return .strict + case .bool(true): return .allowUnrestorable + default: return nil + } + } + + public var exactDisplayConnectionUUID: String? { + guard mutationKind == .displayConnection, + case let .string(uuid)? = arguments["uuid"], + Self.isExactDisplayUUID(uuid) else { return nil } + return uuid + } + + public static func isExactDisplayUUID(_ value: String) -> Bool { + let characters = Array(value) + return value.utf8.count == 36 + && UUID(uuidString: value) != nil + && [8, 13, 18, 23].allSatisfy { + characters.indices.contains($0) && characters[$0] == "-" + } + } +} + +public enum ControlMutationKind: String, Codable, Equatable, Sendable { + case brightness + case brightnessBatch = "brightness_batch" + case extraBrightness = "extra_brightness" + case hdr + case displayConnection = "display_connection" + /// Conservative fallback for a future set-shaped command omitted from the + /// explicit inventory: timeout safety must fail closed until CI classifies it. + case unknown +} + +public enum BrightnessBatchRestoreMode: String, Codable, Equatable, Sendable { + case strict + case allowUnrestorable = "allow_unrestorable" +} + +public enum BrightnessBatchDisplayStatus: String, Codable, Equatable, Sendable { + case writtenVerified = "written_verified" + case writtenUnverified = "written_unverified" + case failed + case writeIndeterminate = "write_indeterminate" + case notAttempted = "not_attempted" +} + +public enum ControlCommandInventory { + public static let dispatcherCommands: Set = [ + "version", "status", "displays.list", "displays.get", "displays.capabilities", + "displays.disconnected", "displays.disconnect", "displays.reconnect", + "brightness.get", "brightness.set", "brightness.get-all", "brightness.set-all", + "extra-brightness.get", "extra-brightness.set", "hdr.get", "hdr.set" + ] + public static let mutatingCommands: Set = [ + "brightness.set", "brightness.set-all", "extra-brightness.set", "hdr.set", + "displays.disconnect", "displays.reconnect" + ] +} + +public enum ControlErrorCode: String, Codable, CaseIterable, Sendable { + case invalidArguments = "invalid_arguments" + case appNotRunning = "app_not_running" + case appLaunchFailed = "app_launch_failed" + case appReadinessTimeout = "app_readiness_timeout" + case unsupportedCapability = "unsupported_capability" + case writeVerificationFailed = "write_verification_failed" + case writeOutcomeIndeterminate = "write_outcome_indeterminate" + case batchPartialFailure = "batch_partial_failure" + case batchPreflightFailed = "batch_preflight_failed" + case emptyPhysicalInventory = "empty_physical_inventory" + case selectorNotFound = "selector_not_found" + case ambiguousSelector = "ambiguous_selector" + case protocolMismatch = "protocol_mismatch" + case malformedRequest = "malformed_request" + case timeout + case transportError = "transport_error" + case internalError = "internal_error" + + public var exitCode: Int32 { + switch self { + case .invalidArguments: 2 + case .appNotRunning, .appLaunchFailed, .appReadinessTimeout: 3 + case .unsupportedCapability, .batchPreflightFailed, .emptyPhysicalInventory: 4 + case .writeVerificationFailed, .writeOutcomeIndeterminate, .batchPartialFailure: 5 + case .selectorNotFound, .ambiguousSelector: 6 + case .protocolMismatch, .malformedRequest: 7 + case .timeout, .transportError: 8 + case .internalError: 1 + } + } +} + +public struct ControlError: Codable, Equatable, Sendable { + public let code: ControlErrorCode + public let message: String + public let details: JSONValue? + + public init(code: ControlErrorCode, message: String, details: JSONValue? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +public struct ControlResponse: Codable, Equatable, Sendable { + public let protocolVersion: Int + public let requestID: String + public let ok: Bool + public let result: JSONValue? + public let error: ControlError? + + public static func success(requestID: String, result: JSONValue) -> Self { + Self(protocolVersion: crispControlProtocolVersion, requestID: requestID, + ok: true, result: result, error: nil) + } + + public static func failure( + requestID: String, + code: ControlErrorCode, + message: String, + details: JSONValue? = nil + ) -> Self { + Self(protocolVersion: crispControlProtocolVersion, requestID: requestID, + ok: false, result: nil, error: ControlError(code: code, message: message, details: details)) + } + + public static func timeout(for request: ControlRequest) -> Self { + guard request.isMutating else { + return .failure( + requestID: request.requestID, + code: .timeout, + message: "Crisp control handler timed out" + ) + } + var details: [String: JSONValue] = [ + "retrySafe": .bool(false), + "outcome": .string("unknown"), + "command": .string(request.command) + ] + if let selector = request.arguments["selector"] { details["selector"] = selector } + if let percent = request.arguments["percent"] { details["targetPercent"] = percent } + if let enabled = request.arguments["enabled"] { details["targetEnabled"] = enabled } + if request.command == "brightness.set-all" { + details["target"] = .string("all_physical_displays") + } + if request.mutationKind == .displayConnection { + guard let uuid = request.exactDisplayConnectionUUID else { + return invalidDisplayConnectionRequest(for: request) + } + let requestedState = request.command == "displays.reconnect" ? "connected" : "disconnected" + details["requestedConnectionState"] = .string(requestedState) + details["displayUUID"] = .string(uuid) + } + return .failure( + requestID: request.requestID, + code: .writeOutcomeIndeterminate, + message: "control write timed out; one or more displays may still reach the requested state", + details: .object(details) + ) + } + + public static func invalidDisplayConnectionRequest(for request: ControlRequest) -> Self { + .failure( + requestID: request.requestID, + code: .invalidArguments, + message: "\(request.command) requires an exact UUID in the uuid argument", + details: .object([ + "phase": .string("preflight"), + "retrySafe": .bool(true), + "mutationDispatched": .bool(false), + "command": .string(request.command) + ]) + ) + } +} diff --git a/Sources/CrispControlCore/Transport.swift b/Sources/CrispControlCore/Transport.swift new file mode 100644 index 0000000..c294c49 --- /dev/null +++ b/Sources/CrispControlCore/Transport.swift @@ -0,0 +1,496 @@ +import Darwin +import Foundation + +public enum IPCError: Error, Equatable, LocalizedError { + case unavailable + case timeout + case unsafeSocketPath + case socketPathTooLong + case addressInUse + case malformedResponse + case responseProtocolMismatch + case responseRequestMismatch + case ioFailure + + public var errorDescription: String? { + switch self { + case .unavailable: "Crisp control socket is unavailable" + case .timeout: "Crisp control request timed out" + case .unsafeSocketPath: "Refusing to replace a non-socket or foreign-owned path" + case .socketPathTooLong: "Unix socket path is too long" + case .addressInUse: "Crisp control socket is already in use" + case .malformedResponse: "Crisp returned malformed JSON" + case .responseProtocolMismatch: "Crisp returned an unsupported protocol version" + case .responseRequestMismatch: "Crisp returned a response for a different request" + case .ioFailure: "Unix socket I/O failed" + } + } +} + +public enum ControlSocket { + public static var defaultPath: String { + FileManager.default.temporaryDirectory + .appendingPathComponent("com.crisp.app", isDirectory: true) + .appendingPathComponent("control.sock").path + } +} + +public enum ControlTimeoutPolicy { + public static let defaultBatchHandlerTimeout: TimeInterval = 11 + public static let displayConfigurationTimeout: TimeInterval = 10 + public static let displayConnectionSettlementTimeout: TimeInterval = 2 + public static let defaultDisplayConnectionHandlerTimeout: TimeInterval = 13 + public static let defaultDisplayConnectionClientTimeout: TimeInterval = 15 + + public static func clientTimeout( + for request: ControlRequest, + standard: TimeInterval, + settlingWrite: TimeInterval, + batch: TimeInterval, + displayConnection: TimeInterval = defaultDisplayConnectionClientTimeout + ) -> TimeInterval { + switch request.mutationKind { + case .brightnessBatch: return batch + case .extraBrightness, .hdr: return settlingWrite + case .displayConnection: return displayConnection + default: return standard + } + } + + public static func handlerTimeout( + for request: ControlRequest, + standard: TimeInterval, + settlingWrite: TimeInterval, + batch: TimeInterval, + displayConnection: TimeInterval = defaultDisplayConnectionHandlerTimeout + ) -> TimeInterval { + switch request.mutationKind { + case .brightnessBatch: return batch + case .extraBrightness, .hdr: return settlingWrite + case .displayConnection: return displayConnection + default: return standard + } + } + + /// Frame receipt keeps the normal absolute slowloris deadline for every command. + public static func receiveTimeout(standard: TimeInterval) -> TimeInterval { standard } +} + +public struct UnixSocketClient: Sendable { + public let path: String + public let timeout: TimeInterval + public let settlingWriteTimeout: TimeInterval + public let batchTimeout: TimeInterval + public let displayConnectionTimeout: TimeInterval + + public init( + path: String = ControlSocket.defaultPath, + timeout: TimeInterval = 3, + settlingWriteTimeout: TimeInterval = 6, + batchTimeout: TimeInterval = 12, + displayConnectionTimeout: TimeInterval = ControlTimeoutPolicy.defaultDisplayConnectionClientTimeout + ) { + self.path = path + self.timeout = timeout + self.settlingWriteTimeout = settlingWriteTimeout + self.batchTimeout = batchTimeout + self.displayConnectionTimeout = displayConnectionTimeout + } + + public func send(_ request: ControlRequest) throws -> ControlResponse { + var data = try ControlJSON.encoder.encode(request) + data.append(0x0A) + return try sendRaw( + data, + expectedRequestID: request.requestID, + timeoutOverride: ControlTimeoutPolicy.clientTimeout( + for: request, standard: timeout, + settlingWrite: settlingWriteTimeout, + batch: batchTimeout, + displayConnection: displayConnectionTimeout + ) + ) + } + + func sendRaw( + _ data: Data, + expectedRequestID: String? = nil, + timeoutOverride: TimeInterval? = nil + ) throws -> ControlResponse { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw IPCError.ioFailure } + defer { Darwin.close(descriptor) } + setNoSigPipe(descriptor) + let deadline = monotonicDeadline(after: timeoutOverride ?? timeout) + + let result = try withSocketAddress(path) { address, length in + Darwin.connect(descriptor, address, length) + } + guard result == 0 else { + if errno == EAGAIN || errno == EWOULDBLOCK || errno == ETIMEDOUT { throw IPCError.timeout } + throw IPCError.unavailable + } + try writeAll(data, to: descriptor, deadline: deadline) + let responseData = try readLine(from: descriptor, deadline: deadline) + guard let response = try? ControlJSON.decoder.decode(ControlResponse.self, from: responseData) else { + throw IPCError.malformedResponse + } + guard response.protocolVersion == crispControlProtocolVersion else { + throw IPCError.responseProtocolMismatch + } + if let expectedRequestID, response.requestID != expectedRequestID { + throw IPCError.responseRequestMismatch + } + return response + } +} + +public final class UnixSocketServer: @unchecked Sendable { + public typealias Handler = @Sendable (ControlRequest) async -> ControlResponse + + private let path: String + private let handler: Handler + private let connectionTimeout: TimeInterval + private let settlingWriteHandlerTimeout: TimeInterval + private let batchHandlerTimeout: TimeInterval + private let displayConnectionHandlerTimeout: TimeInterval + private let maximumConnections: Int + private let queue = DispatchQueue(label: "com.crisp.control.socket") + private let lock = NSLock() + private let connectionLock = NSLock() + private var descriptor: Int32 = -1 + private var activeConnections = 0 + + public init( + path: String = ControlSocket.defaultPath, + connectionTimeout: TimeInterval = 2, + settlingWriteHandlerTimeout: TimeInterval = 5, + batchHandlerTimeout: TimeInterval = ControlTimeoutPolicy.defaultBatchHandlerTimeout, + displayConnectionHandlerTimeout: TimeInterval = ControlTimeoutPolicy.defaultDisplayConnectionHandlerTimeout, + maximumConnections: Int = 16, + handler: @escaping Handler + ) { + self.path = path + self.connectionTimeout = connectionTimeout + self.settlingWriteHandlerTimeout = max(connectionTimeout, settlingWriteHandlerTimeout) + self.batchHandlerTimeout = max(connectionTimeout, batchHandlerTimeout) + self.displayConnectionHandlerTimeout = max(connectionTimeout, displayConnectionHandlerTimeout) + self.maximumConnections = max(1, maximumConnections) + self.handler = handler + } + + deinit { stop() } + + public func start() throws { + try lock.withLock { + guard descriptor < 0 else { return } + let parent = URL(fileURLWithPath: path).deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + guard chmod(parent.path, 0o700) == 0 else { throw IPCError.ioFailure } + try recoverStaleSocket() + + let socketDescriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { throw IPCError.ioFailure } + setNoSigPipe(socketDescriptor) + do { + let bindResult = try withSocketAddress(path) { address, length in + Darwin.bind(socketDescriptor, address, length) + } + guard bindResult == 0 else { + throw errno == EADDRINUSE ? IPCError.addressInUse : IPCError.ioFailure + } + guard chmod(path, 0o600) == 0, Darwin.listen(socketDescriptor, 16) == 0 else { + throw IPCError.ioFailure + } + descriptor = socketDescriptor + } catch { + Darwin.close(socketDescriptor) + _ = unlink(path) + throw error + } + queue.async { [weak self] in self?.acceptLoop(socketDescriptor) } + } + } + + public func stop() { + let oldDescriptor = lock.withLock { () -> Int32 in + let old = descriptor + descriptor = -1 + return old + } + if oldDescriptor >= 0 { + Darwin.shutdown(oldDescriptor, SHUT_RDWR) + Darwin.close(oldDescriptor) + _ = unlink(path) + } + } + + private func acceptLoop(_ listeningDescriptor: Int32) { + while lock.withLock({ descriptor == listeningDescriptor }) { + let client = Darwin.accept(listeningDescriptor, nil, nil) + guard client >= 0 else { + if errno == EINTR { continue } + return + } + setNoSigPipe(client) + let receiveDeadline = monotonicDeadline(after: connectionTimeout) + let accepted = connectionLock.withLock { () -> Bool in + guard activeConnections < maximumConnections else { return false } + activeConnections += 1 + return true + } + guard accepted else { + Darwin.close(client) + continue + } + Task { [handler, connectionTimeout, settlingWriteHandlerTimeout, batchHandlerTimeout, displayConnectionHandlerTimeout] in + defer { + Darwin.close(client) + connectionLock.withLock { activeConnections -= 1 } + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(client, &peerUID, &peerGID) == 0, peerUID == getuid() else { return } + var response: ControlResponse + do { + let data = try readLine(from: client, deadline: receiveDeadline) + do { + let request = try ControlJSON.decoder.decode(ControlRequest.self, from: data) + guard request.protocolVersion == crispControlProtocolVersion else { + response = .failure( + requestID: request.requestID, + code: .protocolMismatch, + message: "unsupported protocol version", + details: .object([ + "received": .number(Double(request.protocolVersion)), + "supported": .number(Double(crispControlProtocolVersion)) + ]) + ) + try writeResponse( + response, + to: client, + deadline: monotonicDeadline(after: connectionTimeout) + ) + return + } + response = await responseBeforeDeadline( + request: request, + timeout: ControlTimeoutPolicy.handlerTimeout( + for: request, standard: connectionTimeout, + settlingWrite: settlingWriteHandlerTimeout, + batch: batchHandlerTimeout, + displayConnection: displayConnectionHandlerTimeout + ), + handler: handler + ) + } catch { + response = .failure( + requestID: requestID(in: data) ?? UUID().uuidString, + code: .malformedRequest, + message: "request is not a valid Crisp control envelope" + ) + } + try writeResponse( + response, + to: client, + deadline: monotonicDeadline(after: connectionTimeout) + ) + } catch { + return + } + } + } + } + + private func recoverStaleSocket() throws { + var info = stat() + guard lstat(path, &info) == 0 else { + if errno == ENOENT { return } + throw IPCError.ioFailure + } + guard info.st_uid == getuid(), info.st_mode & S_IFMT == S_IFSOCK else { + throw IPCError.unsafeSocketPath + } + if (try? socketIsReachable(path)) == true { throw IPCError.addressInUse } + guard unlink(path) == 0 else { throw IPCError.ioFailure } + } +} + +private let maximumMessageBytes = 1_048_576 + +private final class ResponseRace: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var operation: Task? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func setOperation(_ operation: Task) { + let shouldCancel = lock.withLock { () -> Bool in + guard continuation != nil else { return true } + self.operation = operation + return false + } + if shouldCancel { operation.cancel() } + } + + @discardableResult + func resolve(_ response: ControlResponse, cancelOperation: Bool = false) -> Bool { + let state = lock.withLock { () -> (CheckedContinuation, Task?)? in + guard let continuation else { return nil } + self.continuation = nil + let operation = self.operation + self.operation = nil + return (continuation, operation) + } + guard let state else { return false } + if cancelOperation { state.1?.cancel() } + state.0.resume(returning: response) + return true + } +} + +func responseBeforeDeadline( + request: ControlRequest, + timeout: TimeInterval, + monotonicNow: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + }, + handler: @escaping UnixSocketServer.Handler +) async -> ControlResponse { + if request.mutationKind == .displayConnection, + request.exactDisplayConnectionUUID == nil { + return .invalidDisplayConnectionRequest(for: request) + } + let deadline = monotonicDeadline(after: timeout, now: monotonicNow()) + return await withCheckedContinuation { continuation in + let race = ResponseRace(continuation) + let operation = Task { + let response = await handler(request) + let resolvedResponse = monotonicNow() < deadline + ? response + : .timeout(for: request) + _ = race.resolve(resolvedResponse) + } + race.setOperation(operation) + Task { + try? await Task.sleep(for: .seconds(timeout)) + guard !Task.isCancelled else { return } + race.resolve( + .timeout(for: request), + cancelOperation: true + ) + } + } +} + +private func withSocketAddress( + _ path: String, + _ body: (UnsafePointer, socklen_t) throws -> T +) throws -> T { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw IPCError.socketPathTooLong + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + pathBytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + return try withUnsafePointer(to: &address) { + try $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + try body($0, socklen_t(MemoryLayout.size)) + } + } +} + +private func setNoSigPipe(_ descriptor: Int32) { + var enabled: Int32 = 1 + _ = setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enabled, socklen_t(MemoryLayout.size(ofValue: enabled))) +} + +private func monotonicDeadline( + after seconds: TimeInterval, + now: UInt64 = DispatchTime.now().uptimeNanoseconds +) -> UInt64 { + let interval = UInt64(max(0, seconds) * 1_000_000_000) + let (deadline, overflow) = now.addingReportingOverflow(interval) + return overflow ? UInt64.max : deadline +} + +private func setTimeout(_ descriptor: Int32, deadline: UInt64) throws { + let now = DispatchTime.now().uptimeNanoseconds + guard now < deadline else { throw IPCError.timeout } + let remaining = deadline - now + let seconds = remaining / 1_000_000_000 + let microseconds = max(1, (remaining % 1_000_000_000) / 1_000) + var timeout = timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds)) + let size = socklen_t(MemoryLayout.size(ofValue: timeout)) + guard setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, size) == 0, + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, size) == 0 else { + throw IPCError.ioFailure + } +} + +private func writeAll(_ data: Data, to descriptor: Int32, deadline: UInt64) throws { + try data.withUnsafeBytes { bytes in + var sent = 0 + while sent < bytes.count { + try setTimeout(descriptor, deadline: deadline) + let count = Darwin.send(descriptor, bytes.baseAddress!.advanced(by: sent), bytes.count - sent, 0) + guard count > 0 else { + if errno == EAGAIN || errno == EWOULDBLOCK || errno == ETIMEDOUT { throw IPCError.timeout } + throw IPCError.ioFailure + } + sent += count + } + } +} + +private func readLine(from descriptor: Int32, deadline: UInt64) throws -> Data { + var result = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + try setTimeout(descriptor, deadline: deadline) + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count > 0 { + result.append(buffer, count: count) + if let newline = result.firstIndex(of: 0x0A) { + guard newline <= maximumMessageBytes else { throw IPCError.ioFailure } + return result.prefix(upTo: newline) + } + guard result.count <= maximumMessageBytes else { throw IPCError.ioFailure } + } else if count == 0 { + if !result.isEmpty { return result } + throw IPCError.ioFailure + } else if errno == EINTR { + continue + } else if errno == EAGAIN || errno == EWOULDBLOCK || errno == ETIMEDOUT { + throw IPCError.timeout + } else { + throw IPCError.ioFailure + } + } +} + +private func writeResponse(_ response: ControlResponse, to descriptor: Int32, deadline: UInt64) throws { + var data = try ControlJSON.encoder.encode(response) + data.append(0x0A) + try writeAll(data, to: descriptor, deadline: deadline) +} + +private func requestID(in data: Data) -> String? { + (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["requestID"] as? String +} + +private func socketIsReachable(_ path: String) throws -> Bool { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw IPCError.ioFailure } + defer { Darwin.close(descriptor) } + let result = try withSocketAddress(path) { address, length in + Darwin.connect(descriptor, address, length) + } + return result == 0 +} diff --git a/Sources/CrispControlTestHost/main.swift b/Sources/CrispControlTestHost/main.swift new file mode 100644 index 0000000..7f60908 --- /dev/null +++ b/Sources/CrispControlTestHost/main.swift @@ -0,0 +1,204 @@ +import Darwin +import Foundation +import CrispControlCore + +private let fixtureExternalUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + +private actor FixtureService: ControlCommandService { + private var brightness = ["fixture-built-in": 42.0, fixtureExternalUUID: 48.0] + private var boostEnabled = false + private var hdrEnabled = true + private var externalConnected = true + + func displays() async throws -> [ControlDisplay] { + let builtinMax = boostEnabled ? 150.0 : 100.0 + var displays = [ + ControlDisplay( + uuid: "fixture-built-in", name: "Fixture Built-in", isMain: true, isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: builtinMax, precision: 0.1), + readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: builtinMax, precision: 0.1) + ), + brightnessPercent: brightness["fixture-built-in"], + extraBrightness: ExtraBrightnessCapability( + state: .writable, enabled: boostEnabled, persistedEnabled: boostEnabled, + maxBrightness: builtinMax, + headroom: EDRHeadroomSnapshot(potential: 1.6, current: 1.5) + ), + hdr: .unsupported( + reason: "built-in displays do not expose an HDR preference toggle", + remediation: "use Extra Brightness when eligible" + ), + connection: .unsupported( + connected: true, + platformSupported: true, + reason: "fixture built-in is reserved as the remaining viewable display" + ) + ) + ] + if externalConnected { + displays.append( + ControlDisplay( + uuid: fixtureExternalUUID, + name: "Fixture External", + isMain: false, + isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .software, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .unavailable + ), + brightnessPercent: brightness[fixtureExternalUUID], + extraBrightness: .unsupported(reason: "fixture external boost is unavailable"), + hdr: HDRCapability(state: .writable, enabled: hdrEnabled), + connection: DisplayConnectionCapability( + state: .writable, + connected: true, + disconnectAllowed: true, + reconnectAllowed: false, + platformSupported: true, + reason: "fixture built-in remains viewable" + ) + ) + ) + } + return displays + } + + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { + guard !externalConnected else { return [] } + return [ControlDisconnectedDisplay( + uuid: fixtureExternalUUID, + name: "Fixture External", + width: 2560, + height: 1440, + connection: DisplayConnectionCapability( + state: .writable, + connected: false, + disconnectAllowed: false, + reconnectAllowed: true, + platformSupported: true, + reason: "fixture exact UUID is intentionally disconnected" + ) + )] + } + + func disconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + guard displayUUID == fixtureExternalUUID, externalConnected else { + throw DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: displayUUID, + requestedConnectionState: .disconnected, + message: "fixture external UUID is not online" + ) + } + externalConnected = false + return connectionResult(state: .disconnected) + } + + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + guard displayUUID == fixtureExternalUUID, !externalConnected else { + throw DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: displayUUID, + requestedConnectionState: .connected, + message: "fixture UUID is absent from disconnected inventory" + ) + } + externalConnected = true + return connectionResult(state: .connected) + } + + func readBrightness(displayUUID: String) async throws -> Double? { brightness[displayUUID] } + + func readBrightnessState(displayUUID: String) async throws -> BrightnessReadSnapshot? { + guard let logical = brightness[displayUUID] else { return nil } + guard displayUUID != fixtureExternalUUID else { return nil } + return BrightnessReadSnapshot( + logicalPercent: logical, + hardwareReadbackPercent: min(logical, 100) + ) + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + brightness[displayUUID] = percent + return percent + } + + func setExtraBrightness(displayUUID: String, enabled: Bool) async throws -> ExtraBrightnessSetResult { + guard displayUUID == "fixture-built-in" else { + throw ControlServiceError.unsupported("fixture boost unsupported") + } + boostEnabled = enabled + let maximum = enabled ? 150.0 : 100.0 + return ExtraBrightnessSetResult( + capability: ExtraBrightnessCapability( + state: .writable, enabled: enabled, persistedEnabled: enabled, + maxBrightness: maximum, + headroom: EDRHeadroomSnapshot(potential: 1.6, current: 1.5) + ), + verification: .appStateVerified + ) + } + + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult { + guard displayUUID == fixtureExternalUUID else { + throw ControlServiceError.unsupported("fixture HDR unsupported") + } + hdrEnabled = enabled + return HDRSetResult( + capability: HDRCapability(state: .writable, enabled: enabled), + verification: .verified + ) + } + + private func connectionResult(state: DisplayConnectionState) -> DisplayConnectionSetResult { + DisplayConnectionSetResult( + displayUUID: fixtureExternalUUID, + requestedConnectionState: state, + observedConnectionState: state, + verification: .sameUUIDEnumeration + ) + } +} + +guard CommandLine.arguments.count == 2 else { + FileHandle.standardError.write(Data("usage: crisp-control-test-host \n".utf8)) + exit(2) +} + +let dispatcher = ControlCommandDispatcher(service: FixtureService(), appVersion: "test-host") +let server = UnixSocketServer(path: CommandLine.arguments[1]) { request in await dispatcher.handle(request) } +do { + try server.start() +} catch { + FileHandle.standardError.write(Data("test host failed: \(error)\n".utf8)) + exit(1) +} + +private enum FixtureSignalHandlers { + static func make(for server: UnixSocketServer) -> [DispatchSourceSignal] { + signal(SIGINT, SIG_IGN) + signal(SIGTERM, SIG_IGN) + let queue = DispatchQueue(label: "com.crisp.control.test-host.signals") + let sources = [ + DispatchSource.makeSignalSource(signal: SIGINT, queue: queue), + DispatchSource.makeSignalSource(signal: SIGTERM, queue: queue) + ] + for source in sources { + source.setEventHandler { + server.stop() + exit(0) + } + source.resume() + } + return sources + } +} + +let signalSources = FixtureSignalHandlers.make(for: server) +withExtendedLifetime(signalSources) { + dispatchMain() +} diff --git a/Sources/crispctl/main.swift b/Sources/crispctl/main.swift new file mode 100644 index 0000000..43bca3f --- /dev/null +++ b/Sources/crispctl/main.swift @@ -0,0 +1,32 @@ +import Foundation +import CrispControlCLI +import CrispControlCore + +if CommandLine.arguments.dropFirst().contains("--help") || CommandLine.arguments.dropFirst().contains("-h") { + print(crispctlHelp) + exit(0) +} + +let requestID = UUID().uuidString +let response: CLIRunResult +do { + let invocation = try CLIParser(requestID: { requestID }).parse(Array(CommandLine.arguments.dropFirst())) + response = CLIRunner( + transport: UnixSocketClient(path: invocation.socketPath), + launcher: DefaultCrispAppLauncher() + ).run(invocation) +} catch { + response = CLIRunResult(response: .failure( + requestID: requestID, + code: .invalidArguments, + message: (error as? LocalizedError)?.errorDescription ?? "invalid command" + )) +} + +do { + FileHandle.standardOutput.write(Data(try CLIOutput.jsonLine(response.response).utf8)) +} catch { + FileHandle.standardError.write(Data("crispctl: failed to encode JSON response\n".utf8)) + exit(1) +} +exit(response.exitCode) diff --git a/Tests/CrispControlCLITests/CLIParserTests.swift b/Tests/CrispControlCLITests/CLIParserTests.swift new file mode 100644 index 0000000..24bc68f --- /dev/null +++ b/Tests/CrispControlCLITests/CLIParserTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import CrispControlCLI +import CrispControlCore + +final class CLIParserTests: XCTestCase { + private let connectionUUID = "00000000-0000-0000-0000-000000000001" + + func testRequiredCommandsMapToProtocolCommands() throws { + XCTAssertEqual(try parse(["version", "--json"]).request.command, "version") + XCTAssertEqual(try parse(["status", "--json", "--no-start"]).request.command, "status") + XCTAssertEqual(try parse(["displays", "list", "--json"]).request.command, "displays.list") + XCTAssertEqual(try parse(["displays", "get", "main", "--json"]).request.command, "displays.get") + XCTAssertEqual( + try parse(["displays", "capabilities", "builtin", "--json"]).request.command, + "displays.capabilities" + ) + XCTAssertEqual( + try parse(["displays", "disconnected", "--json"]).request.command, + "displays.disconnected" + ) + XCTAssertEqual( + try parse(["displays", "disconnect", connectionUUID, "--json"]).request.command, + "displays.disconnect" + ) + XCTAssertEqual( + try parse([ + "displays", "reconnect", "00000000-0000-0000-0000-000000000001", "--json" + ]).request.command, + "displays.reconnect" + ) + XCTAssertEqual(try parse(["brightness", "get", "uuid-a", "--json"]).request.command, "brightness.get") + XCTAssertEqual(try parse(["brightness", "set", "uuid-a", "57.5", "--json"]).request.command, "brightness.set") + XCTAssertEqual(try parse(["extra-brightness", "get", "uuid-a", "--json"]).request.command, + "extra-brightness.get") + XCTAssertEqual(try parse(["extra-brightness", "set", "uuid-a", "on", "--json"]).request.command, + "extra-brightness.set") + XCTAssertEqual(try parse(["hdr", "get", "uuid-a", "--json"]).request.command, "hdr.get") + XCTAssertEqual(try parse(["hdr", "set", "uuid-a", "off", "--json"]).request.command, "hdr.set") + XCTAssertEqual(try parse(["brightness", "get-all", "--json"]).request.command, "brightness.get-all") + XCTAssertEqual(try parse(["brightness", "set-all", "125", "--json"]).request.command, + "brightness.set-all") + } + + func testSelectorsPercentAndGlobalOptionsArePreserved() throws { + let invocation = try parse([ + "brightness", "set", "main", "57.5", "--no-start", "--socket", "/tmp/test.sock", "--json" + ]) + + XCTAssertEqual(invocation.request.requestID, "fixed-request-id") + XCTAssertEqual(invocation.request.arguments["selector"], .string("main")) + XCTAssertEqual(invocation.request.arguments["percent"], .number(57.5)) + XCTAssertTrue(invocation.noStart) + XCTAssertEqual(invocation.socketPath, "/tmp/test.sock") + } + + func testAllowUnrestorableIsExplicitAndScopedToBrightnessSetAll() throws { + let strict = try parse(["brightness", "set-all", "50", "--json"]) + let override = try parse([ + "brightness", "set-all", "50", "--allow-unrestorable", "--json" + ]) + + XCTAssertNil(strict.request.arguments["allowUnrestorable"]) + XCTAssertEqual(override.request.arguments["allowUnrestorable"], .bool(true)) + XCTAssertThrowsError(try parse(["brightness", "get-all", "--allow-unrestorable", "--json"])) + XCTAssertThrowsError(try parse(["status", "--allow-unrestorable", "--json"])) + XCTAssertTrue(crispctlHelp.contains("brightness set-all --json [--allow-unrestorable]")) + XCTAssertTrue(crispctlHelp.contains("require manual restoration")) + } + + func testMalformedCommandsFailBeforeTransport() { + XCTAssertThrowsError(try parse(["brightness", "set", "main", "NaN", "--json"])) + XCTAssertThrowsError(try parse(["displays", "get", "--json"])) + XCTAssertThrowsError(try parse(["brightness", "delete", "main", "--json"])) + XCTAssertThrowsError(try parse(["status", "--unknown"])) + XCTAssertThrowsError(try parse(["extra-brightness", "set", "main", "yes", "--json"])) + XCTAssertThrowsError(try parse(["extra-brightness", "set", "main", "--json"])) + XCTAssertThrowsError(try parse(["hdr", "set", "main", "1", "--json"])) + XCTAssertThrowsError(try parse(["brightness", "set-all", "NaN", "--json"])) + XCTAssertThrowsError(try parse(["brightness", "get-all", "main", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnected", "main", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnect", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnect", "main", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnect", "builtin", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnect", "Fixture Display", "--json"])) + XCTAssertThrowsError(try parse(["displays", "disconnect", "not-a-uuid", "--json"])) + XCTAssertThrowsError(try parse(["displays", "reconnect", "main", "--json"])) + XCTAssertThrowsError(try parse(["displays", "reconnect", "Fixture Display", "--json"])) + XCTAssertThrowsError(try parse(["displays", "reconnect", "not-a-uuid", "--json"])) + } + + func testToggleArgumentsAndHelpAreExplicit() throws { + let boost = try parse(["extra-brightness", "set", "uuid-a", "on", "--no-start", "--json"]) + let hdr = try parse(["hdr", "set", "uuid-b", "off", "--json"]) + + XCTAssertEqual(boost.request.arguments["selector"], .string("uuid-a")) + XCTAssertEqual(boost.request.arguments["enabled"], .bool(true)) + XCTAssertTrue(boost.noStart) + XCTAssertEqual(hdr.request.arguments["selector"], .string("uuid-b")) + XCTAssertEqual(hdr.request.arguments["enabled"], .bool(false)) + for usage in [ + "extra-brightness get ", "extra-brightness set on|off", + "hdr get ", "hdr set on|off", + "brightness get-all", "brightness set-all " + ] { + XCTAssertTrue(crispctlHelp.contains(usage), "missing help: \(usage)") + } + } + + func testDisplayConnectionArgumentsAndHelpAreExplicit() throws { + let disconnect = try parse([ + "displays", "disconnect", connectionUUID, "--no-start", "--json" + ]) + let reconnect = try parse(["displays", "reconnect", connectionUUID, "--json"]) + + XCTAssertEqual(disconnect.request.arguments["uuid"], .string(connectionUUID)) + XCTAssertNil(disconnect.request.arguments["selector"]) + XCTAssertTrue(disconnect.noStart) + XCTAssertEqual(reconnect.request.arguments["uuid"], .string(connectionUUID)) + let timeout = ControlResponse.timeout(for: disconnect.request) + XCTAssertEqual(timeout.error?.details?["displayUUID"], .string(connectionUUID)) + XCTAssertEqual(timeout.error?.details?["requestedConnectionState"], .string("disconnected")) + XCTAssertEqual(timeout.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(timeout.error?.code.exitCode, 5) + for usage in [ + "displays disconnected", "displays disconnect ", + "displays reconnect " + ] { + XCTAssertTrue(crispctlHelp.contains(usage), "missing help: \(usage)") + } + } + + func testParserDispatcherAndMutationClassifierInventoriesStaySynchronized() throws { + let forms = [ + ["version"], ["status"], ["displays", "list"], + ["displays", "get", "uuid-a"], ["displays", "capabilities", "uuid-a"], + ["displays", "disconnected"], ["displays", "disconnect", connectionUUID], + ["displays", "reconnect", "00000000-0000-0000-0000-000000000001"], + ["brightness", "get", "uuid-a"], ["brightness", "set", "uuid-a", "50"], + ["brightness", "get-all"], ["brightness", "set-all", "50"], + ["extra-brightness", "get", "uuid-a"], ["extra-brightness", "set", "uuid-a", "on"], + ["hdr", "get", "uuid-a"], ["hdr", "set", "uuid-a", "off"] + ] + let requests = try forms.map(parse) + let parserCommands = Set(requests.map(\.request.command)) + let parserMutations = Set(requests.compactMap { request in + request.request.mutationKind == nil ? nil : request.request.command + }) + + XCTAssertEqual(parserCommands, ControlCommandInventory.dispatcherCommands) + XCTAssertEqual(parserMutations, ControlCommandInventory.mutatingCommands) + } + + private func parse(_ arguments: [String]) throws -> CLIInvocation { + try CLIParser(requestID: { "fixed-request-id" }).parse(arguments) + } +} diff --git a/Tests/CrispControlCLITests/CLIRunnerTests.swift b/Tests/CrispControlCLITests/CLIRunnerTests.swift new file mode 100644 index 0000000..967b285 --- /dev/null +++ b/Tests/CrispControlCLITests/CLIRunnerTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import CrispControlCLI +import CrispControlCore + +final class CLIRunnerTests: XCTestCase { + func testNoStartReturnsAppNotRunningWithoutLaunching() { + let transport = MockTransport([.failure(IPCError.unavailable)]) + let launcher = MockLauncher() + let result = CLIRunner(transport: transport, launcher: launcher).run(invocation(noStart: true)) + + XCTAssertEqual(result.response.error?.code, .appNotRunning) + XCTAssertEqual(result.exitCode, ControlErrorCode.appNotRunning.exitCode) + XCTAssertEqual(launcher.launchCount, 0) + } + + func testDefaultPolicyLaunchesThenPollsUntilReady() { + let success = ControlResponse.success(requestID: "req", result: .object(["running": .bool(true)])) + let transport = MockTransport([.failure(IPCError.unavailable), .failure(IPCError.unavailable), .success(success)]) + let launcher = MockLauncher() + let result = CLIRunner( + transport: transport, + launcher: launcher, + readinessTimeout: 0.1, + pollInterval: 0 + ).run(invocation()) + + XCTAssertTrue(result.response.ok) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(launcher.launchCount, 1) + XCTAssertEqual(transport.sendCount, 3) + } + + func testLaunchFailureAndReadinessTimeoutAreStructured() { + let failedLauncher = MockLauncher(error: LauncherError.notFound) + let launchFailure = CLIRunner( + transport: MockTransport([.failure(IPCError.unavailable)]), launcher: failedLauncher + ).run(invocation()) + XCTAssertEqual(launchFailure.response.error?.code, .appLaunchFailed) + + let timeout = CLIRunner( + transport: MockTransport(Array(repeating: .failure(IPCError.unavailable), count: 100)), + launcher: MockLauncher(), + readinessTimeout: 0, + pollInterval: 0 + ).run(invocation()) + XCTAssertEqual(timeout.response.error?.code, .appReadinessTimeout) + } + + func testServerErrorControlsProcessExitCode() { + let response = ControlResponse.failure( + requestID: "req", code: .ambiguousSelector, message: "ambiguous" + ) + let result = CLIRunner( + transport: MockTransport([.success(response)]), launcher: MockLauncher() + ).run(invocation()) + + XCTAssertEqual(result.exitCode, ControlErrorCode.ambiguousSelector.exitCode) + } + + func testResponseIdentityFailuresAreStructuredProtocolErrors() { + for error in [IPCError.responseProtocolMismatch, IPCError.responseRequestMismatch] { + let result = CLIRunner( + transport: MockTransport([.failure(error)]), launcher: MockLauncher() + ).run(invocation()) + + XCTAssertEqual(result.response.error?.code, .protocolMismatch) + XCTAssertEqual(result.exitCode, ControlErrorCode.protocolMismatch.exitCode) + } + } + + func testBrightnessSetClientTimeoutIsIndeterminateAndNotRetrySafe() { + let request = ControlRequest( + requestID: "timed-out-write", + command: "brightness.set", + arguments: ["selector": .string("builtin"), "percent": .number(55)] + ) + let invocation = CLIInvocation(request: request, noStart: true, socketPath: "/tmp/test.sock") + + let result = CLIRunner( + transport: MockTransport([.failure(IPCError.timeout)]), launcher: MockLauncher() + ).run(invocation) + + XCTAssertEqual(result.response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(result.response.error?.details?["selector"], .string("builtin")) + XCTAssertEqual(result.response.error?.details?["targetPercent"], .number(55)) + XCTAssertEqual(result.response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(result.exitCode, ControlErrorCode.writeOutcomeIndeterminate.exitCode) + } + + func testEveryP0MutationTimeoutIsIndeterminateWithTargetAndNoRetry() { + let cases: [MutationTimeoutCase] = [ + MutationTimeoutCase( + command: "displays.disconnect", + arguments: [ + "uuid": .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + ], + targetKey: "requestedConnectionState", + targetValue: .string("disconnected") + ), + MutationTimeoutCase( + command: "displays.reconnect", + arguments: [ + "uuid": .string("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + ], + targetKey: "requestedConnectionState", + targetValue: .string("connected") + ), + MutationTimeoutCase( + command: "extra-brightness.set", + arguments: ["selector": .string("uuid-a"), "enabled": .bool(true)], + targetKey: "targetEnabled", + targetValue: .bool(true) + ), + MutationTimeoutCase( + command: "hdr.set", + arguments: ["selector": .string("uuid-b"), "enabled": .bool(false)], + targetKey: "targetEnabled", + targetValue: .bool(false) + ), + MutationTimeoutCase( + command: "brightness.set-all", + arguments: ["percent": .number(125)], + targetKey: "targetPercent", + targetValue: .number(125) + ) + ] + + for testCase in cases { + let request = ControlRequest( + requestID: testCase.command, + command: testCase.command, + arguments: testCase.arguments + ) + let invocation = CLIInvocation(request: request, noStart: true, socketPath: "/tmp/test.sock") + let result = CLIRunner( + transport: MockTransport([.failure(IPCError.timeout)]), launcher: MockLauncher() + ).run(invocation) + + XCTAssertEqual(result.response.error?.code, .writeOutcomeIndeterminate, testCase.command) + XCTAssertEqual(result.response.error?.details?["retrySafe"], .bool(false), testCase.command) + XCTAssertEqual( + result.response.error?.details?[testCase.targetKey], + testCase.targetValue, + testCase.command + ) + if testCase.command == "brightness.set-all" { + XCTAssertEqual(result.response.error?.details?["target"], .string("all_physical_displays")) + } else if testCase.command.hasPrefix("displays.") { + XCTAssertNotNil(result.response.error?.details?["displayUUID"]) + } else { + XCTAssertNotNil(result.response.error?.details?["selector"]) + } + XCTAssertEqual(result.exitCode, 5) + } + } + + func testReadTimeoutRemainsOrdinaryTimeout() { + let request = ControlRequest(requestID: "read-timeout", command: "brightness.get-all") + let result = CLIRunner( + transport: MockTransport([.failure(IPCError.timeout)]), launcher: MockLauncher() + ).run(CLIInvocation(request: request, noStart: true, socketPath: "/tmp/test.sock")) + + XCTAssertEqual(result.response.error?.code, .timeout) + XCTAssertEqual(result.exitCode, ControlErrorCode.timeout.exitCode) + } + + func testJSONOutputIsOneValueWithTrailingNewline() throws { + let response = ControlResponse.success(requestID: "req", result: .object(["b": .number(2), "a": .number(1)])) + let output = try CLIOutput.jsonLine(response) + + XCTAssertTrue(output.hasSuffix("\n")) + XCTAssertEqual(output.filter { $0 == "\n" }.count, 1) + XCTAssertTrue(output.contains("\"a\":1,\"b\":2")) + } + + private func invocation(noStart: Bool = false) -> CLIInvocation { + CLIInvocation(request: ControlRequest(requestID: "req", command: "status"), + noStart: noStart, socketPath: "/tmp/test.sock") + } +} + +private struct MutationTimeoutCase { + let command: String + let arguments: [String: JSONValue] + let targetKey: String + let targetValue: JSONValue +} + +private final class MockTransport: ControlTransport, @unchecked Sendable { + private let lock = NSLock() + private var results: [Result] + private(set) var sendCount = 0 + + init(_ results: [Result]) { self.results = results } + + func send(_ request: ControlRequest) throws -> ControlResponse { + try lock.withLock { + sendCount += 1 + guard !results.isEmpty else { throw IPCError.unavailable } + return try results.removeFirst().get() + } + } +} + +private final class MockLauncher: AppLaunching, @unchecked Sendable { + private let lock = NSLock() + private let error: Error? + private(set) var launchCount = 0 + + init(error: Error? = nil) { self.error = error } + + func launch() throws { + try lock.withLock { + launchCount += 1 + if let error { throw error } + } + } +} diff --git a/Tests/CrispControlCoreTests/AppliedFactorCommitCoordinatorTests.swift b/Tests/CrispControlCoreTests/AppliedFactorCommitCoordinatorTests.swift new file mode 100644 index 0000000..4da00c0 --- /dev/null +++ b/Tests/CrispControlCoreTests/AppliedFactorCommitCoordinatorTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import CrispControlCore + +final class AppliedFactorCommitCoordinatorTests: XCTestCase { + func testDelayedExternalWriteIsUnknownUntilQueueReportsCommit() { + var coordinator = AppliedFactorCommitCoordinator() + let token = coordinator.begin(uuid: "uuid-a", identity: "object-a", factor: 1.4) + + XCTAssertNil(coordinator.appliedFactor(uuid: "uuid-a", identity: "object-a")) + XCTAssertTrue(coordinator.complete( + token, + queueAccepted: true, + currentUUID: "uuid-a", + currentIdentity: "object-a" + )) + XCTAssertEqual(coordinator.appliedFactor(uuid: "uuid-a", identity: "object-a"), 1.4) + } + + func testUUIDRejectedQueuedWriteDoesNotPublishFactorOrSatisfyIdentityBarrier() { + var coordinator = AppliedFactorCommitCoordinator() + let token = coordinator.begin(uuid: "uuid-a", identity: "object-a", factor: 1) + + XCTAssertFalse(coordinator.complete( + token, + queueAccepted: false, + currentUUID: "uuid-b", + currentIdentity: "object-b" + )) + XCTAssertNil(coordinator.appliedFactor(uuid: "uuid-a", identity: "object-a")) + XCTAssertFalse(coordinator.isCommitted( + factor: 1, + uuid: "uuid-a", + identity: "object-a", + tolerance: 0.001 + )) + } + + func testNewerCollapseOrReapplyWriteRejectsDelayedOlderCompletion() { + var coordinator = AppliedFactorCommitCoordinator() + let collapse = coordinator.begin(uuid: "uuid-a", identity: "object-a", factor: 1.2) + let reapply = coordinator.begin(uuid: "uuid-a", identity: "object-a", factor: 1) + + XCTAssertFalse(coordinator.complete( + collapse, + queueAccepted: true, + currentUUID: "uuid-a", + currentIdentity: "object-a" + )) + XCTAssertNil(coordinator.appliedFactor(uuid: "uuid-a", identity: "object-a")) + XCTAssertTrue(coordinator.complete( + reapply, + queueAccepted: true, + currentUUID: "uuid-a", + currentIdentity: "object-a" + )) + XCTAssertTrue(coordinator.isCommitted( + factor: 1, + uuid: "uuid-a", + identity: "object-a", + tolerance: 0.001 + )) + } +} diff --git a/Tests/CrispControlCoreTests/BoostTransitionCoordinatorTests.swift b/Tests/CrispControlCoreTests/BoostTransitionCoordinatorTests.swift new file mode 100644 index 0000000..36aa029 --- /dev/null +++ b/Tests/CrispControlCoreTests/BoostTransitionCoordinatorTests.swift @@ -0,0 +1,132 @@ +import XCTest +@testable import CrispControlCore + +final class BoostTransitionCoordinatorTests: XCTestCase { + func testControlDisableOutcomeSeparatesRejectionSettlingAndUnknown() { + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: false, + operationCompleted: false, + identityMatches: true, + persistedEnabled: false, + liveEnabled: false, + maxBrightness: 100, + cleanupInProgress: false + ), + .rejectedBeforeAcceptance + ) + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: false, + identityMatches: true, + persistedEnabled: false, + liveEnabled: true, + maxBrightness: 150, + cleanupInProgress: true + ), + .settling + ) + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: false, + identityMatches: false, + persistedEnabled: false, + liveEnabled: true, + maxBrightness: 150, + cleanupInProgress: true + ), + .indeterminate + ) + } + + func testControlDisableOutcomeRequiresTerminalCompletionForAcceptedResult() { + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: true, + identityMatches: true, + persistedEnabled: false, + liveEnabled: false, + maxBrightness: 100, + cleanupInProgress: false + ), + .accepted + ) + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: false, + identityMatches: true, + persistedEnabled: false, + liveEnabled: false, + maxBrightness: 100, + cleanupInProgress: false + ), + .indeterminate + ) + } + + func testIncompleteDisableWithoutCleanupOwnershipIsIndeterminate() { + XCTAssertEqual( + ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: false, + identityMatches: true, + persistedEnabled: false, + liveEnabled: true, + maxBrightness: 150, + cleanupInProgress: false + ), + .indeterminate + ) + } + + func testDisableDoesNotCompleteBeforeCollapseReachesIdentity() { + var coordinator = BoostTransitionCoordinator() + let token = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: false) + + XCTAssertEqual(coordinator.phase(uuid: "uuid-a", identity: "object-a"), .collapsing) + XCTAssertFalse(coordinator.completeDisable(token, atIdentity: false)) + XCTAssertEqual(coordinator.phase(uuid: "uuid-a", identity: "object-a"), .collapsing) + XCTAssertTrue(coordinator.completeDisable(token, atIdentity: true)) + XCTAssertEqual(coordinator.phase(uuid: "uuid-a", identity: "object-a"), .disabled) + } + + func testRapidDisableThenEnableCannotRunStaleFinishDisable() { + var coordinator = BoostTransitionCoordinator() + let staleDisable = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: false) + let enable = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: true) + + XCTAssertFalse(coordinator.completeDisable(staleDisable, atIdentity: true)) + XCTAssertTrue(coordinator.completeEnable(enable)) + XCTAssertEqual(coordinator.phase(uuid: "uuid-a", identity: "object-a"), .enabled) + } + + func testRapidEnableThenDisableEndsDisabled() { + var coordinator = BoostTransitionCoordinator() + let staleEnable = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: true) + let disable = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: false) + + XCTAssertFalse(coordinator.completeEnable(staleEnable)) + XCTAssertTrue(coordinator.completeDisable(disable, atIdentity: true)) + XCTAssertEqual(coordinator.phase(uuid: "uuid-a", identity: "object-a"), .disabled) + } + + func testDisconnectDuringCollapseCannotMutateReusedDisplayIdentity() { + var coordinator = BoostTransitionCoordinator() + let stale = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: false) + coordinator.invalidate(uuid: "uuid-a", identity: "object-a") + _ = coordinator.begin(uuid: "uuid-b", identity: "object-b", enabled: true) + + XCTAssertFalse(coordinator.accepts(stale, currentUUID: "uuid-b", currentIdentity: "object-b")) + XCTAssertFalse(coordinator.completeDisable(stale, atIdentity: true)) + } + + func testHeadroomPollCannotOverrideCollapse() { + var coordinator = BoostTransitionCoordinator() + _ = coordinator.begin(uuid: "uuid-a", identity: "object-a", enabled: false) + XCTAssertFalse(coordinator.headroomMaySync(uuid: "uuid-a", identity: "object-a")) + } +} diff --git a/Tests/CrispControlCoreTests/BrightnessHeartbeatControllerTests.swift b/Tests/CrispControlCoreTests/BrightnessHeartbeatControllerTests.swift new file mode 100644 index 0000000..0331358 --- /dev/null +++ b/Tests/CrispControlCoreTests/BrightnessHeartbeatControllerTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import CrispControlCore + +final class BrightnessHeartbeatControllerTests: XCTestCase { + @MainActor + func testControllerRunsProductionHeartbeatAndRejectsOverlap() async { + final class Model { + let isBuiltin = true + var brightness = 80.0 + } + let model = Model() + let gate = AsyncGate() + let controller = BrightnessHeartbeatController() + let started = expectation(description: "refresh entered single-flight pass") + + XCTAssertTrue(controller.schedule( + displays: [model], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: nil, isBuiltin: { $0.isBuiltin }, prepare: {} + ) { display in + started.fulfill() + await gate.wait() + display.brightness = 50 + }) + await fulfillment(of: [started], timeout: 1) + XCTAssertTrue(controller.isRefreshing) + XCTAssertFalse(controller.schedule( + displays: [model], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: nil, isBuiltin: { $0.isBuiltin }, prepare: {} + ) { _ in XCTFail("overlapping pass must not start") }) + + await gate.open() + await controller.waitUntilIdle() + XCTAssertEqual(model.brightness, 50) + XCTAssertFalse(controller.isRefreshing) + } + + @MainActor + func testImmediateCancelBeforeTaskStartsSkipsPrepareAndRefresh() async { + final class Counters { + var prepared = 0 + var refreshed = 0 + } + let counters = Counters() + let controller = BrightnessHeartbeatController() + + XCTAssertTrue(controller.schedule( + displays: [true], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: nil, isBuiltin: { $0 } + ) { + counters.prepared += 1 + } refresh: { _ in + counters.refreshed += 1 + }) + controller.cancel() + await controller.waitUntilIdle() + + XCTAssertEqual(counters.prepared, 0) + XCTAssertEqual(counters.refreshed, 0) + } + + @MainActor + func testCancelKeepsSingleFlightOccupiedUntilUncancellableRefreshReturns() async { + let gate = AsyncGate() + let controller = BrightnessHeartbeatController() + let started = expectation(description: "refresh entered callback-backed operation") + + XCTAssertTrue(controller.schedule( + displays: [true], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: nil, isBuiltin: { $0 }, prepare: {} + ) { _ in + started.fulfill() + await gate.wait() + }) + await fulfillment(of: [started], timeout: 1) + controller.cancel() + + XCTAssertFalse(controller.schedule( + displays: [true], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: nil, isBuiltin: { $0 }, prepare: {} + ) { _ in XCTFail("a reopened panel must not overlap the cancelled pass") }) + + await gate.open() + await controller.waitUntilIdle() + XCTAssertFalse(controller.isRefreshing) + } +} + +private actor AsyncGate { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation = $0 } + } + + func open() { + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/CrispControlCoreTests/BrightnessPollPolicyTests.swift b/Tests/CrispControlCoreTests/BrightnessPollPolicyTests.swift new file mode 100644 index 0000000..98f67f9 --- /dev/null +++ b/Tests/CrispControlCoreTests/BrightnessPollPolicyTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import CrispControlCore + +final class BrightnessPollPolicyTests: XCTestCase { + func testBuiltinIsAlwaysPolledWhilePanelHeartbeatRuns() { + XCTAssertTrue(BrightnessPollPolicy.shouldRefresh(isBuiltin: true, autoBrightnessEnabled: false)) + XCTAssertTrue(BrightnessPollPolicy.shouldRefresh(isBuiltin: true, autoBrightnessEnabled: true)) + } + + func testExternalPollingStillYieldsToAutoBrightness() { + XCTAssertTrue(BrightnessPollPolicy.shouldRefresh(isBuiltin: false, autoBrightnessEnabled: false)) + XCTAssertFalse(BrightnessPollPolicy.shouldRefresh(isBuiltin: false, autoBrightnessEnabled: true)) + } + + @MainActor + func testHeartbeatRefreshesBuiltinAndUpdatesUIFacingModel() async { + final class Model { + let isBuiltin: Bool + var brightness: Double + init(isBuiltin: Bool, brightness: Double) { + self.isBuiltin = isBuiltin + self.brightness = brightness + } + } + let builtin = Model(isBuiltin: true, brightness: 80) + let external = Model(isBuiltin: false, brightness: 40) + + var prepareCount = 0 + await BrightnessHeartbeat.refresh( + displays: [builtin, external], + panelVisible: true, + autoBrightnessEnabled: true, + lastManualAdjustment: nil, + now: Date(), + isBuiltin: { $0.isBuiltin }, + prepare: { prepareCount += 1 }, + refresh: { display in display.brightness = display.isBuiltin ? 50 : 10 } + ) + + XCTAssertEqual(prepareCount, 1) + XCTAssertEqual(builtin.brightness, 50) + XCTAssertEqual(external.brightness, 40) + } + + @MainActor + func testHeartbeatSuppressesRefreshWhileHiddenOrDuringManualAdjustmentWindow() async { + var refreshCount = 0 + let now = Date() + + await BrightnessHeartbeat.refresh( + displays: [true], panelVisible: false, autoBrightnessEnabled: false, + lastManualAdjustment: nil, now: now, + isBuiltin: { $0 }, refresh: { _ in refreshCount += 1 } + ) + await BrightnessHeartbeat.refresh( + displays: [true], panelVisible: true, autoBrightnessEnabled: false, + lastManualAdjustment: now.addingTimeInterval(-2.9), now: now, + isBuiltin: { $0 }, refresh: { _ in refreshCount += 1 } + ) + + XCTAssertEqual(refreshCount, 0) + } +} diff --git a/Tests/CrispControlCoreTests/BrightnessWriteCommitTests.swift b/Tests/CrispControlCoreTests/BrightnessWriteCommitTests.swift new file mode 100644 index 0000000..15e6d49 --- /dev/null +++ b/Tests/CrispControlCoreTests/BrightnessWriteCommitTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import CrispControlCore + +final class BrightnessWriteCommitTests: XCTestCase { + @MainActor + func testFailedWriteDoesNotCommitManualAdjustmentSideEffects() async { + var committed = false + + do { + _ = try await BrightnessWriteCommit.perform( + write: { throw TestWriteError.rejected }, + commit: { (_: Double) in committed = true } + ) as Double + XCTFail("expected backend rejection") + } catch { + XCTAssertEqual(error as? TestWriteError, .rejected) + } + + XCTAssertFalse(committed) + } + + @MainActor + func testSuccessfulWriteCommitsExactlyOnce() async throws { + var committedValues: [Double] = [] + + let value = try await BrightnessWriteCommit.perform( + write: { 42.0 }, + commit: { committedValues.append($0) } + ) + + XCTAssertEqual(value, 42) + XCTAssertEqual(committedValues, [42]) + } + + @MainActor + func testCancellationDuringWritePreventsCommit() async { + var committed = false + let task = Task { + try await BrightnessWriteCommit.perform( + write: { + try? await Task.sleep(for: .seconds(5)) + return 42.0 + }, + commit: { _ in committed = true } + ) + } + await Task.yield() + task.cancel() + + do { + _ = try await task.value + XCTFail("expected cancellation") + } catch is CancellationError { + // Expected: an in-flight backend may have changed hardware, but + // no success-only model/preset side effects may be committed. + } catch { + XCTFail("unexpected error: \(error)") + } + XCTAssertFalse(committed) + } +} + +private enum TestWriteError: Error, Equatable { + case rejected +} diff --git a/Tests/CrispControlCoreTests/CommandDispatcherTests.swift b/Tests/CrispControlCoreTests/CommandDispatcherTests.swift new file mode 100644 index 0000000..e920fc3 --- /dev/null +++ b/Tests/CrispControlCoreTests/CommandDispatcherTests.swift @@ -0,0 +1,1400 @@ +import XCTest +@testable import CrispControlCore + +final class CommandDispatcherTests: XCTestCase { + func testReadOnlyCommandsReturnDeterministicDisplayData() async throws { + let service = MockControlService(displays: [.builtin]) + let dispatcher = ControlCommandDispatcher(service: service, appVersion: "1.5.0") + + let version = await dispatcher.handle(ControlRequest(requestID: "v", command: "version")) + let status = await dispatcher.handle(ControlRequest(requestID: "s", command: "status")) + let list = await dispatcher.handle(ControlRequest(requestID: "l", command: "displays.list")) + let get = await dispatcher.handle(request("displays.get", selector: "main")) + let capabilities = await dispatcher.handle(request("displays.capabilities", selector: "builtin")) + let brightness = await dispatcher.handle(request("brightness.get", selector: "uuid-built-in")) + + XCTAssertEqual(version.result?["appVersion"], .string("1.5.0")) + XCTAssertEqual(status.result?["running"], .bool(true)) + XCTAssertEqual(list.result?["displays"]?[0]?["uuid"], .string("uuid-built-in")) + XCTAssertEqual(get.result?["display"]?["name"], .string("Built-in Display")) + XCTAssertEqual(capabilities.result?["brightness"]?["backend"], .string("DisplayServices")) + XCTAssertEqual(brightness.result?["percent"], .number(42)) + } + + func testBrightnessSetWritesThenReadsBackBeforeSuccess() async throws { + let service = MockControlService(displays: [.builtin], readValues: [42, 50.04]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "builtin", percent: 50) + ) + + XCTAssertTrue(response.ok) + let writes = await service.writes + XCTAssertEqual(writes, [50]) + XCTAssertEqual(response.result?["requestedPercent"], .number(50)) + XCTAssertEqual(response.result?["appliedPercent"], .number(50)) + XCTAssertEqual(response.result?["readbackPercent"], .number(50.04)) + XCTAssertEqual(response.result?["verification"], .string("verified")) + XCTAssertEqual(response.result?["backend"], .string("DisplayServices")) + } + + func testBrightnessSetRejectsOutOfCapabilityRangeWithoutWriting() async throws { + let service = MockControlService(displays: [.builtin]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "builtin", percent: 101) + ) + + XCTAssertEqual(response.error?.code, .invalidArguments) + let writes = await service.writes + XCTAssertEqual(writes, []) + } + + func testUnsupportedBrightnessIsAnError() async throws { + let service = MockControlService(displays: [.unsupportedExternal]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.get", selector: "uuid-external") + ) + + XCTAssertEqual(response.error?.code, .unsupportedCapability) + XCTAssertEqual(response.error?.details?["reason"], .string("No controllable backend")) + } + + func testReadOnlyCapabilityRejectsSetWithoutWriting() async throws { + let service = MockControlService(displays: [.readOnlyExternal]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "uuid-read-only", percent: 30) + ) + + XCTAssertEqual(response.error?.code, .unsupportedCapability) + let writes = await service.writes + XCTAssertEqual(writes, []) + } + + func testAmbiguousSelectorReturnsCandidates() async throws { + let service = MockControlService(displays: [.deskA, .deskB]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("displays.get", selector: "Desk") + ) + + XCTAssertEqual(response.error?.code, .ambiguousSelector) + XCTAssertEqual(response.error?.details?["candidates"]?[0]?["uuid"], .string("uuid-a")) + XCTAssertEqual(response.error?.details?["candidates"]?[1]?["uuid"], .string("uuid-b")) + } + + func testWriteVerificationFailureNeverReturnsSuccess() async throws { + let service = MockControlService(displays: [.builtin], readValues: [42, 43]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "builtin", percent: 50) + ) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, .writeVerificationFailed) + XCTAssertEqual(response.error?.details?["requestedPercent"], .number(50)) + XCTAssertEqual(response.error?.details?["readbackPercent"], .number(43)) + } + + func testUnavailableReadbackIsReportedWithoutFalsePrecision() async throws { + let service = MockControlService(displays: [.softwareExternal], readValues: [nil]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "uuid-software", percent: 30) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["verification"], .string("unavailable")) + XCTAssertEqual(response.result?["readbackPercent"], .null) + XCTAssertNotNil(response.result?["warnings"]) + } + + func testExtraBrightnessGetSetAndCapabilitiesExposeLiveDynamicTruth() async throws { + let service = MockControlService(displays: [.boostedBuiltin]) + let dispatcher = ControlCommandDispatcher(service: service, appVersion: "1.5.0") + + let get = await dispatcher.handle(request("extra-brightness.get", selector: "uuid-boosted")) + let set = await dispatcher.handle(toggleRequest("extra-brightness.set", selector: "uuid-boosted", enabled: true)) + let capabilities = await dispatcher.handle(request("displays.capabilities", selector: "uuid-boosted")) + + XCTAssertEqual(get.result?["enabled"], .bool(true)) + XCTAssertEqual(get.result?["persistedEnabled"], .bool(true)) + XCTAssertEqual(get.result?["maxBrightness"], .number(150)) + XCTAssertEqual(get.result?["headroom"]?["potential"], .number(1.7)) + XCTAssertEqual(set.result?["verification"], .string("app_state_verified")) + XCTAssertEqual(set.result?["maxBrightness"], .number(150)) + XCTAssertEqual(capabilities.result?["extraBrightness"]?["state"], .string("writable")) + XCTAssertEqual(capabilities.result?["brightness"]?["hardwareRange"]?["max"], .number(100)) + XCTAssertEqual(capabilities.result?["brightness"]?["logicalRange"]?["max"], .number(150)) + let boostWrites = await service.extraBrightnessWrites + XCTAssertEqual(boostWrites, [true]) + } + + func testHDRGetSetAndBuiltinUnsupportedAreDistinct() async throws { + let externalService = MockControlService(displays: [.hdrExternal]) + let externalDispatcher = ControlCommandDispatcher(service: externalService, appVersion: "1.5.0") + let get = await externalDispatcher.handle(request("hdr.get", selector: "uuid-hdr")) + let set = await externalDispatcher.handle(toggleRequest("hdr.set", selector: "uuid-hdr", enabled: false)) + + XCTAssertEqual(get.result?["enabled"], .bool(true)) + XCTAssertEqual(set.result?["verification"], .string("verified")) + let externalHDRWrites = await externalService.hdrWrites + XCTAssertEqual(externalHDRWrites, [false]) + + let builtinService = MockControlService(displays: [.boostedBuiltin]) + let unsupported = await ControlCommandDispatcher(service: builtinService, appVersion: "1.5.0").handle( + toggleRequest("hdr.set", selector: "uuid-boosted", enabled: true) + ) + XCTAssertEqual(unsupported.error?.code, .unsupportedCapability) + XCTAssertTrue(unsupported.error?.details?["remediation"] == .string("use Extra Brightness when eligible")) + let builtinHDRWrites = await builtinService.hdrWrites + XCTAssertEqual(builtinHDRWrites, []) + } + + func testExtraBrightnessOffCleansUpAfterLiveEligibilityCollapses() async throws { + let cleanupDisplay = ControlDisplay( + uuid: "uuid-stale-boost", + name: "Stale Boost", + isMain: true, + isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, + backend: .displayServices, + range: ControlRange(min: 0, max: 150, precision: 0.1), + readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: 150, precision: 0.1) + ), + brightnessPercent: 120, + extraBrightness: .unsupported( + enabled: true, + persistedEnabled: true, + maxBrightness: 150, + reason: "live EDR eligibility collapsed" + ) + ) + let service = MockControlService(displays: [cleanupDisplay]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + toggleRequest( + "extra-brightness.set", selector: "uuid-stale-boost", enabled: false + ) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["requestedEnabled"], .bool(false)) + XCTAssertEqual(response.result?["persistedEnabled"], .bool(false)) + XCTAssertEqual(response.result?["maxBrightness"], .number(100)) + let writes = await service.extraBrightnessWrites + XCTAssertEqual(writes, [false]) + } + + func testUnsupportedExtraBrightnessStillRejectsEnableAndUnneededOff() async throws { + for enabled in [true, false] { + let service = MockControlService(displays: [.hdrExternal]) + let response = await ControlCommandDispatcher( + service: service, appVersion: "1.5.0" + ).handle(toggleRequest( + "extra-brightness.set", selector: "uuid-hdr", enabled: enabled + )) + + XCTAssertEqual(response.error?.code, .unsupportedCapability) + let writes = await service.extraBrightnessWrites + XCTAssertEqual(writes, []) + } + } + + func testReadableButNotWritableHDROffRemainsUnsupported() async throws { + let display = ControlDisplay( + uuid: "uuid-readable-hdr", + name: "Readable HDR", + isMain: false, + isBuiltin: false, + brightness: .unsupported(reason: "not relevant"), + hdr: HDRCapability( + state: .readable, + enabled: true, + reason: "MonitorPanel setter ABI is unavailable" + ) + ) + let service = MockControlService(displays: [display]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + toggleRequest("hdr.set", selector: "uuid-readable-hdr", enabled: false) + ) + + XCTAssertEqual(response.error?.code, .unsupportedCapability) + let writes = await service.hdrWrites + XCTAssertEqual(writes, []) + } + + func testBoostedBrightnessSetPreservesLogical150AndReportsHardware100Separately() async throws { + let service = MockControlService( + displays: [.boostedBuiltin], + brightnessStates: [ + BrightnessReadSnapshot(logicalPercent: 120, hardwareReadbackPercent: 100), + BrightnessReadSnapshot(logicalPercent: 150, hardwareReadbackPercent: 100), + BrightnessReadSnapshot(logicalPercent: 150, hardwareReadbackPercent: 100) + ] + ) + let dispatcher = ControlCommandDispatcher(service: service, appVersion: "1.5.0") + let response = await dispatcher.handle(request("brightness.set", selector: "uuid-boosted", percent: 150)) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["appliedPercent"], .number(150)) + XCTAssertEqual(response.result?["logicalPercent"], .number(150)) + XCTAssertEqual(response.result?["hardwareReadbackPercent"], .number(100)) + XCTAssertEqual(response.result?["verification"], .string("app_state_verified")) + XCTAssertNotNil(response.result?["warnings"]) + } + + func testBoostedBrightnessGetDoesNotCollapseToPhysicalReadback() async throws { + let service = MockControlService( + displays: [.boostedBuiltin], + brightnessStates: [BrightnessReadSnapshot(logicalPercent: 150, hardwareReadbackPercent: 100)] + ) + let get = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.get", selector: "uuid-boosted") + ) + + XCTAssertEqual(get.result?["percent"], .number(150)) + XCTAssertEqual(get.result?["logicalPercent"], .number(150)) + XCTAssertEqual(get.result?["hardwareReadbackPercent"], .number(100)) + } + + func testCompatibilityReadDoesNotAliasLogicalBoostToHardwareReadback() async throws { + let service = LegacyLogicalOnlyControlService(display: .boostedBuiltin, logicalPercent: 150) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.get", selector: "uuid-boosted") + ) + + XCTAssertEqual(response.result?["logicalPercent"], .number(150)) + XCTAssertEqual(response.result?["hardwareReadbackPercent"], .null) + } + + func testBoostedSetFailsClosedWhenDynamicMaximumDropsBelowRequest() async throws { + let service = MockControlService(displays: [.boostedBuiltin]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "uuid-boosted", percent: 150.01) + ) + + XCTAssertEqual(response.error?.code, .invalidArguments) + let writes = await service.writes + XCTAssertEqual(writes, []) + } + + func testOrdinaryBrightnessResponseSchemaRemainsCompatibleAtOrBelow100() async throws { + let service = MockControlService(displays: [.builtin], readValues: [42, 50]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + request("brightness.set", selector: "builtin", percent: 50) + ) + + XCTAssertEqual(response.result?["requestedPercent"], .number(50)) + XCTAssertEqual(response.result?["appliedPercent"], .number(50)) + XCTAssertEqual(response.result?["readbackPercent"], .number(50)) + XCTAssertEqual(response.result?["logicalPercent"], .number(50)) + XCTAssertEqual(response.result?["hardwareReadbackPercent"], .number(50)) + XCTAssertEqual(response.result?["verification"], .string("verified")) + } + + func testBrightnessGetAllIsUUIDSortedAndExcludesVirtualDisplays() async throws { + let service = BatchControlService(displays: [.physicalB, .virtual, .physicalA]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "all", command: "brightness.get-all") + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["displays"]?[0]?["displayUUID"], .string("uuid-a")) + XCTAssertEqual(response.result?["displays"]?[1]?["displayUUID"], .string("uuid-b")) + XCTAssertNil(response.result?["displays"]?[2]) + XCTAssertEqual(response.result?["semantics"], .string("same_logical_percent_per_display")) + } + + func testBrightnessBatchEmptyInventoryIsStructured() async throws { + let service = BatchControlService(displays: [.virtual]) + for command in ["brightness.get-all", "brightness.set-all"] { + let arguments: [String: JSONValue] = command.hasSuffix("set-all") ? ["percent": .number(50)] : [:] + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: command, command: command, arguments: arguments) + ) + XCTAssertEqual(response.error?.code, .emptyPhysicalInventory) + } + } + + func testBrightnessSetAllPreflightsEveryDisplayBeforeAnyWrite() async throws { + let service = BatchControlService(displays: [.physicalA, .physicalB]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "preflight", command: "brightness.set-all", + arguments: ["percent": .number(125)]) + ) + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["failedUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["displayUUID"], .string("uuid-a")) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["attempted"], .bool(false)) + XCTAssertEqual(response.error?.details?["outcomes"]?[1]?["displayUUID"], .string("uuid-b")) + XCTAssertEqual(response.error?.details?["outcomes"]?[1]?["attempted"], .bool(false)) + let writes = await service.writes + XCTAssertTrue(writes.isEmpty) + } + + func testBatchSnapshotDeadlineFailsPreflightWithoutMakingWritesIndeterminate() async throws { + let service = DelayedSnapshotBatchControlService(displays: [.physicalB, .physicalA]) + let response = await ControlCommandDispatcher( + service: service, appVersion: "1.5.0", batchExecutionTimeout: 0.03 + ).handle(ControlRequest( + requestID: "snapshot-deadline", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true)) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["attempted"], .bool(false)) + XCTAssertEqual(response.error?.details?["outcomes"]?[1]?["attempted"], .bool(false)) + let writes = await service.writes + XCTAssertTrue(writes.isEmpty) + } + + func testBatchMonotonicDeadlineStartsBeforeInventoryDiscovery() async throws { + let clock = ManualMonotonicClock(now: 100) + let service = AdvancingClockBatchControlService( + displays: [.physicalA], clock: clock, inventoryAdvance: 6, snapshotAdvance: 0 + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchExecutionTimeout: 5, + batchMonotonicNow: clock.now + ).handle(ControlRequest( + requestID: "inventory-budget", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["phase"], .string("inventory")) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true)) + let inventoryWrites = await service.writes + XCTAssertEqual(inventoryWrites, []) + } + + func testInventoryAndSnapshotsConsumeOneMonotonicBatchBudget() async throws { + let clock = ManualMonotonicClock(now: 500) + let service = AdvancingClockBatchControlService( + displays: [.physicalA], clock: clock, inventoryAdvance: 4, snapshotAdvance: 2 + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchExecutionTimeout: 5, + batchMonotonicNow: clock.now + ).handle(ControlRequest( + requestID: "shared-budget", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["phase"], .string("snapshot")) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true)) + let snapshotWrites = await service.writes + XCTAssertEqual(snapshotWrites, []) + } + + func testFinalBatchMemberCompletingAfterAbsoluteDeadlineIsIndeterminate() async throws { + let clock = ManualMonotonicClock(now: 700) + let service = AdvancingClockBatchControlService( + displays: [.physicalA], + clock: clock, + inventoryAdvance: 0, + snapshotAdvance: 0, + writeAdvance: 6 + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchExecutionTimeout: 5, + batchMonotonicNow: clock.now + ).handle(ControlRequest( + requestID: "final-member-deadline", + command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["appliedUUIDs"], .array([])) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["attempted"], .bool(true)) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["outcome"], .string("indeterminate")) + } + + func testExpiredMemberIsIndeterminateAndLaterDisplaysAreNotAttempted() async throws { + let clock = ManualMonotonicClock(now: 800) + let service = AdvancingClockBatchControlService( + displays: [.physicalB, .physicalA], + clock: clock, + inventoryAdvance: 0, + snapshotAdvance: 0, + writeAdvance: 6 + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchExecutionTimeout: 5, + batchMonotonicNow: clock.now + ).handle(ControlRequest( + requestID: "member-deadline", + command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["notAttemptedUUIDs"], .array([.string("uuid-b")])) + let writes = await service.writes + XCTAssertEqual(writes, ["uuid-a"]) + } + + func testBrightnessSetAllReturnsEverySuccessfulOutcomeInDeterministicOrder() async throws { + let service = BatchControlService(displays: [.physicalB, .physicalA]) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "success", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["outcomes"]?[0]?["displayUUID"], .string("uuid-a")) + XCTAssertEqual(response.result?["outcomes"]?[1]?["displayUUID"], .string("uuid-b")) + XCTAssertEqual(response.result?["appliedUUIDs"], .array([.string("uuid-a"), .string("uuid-b")])) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-a", "uuid-b"]) + } + + func testBrightnessSetAllPartialAndIndeterminateOutcomesNeverSuggestRetry() async throws { + let service = BatchControlService( + displays: [.physicalC, .physicalB, .physicalA], + writeFailures: [ + "uuid-b": .failed("backend rejected write"), + "uuid-c": .indeterminate("callback cancelled in flight") + ] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "partial", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + ) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(response.error?.details?["appliedUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["failedUUIDs"], .array([.string("uuid-b"), .string("uuid-c")])) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-c")])) + XCTAssertEqual(response.error?.details?["outcomes"]?[2]?["outcome"], .string("indeterminate")) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-a", "uuid-b", "uuid-c"]) + } + + func testBatchEveryMemberCarriesAttemptVerificationCodeAndRetryTruth() async throws { + let service = BatchControlService( + displays: [.physicalC, .physicalB, .physicalA], + writeFailures: [ + "uuid-b": .failed("backend rejected write"), + "uuid-c": .indeterminate("callback remains in flight") + ] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "member-schema", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + ) + + let outcomes = response.error?.details?["outcomes"] + XCTAssertEqual(outcomes?[0]?["attempted"], .bool(true)) + XCTAssertEqual(outcomes?[0]?["verification"], .string("verified")) + XCTAssertEqual(outcomes?[0]?["code"], .null) + XCTAssertEqual(outcomes?[0]?["retrySafe"], .bool(false)) + XCTAssertEqual(outcomes?[1]?["attempted"], .bool(true)) + XCTAssertEqual(outcomes?[1]?["verification"], .string("unavailable")) + XCTAssertEqual(outcomes?[1]?["code"], .string("write_verification_failed")) + XCTAssertEqual(outcomes?[1]?["retrySafe"], .bool(false)) + XCTAssertEqual(outcomes?[2]?["verification"], .string("unavailable")) + XCTAssertEqual(outcomes?[2]?["code"], .string("write_outcome_indeterminate")) + XCTAssertEqual(outcomes?[2]?["retrySafe"], .bool(false)) + + let deadlineResponse = await ControlCommandDispatcher( + service: DelayedBatchControlService(displays: [.physicalC, .physicalB, .physicalA]), + appVersion: "1.5.0", batchExecutionTimeout: 0.03 + ).handle(ControlRequest( + requestID: "not-attempted-schema", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + XCTAssertEqual( + deadlineResponse.error?.details?["outcomes"]?[2]?["verification"], + .string("unavailable") + ) + XCTAssertEqual( + deadlineResponse.error?.details?["outcomes"]?[2]?["code"], + .string("batch_partial_failure") + ) + XCTAssertEqual(deadlineResponse.error?.details?["outcomes"]?[2]?["retrySafe"], .bool(true)) + } + + func testCancelledBatchDoesNotStartAnotherDisplayWrite() async throws { + let service = CancellingBatchControlService(displays: [.physicalA, .physicalB]) + let dispatcher = ControlCommandDispatcher(service: service, appVersion: "1.5.0") + let task = Task { + await dispatcher.handle(ControlRequest( + requestID: "cancelled", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + } + try await Task.sleep(for: .milliseconds(20)) + task.cancel() + let response = await task.value + + let writes = await service.writes + XCTAssertEqual(writes, ["uuid-a"]) + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["notAttemptedUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertNotEqual(response.error?.code, .internalError) + } + + func testCancellationAtMemberBoundaryPreservesCompletedMemberTruth() async { + let service = MockControlService( + displays: [.physicalA, .physicalB], + brightnessStates: [ + BrightnessReadSnapshot(logicalPercent: 40, hardwareReadbackPercent: 40), + BrightnessReadSnapshot(logicalPercent: 45, hardwareReadbackPercent: 45), + BrightnessReadSnapshot(logicalPercent: 50, hardwareReadbackPercent: 50) + ] + ) + let gate = BatchMemberBoundaryGate() + let dispatcher = ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchMemberBoundary: { index in + if index == 0 { await gate.pause() } + } + ) + let task = Task { + await dispatcher.handle(ControlRequest( + requestID: "cancel-boundary", + command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + } + await gate.waitUntilPaused() + task.cancel() + await gate.resume() + let response = await task.value + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["appliedUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([])) + XCTAssertEqual(response.error?.details?["notAttemptedUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + let writes = await service.writes + XCTAssertEqual(writes, [50]) + } + + func testCancellationDuringBatchInventoryIsSafePreflightNotInternalError() async throws { + let service = CancellationPhaseBatchControlService(displays: [.physicalA], delayedPhase: .inventory) + let task = Task { + await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "cancel-inventory", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + ) + } + try await Task.sleep(for: .milliseconds(20)) + task.cancel() + let response = await task.value + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["phase"], .string("inventory")) + XCTAssertEqual(response.error?.details?["cancelled"], .bool(true)) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true)) + XCTAssertNotEqual(response.error?.code, .internalError) + let writes = await service.writes + XCTAssertEqual(writes, []) + } + + func testCancellationDuringBatchSnapshotIsSafePreflightNotInternalError() async throws { + let service = CancellationPhaseBatchControlService(displays: [.physicalA], delayedPhase: .snapshot) + let task = Task { + await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest(requestID: "cancel-snapshot", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + ) + } + try await Task.sleep(for: .milliseconds(20)) + task.cancel() + let response = await task.value + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["phase"], .string("snapshot")) + XCTAssertEqual(response.error?.details?["cancelled"], .bool(true)) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true)) + XCTAssertNotEqual(response.error?.code, .internalError) + let writes = await service.writes + XCTAssertEqual(writes, []) + } + + func testBrightnessCancellationAfterSetterIsIndeterminate() async { + await assertCancellationAfterSetterIsIndeterminate( + mutation: .brightness, + request: request("brightness.set", selector: "uuid-boosted", percent: 80) + ) + } + + func testExtraBrightnessCancellationAfterSetterIsIndeterminate() async { + await assertCancellationAfterSetterIsIndeterminate( + mutation: .extraBrightness, + request: toggleRequest( + "extra-brightness.set", selector: "uuid-boosted", enabled: false + ) + ) + } + + func testHDRCancellationAfterSetterIsIndeterminate() async { + await assertCancellationAfterSetterIsIndeterminate( + mutation: .hdr, + request: toggleRequest("hdr.set", selector: "uuid-hdr", enabled: false) + ) + } + + func testBatchOwnDeadlinePreservesCompletedAndUnknownMembers() async throws { + let service = DelayedBatchControlService(displays: [.physicalC, .physicalB, .physicalA]) + let dispatcher = ControlCommandDispatcher( + service: service, appVersion: "1.5.0", batchExecutionTimeout: 0.03 + ) + let response = await dispatcher.handle(ControlRequest( + requestID: "deadline", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["appliedUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(response.error?.details?["notAttemptedUUIDs"], .array([.string("uuid-c")])) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["attempted"], .bool(true)) + XCTAssertEqual(response.error?.details?["outcomes"]?[1]?["outcome"], .string("indeterminate")) + XCTAssertEqual(response.error?.details?["outcomes"]?[2]?["outcome"], .string("not_attempted")) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + let writes = await service.writes + XCTAssertEqual(writes, ["uuid-a", "uuid-b"]) + } + + func testBatchDeadlineIsOverallNotRestartedForEachDisplay() async throws { + let clock = ManualMonotonicClock(now: 900) + let service = AdvancingClockBatchControlService( + displays: [.physicalC, .physicalB, .physicalA], + clock: clock, + inventoryAdvance: 0, + snapshotAdvance: 0, + writeAdvance: 3 + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "1.5.0", + batchExecutionTimeout: 5, + batchMonotonicNow: clock.now + ).handle(ControlRequest( + requestID: "overall-deadline", command: "brightness.set-all", + arguments: ["percent": .number(50)] + )) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + XCTAssertEqual(response.error?.details?["appliedUUIDs"], .array([.string("uuid-a")])) + XCTAssertEqual(response.error?.details?["indeterminateUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(response.error?.details?["notAttemptedUUIDs"], .array([.string("uuid-c")])) + } + + private func request(_ command: String, selector: String, percent: Double? = nil) -> ControlRequest { + var arguments: [String: JSONValue] = ["selector": .string(selector)] + if let percent { arguments["percent"] = .number(percent) } + return ControlRequest(requestID: "req", command: command, arguments: arguments) + } + + private func toggleRequest(_ command: String, selector: String, enabled: Bool) -> ControlRequest { + ControlRequest(requestID: "req", command: command, arguments: [ + "selector": .string(selector), "enabled": .bool(enabled) + ]) + } + + private func assertCancellationAfterSetterIsIndeterminate( + mutation: PostMutationCancellationControlService.Mutation, + request: ControlRequest + ) async { + let gate = BatchMemberBoundaryGate() + let service = PostMutationCancellationControlService(mutation: mutation, gate: gate) + let task = Task { + await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle(request) + } + await gate.waitUntilPaused() + task.cancel() + await gate.resume() + let response = await task.value + + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(response.error?.details?["outcome"], .string("unknown")) + XCTAssertEqual(response.error?.code.exitCode, 5) + let mutations = await service.mutations + XCTAssertEqual(mutations, [mutation]) + } +} + +final class BrightnessBatchOverrideTests: XCTestCase { + func testStrictDefaultRejectsUnreadableRestoreSnapshotWithoutWrites() async throws { + let service = BatchControlService( + displays: [.physicalB, .physicalA], + preWriteSnapshotUnavailableUUIDs: ["uuid-b"] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "strict-unreadable", command: "brightness.set-all", + arguments: ["percent": .number(50)] + ) + ) + + XCTAssertEqual(response.error?.code, .batchPreflightFailed) + XCTAssertEqual(response.error?.details?["restoreMode"], .string("strict")) + XCTAssertEqual( + response.error?.details?["missingRestoreSnapshotUUIDs"], .array([.string("uuid-b")]) + ) + XCTAssertEqual(response.error?.details?["manualRestorationRequired"], .bool(false)) + XCTAssertEqual(response.error?.details?["outcomes"]?[0]?["status"], .string("not_attempted")) + XCTAssertEqual(response.error?.details?["outcomes"]?[1]?["status"], .string("failed")) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), []) + } + + func testOverrideReturnsDeterministicPerDisplayStatusesAndManualRestoreTruth() async throws { + let service = BatchControlService( + displays: [.physicalE, .physicalC, .physicalA, .physicalD, .physicalB], + writeFailures: [ + "uuid-c": .failed("backend rejected write"), + "uuid-d": .indeterminate("callback remains in flight") + ], + preWriteSnapshotUnavailableUUIDs: ["uuid-b"] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "override-categories", command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)] + ) + ) + + XCTAssertEqual(response.error?.code, .batchPartialFailure) + let details = response.error?.details + XCTAssertEqual(details?["restoreMode"], .string("allow_unrestorable")) + XCTAssertEqual(details?["restoreSnapshotsComplete"], .bool(false)) + XCTAssertEqual(details?["missingRestoreSnapshotUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(details?["manualRestorationRequired"], .bool(true)) + XCTAssertEqual(details?["manualRestorationUUIDs"], .array([.string("uuid-b")])) + XCTAssertEqual(details?["outcomes"]?[0]?["status"], .string("written_verified")) + XCTAssertEqual(details?["outcomes"]?[1]?["status"], .string("written_verified")) + XCTAssertEqual(details?["outcomes"]?[1]?["verification"], .string("approximate")) + XCTAssertEqual(details?["outcomes"]?[1]?["originalPercent"], .null) + XCTAssertEqual(details?["outcomes"]?[1]?["manualRestorationRequired"], .bool(true)) + XCTAssertNotEqual(details?["outcomes"]?[1]?["warnings"], .array([])) + XCTAssertEqual(details?["outcomes"]?[2]?["status"], .string("failed")) + XCTAssertEqual(details?["outcomes"]?[3]?["status"], .string("write_indeterminate")) + XCTAssertEqual(details?["outcomes"]?[4]?["status"], .string("not_attempted")) + XCTAssertEqual(details?["outcomes"]?[4]?["attempted"], .bool(false)) + XCTAssertEqual(details?["retrySafe"], .bool(false)) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-a", "uuid-b", "uuid-c", "uuid-d"]) + } + + func testOverrideCancellationAfterUnrestorableWriteIsIndeterminateWithoutRetry() async { + let gate = BatchMemberBoundaryGate() + let service = BatchControlService( + displays: [.physicalB, .physicalA], preWriteSnapshotUnavailableUUIDs: ["uuid-a"], + postWriteAction: { await gate.pause() }) + let request = Self.batchOverrideRequest("override-post-write-cancellation") + let task = Task { await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle(request) } + await gate.waitUntilPaused() + task.cancel() + await gate.resume() + assertUnrestorableIndeterminate(await task.value, writtenUUIDs: (await service.writes).map(\.0)) + } + + func testOverrideDeadlineAfterUnrestorableWriteIsIndeterminateWithoutRetry() async { + let clock = ManualMonotonicClock(now: 1_000) + let service = BatchControlService( + displays: [.physicalB, .physicalA], preWriteSnapshotUnavailableUUIDs: ["uuid-a"], + postWriteAction: { clock.advance(by: 6) }) + let dispatcher = ControlCommandDispatcher( + service: service, appVersion: "1.5.0", batchExecutionTimeout: 5, + batchMonotonicNow: clock.now) + let response = await dispatcher.handle(Self.batchOverrideRequest("override-post-write-deadline")) + assertUnrestorableIndeterminate(response, writtenUUIDs: (await service.writes).map(\.0)) + } + + private static func batchOverrideRequest(_ requestID: String) -> ControlRequest { + ControlRequest(requestID: requestID, command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)]) + } + + private func assertUnrestorableIndeterminate(_ response: ControlResponse, writtenUUIDs: [String]) { + XCTAssertEqual(response.error?.code, .batchPartialFailure) + let details = response.error?.details + XCTAssertEqual( + [details?["retrySafe"], details?["manualRestorationUUIDs"], details?["notAttemptedUUIDs"]], + [.bool(false), .array([.string("uuid-a")]), .array([.string("uuid-b")])]) + let outcomes = details?["outcomes"] + XCTAssertEqual( + [outcomes?[0]?["displayUUID"], outcomes?[0]?["attempted"], outcomes?[0]?["status"], + outcomes?[0]?["code"], outcomes?[0]?["retrySafe"], + outcomes?[0]?["restoreSnapshotAvailable"], outcomes?[0]?["manualRestorationRequired"]], + [.string("uuid-a"), .bool(true), .string("write_indeterminate"), + .string("write_outcome_indeterminate"), .bool(false), .bool(false), .bool(true)]) + XCTAssertEqual( + [outcomes?[1]?["displayUUID"], outcomes?[1]?["status"], outcomes?[1]?["attempted"]], + [.string("uuid-b"), .string("not_attempted"), .bool(false)]) + XCTAssertEqual(writtenUUIDs, ["uuid-a"]) + } + + func testOverrideRejectsNilPostWriteReadbackForReadbackCapableDisplay() async throws { + let service = BatchControlService( + displays: [.physicalB], + preWriteSnapshotUnavailableUUIDs: ["uuid-b"], + postWriteReadbackNilUUIDs: ["uuid-b"] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "override-nil-post-write-readback", command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)] + ) + ) + + XCTAssertFalse(response.ok) + XCTAssertNil(response.result) + XCTAssertEqual(response.error?.code, .batchPartialFailure) + let outcome = response.error?.details?["outcomes"]?[0] + XCTAssertEqual(outcome?["status"], .string("failed")) + XCTAssertNotEqual(outcome?["status"], .string("written_unverified")) + XCTAssertEqual(outcome?["code"], .string("write_verification_failed")) + XCTAssertEqual(outcome?["manualRestorationRequired"], .bool(true)) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-b"]) + } + + func testOverrideRejectsPostWriteReadErrorForReadbackCapableDisplay() async throws { + let service = BatchControlService( + displays: [.physicalB], + preWriteSnapshotUnavailableUUIDs: ["uuid-b"], + postWriteReadbackFailures: ["uuid-b": "post-write read-back failed"] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "override-failed-post-write-readback", command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)] + ) + ) + + XCTAssertFalse(response.ok) + XCTAssertNil(response.result) + XCTAssertEqual(response.error?.code, .batchPartialFailure) + let outcome = response.error?.details?["outcomes"]?[0] + XCTAssertEqual(outcome?["status"], .string("failed")) + XCTAssertNotEqual(outcome?["status"], .string("written_unverified")) + XCTAssertEqual(outcome?["code"], .string("internal_error")) + XCTAssertEqual(outcome?["manualRestorationRequired"], .bool(true)) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-b"]) + } + + func testOverridePreservesKnownReadbackUnavailableAsWrittenUnverified() async throws { + let service = BatchControlService( + displays: [.softwareExternal], + preWriteSnapshotUnavailableUUIDs: ["uuid-software"] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "override-known-unverifiable", command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)] + ) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["outcomes"]?[0]?["status"], .string("written_unverified")) + XCTAssertEqual(response.result?["outcomes"]?[0]?["verification"], .string("unavailable")) + XCTAssertEqual(response.result?["outcomes"]?[0]?["manualRestorationRequired"], .bool(true)) + XCTAssertEqual(response.result?["outcomes"]?[0]?["readbackPercent"], .null) + let writes = await service.writes + XCTAssertEqual(writes.map(\.0), ["uuid-software"]) + } +} + +private final class ManualMonotonicClock: @unchecked Sendable { + private let lock = NSLock() + private var value: TimeInterval + + init(now: TimeInterval) { value = now } + + func now() -> TimeInterval { lock.withLock { value } } + + func advance(by interval: TimeInterval) { + lock.withLock { value += interval } + } +} + +private actor AdvancingClockBatchControlService: ControlCommandService { + let inventory: [ControlDisplay] + let clock: ManualMonotonicClock + let inventoryAdvance: TimeInterval + let snapshotAdvance: TimeInterval + let writeAdvance: TimeInterval + var values: [String: Double] + var writes: [String] = [] + + init( + displays: [ControlDisplay], + clock: ManualMonotonicClock, + inventoryAdvance: TimeInterval, + snapshotAdvance: TimeInterval, + writeAdvance: TimeInterval = 0 + ) { + inventory = displays + self.clock = clock + self.inventoryAdvance = inventoryAdvance + self.snapshotAdvance = snapshotAdvance + self.writeAdvance = writeAdvance + values = Dictionary(uniqueKeysWithValues: displays.map { + ($0.uuid, $0.brightnessPercent ?? 42) + }) + } + + func displays() async throws -> [ControlDisplay] { + clock.advance(by: inventoryAdvance) + return inventory + } + + func readBrightness(displayUUID: String) async throws -> Double? { + clock.advance(by: snapshotAdvance) + return values[displayUUID] + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(displayUUID) + clock.advance(by: writeAdvance) + values[displayUUID] = percent + return percent + } +} + +private actor DelayedBatchControlService: ControlCommandService { + let inventory: [ControlDisplay] + var values: [String: Double] + var writes: [String] = [] + + init(displays: [ControlDisplay]) { + inventory = displays + values = Dictionary(uniqueKeysWithValues: displays.map { ($0.uuid, $0.brightnessPercent ?? 42) }) + } + + func displays() async throws -> [ControlDisplay] { inventory } + func readBrightness(displayUUID: String) async throws -> Double? { values[displayUUID] } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(displayUUID) + if displayUUID == "uuid-b" { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + continuation.resume() + } + } + } + values[displayUUID] = percent + return percent + } +} + +private actor DelayedSnapshotBatchControlService: ControlCommandService { + let inventory: [ControlDisplay] + var writes: [String] = [] + + init(displays: [ControlDisplay]) { inventory = displays } + func displays() async throws -> [ControlDisplay] { inventory } + func readBrightness(displayUUID: String) async throws -> Double? { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + continuation.resume(returning: 42) + } + } + } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(displayUUID) + return percent + } +} + +private actor CancellingBatchControlService: ControlCommandService { + let inventory: [ControlDisplay] + var values: [String: Double] + var writes: [String] = [] + + init(displays: [ControlDisplay]) { + inventory = displays + values = Dictionary(uniqueKeysWithValues: displays.map { ($0.uuid, $0.brightnessPercent ?? 42) }) + } + + func displays() async throws -> [ControlDisplay] { inventory } + func readBrightness(displayUUID: String) async throws -> Double? { values[displayUUID] } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(displayUUID) + try? await Task.sleep(for: .milliseconds(200)) + values[displayUUID] = percent + return percent + } +} + +private actor CancellationPhaseBatchControlService: ControlCommandService { + enum Phase: Equatable { case inventory, snapshot } + + let inventory: [ControlDisplay] + let delayedPhase: Phase + var writes: [String] = [] + + init(displays: [ControlDisplay], delayedPhase: Phase) { + inventory = displays + self.delayedPhase = delayedPhase + } + + func displays() async throws -> [ControlDisplay] { + if delayedPhase == .inventory { + try? await Task.sleep(for: .milliseconds(200)) + } + return inventory + } + + func readBrightness(displayUUID: String) async throws -> Double? { + if delayedPhase == .snapshot { + try? await Task.sleep(for: .milliseconds(200)) + } + return inventory.first { $0.uuid == displayUUID }?.brightnessPercent + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(displayUUID) + return percent + } +} + +private actor BatchControlService: ControlCommandService { + enum WriteFailure { + case failed(String) + case indeterminate(String) + } + + let inventory: [ControlDisplay] + let writeFailures: [String: WriteFailure] + let preWriteSnapshotUnavailableUUIDs: Set + let postWriteReadbackNilUUIDs: Set + let postWriteReadbackFailures: [String: String] + let postWriteAction: (@Sendable () async -> Void)? + var values: [String: Double] + var brightnessReadCounts: [String: Int] = [:] + var writes: [(String, Double)] = [] + + init( + displays: [ControlDisplay], + writeFailures: [String: WriteFailure] = [:], + preWriteSnapshotUnavailableUUIDs: Set = [], + postWriteReadbackNilUUIDs: Set = [], + postWriteReadbackFailures: [String: String] = [:], + postWriteAction: (@Sendable () async -> Void)? = nil + ) { + inventory = displays + self.writeFailures = writeFailures + self.preWriteSnapshotUnavailableUUIDs = preWriteSnapshotUnavailableUUIDs + self.postWriteReadbackNilUUIDs = postWriteReadbackNilUUIDs + self.postWriteReadbackFailures = postWriteReadbackFailures + self.postWriteAction = postWriteAction + values = Dictionary(uniqueKeysWithValues: displays.map { ($0.uuid, $0.brightnessPercent ?? 42) }) + } + + func displays() async throws -> [ControlDisplay] { inventory } + + func readBrightness(displayUUID: String) async throws -> Double? { + let readCount = brightnessReadCounts[displayUUID, default: 0] + brightnessReadCounts[displayUUID] = readCount + 1 + if readCount == 0, preWriteSnapshotUnavailableUUIDs.contains(displayUUID) { + return nil + } + if readCount > 0, let message = postWriteReadbackFailures[displayUUID] { + throw ControlServiceError.readFailed(message) + } + if readCount > 0, postWriteReadbackNilUUIDs.contains(displayUUID) { + return nil + } + return values[displayUUID] + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append((displayUUID, percent)) + if let failure = writeFailures[displayUUID] { + switch failure { + case let .failed(message): throw ControlServiceError.writeFailed(message) + case let .indeterminate(message): throw ControlServiceError.writeIndeterminate(message) + } + } + values[displayUUID] = percent + if let postWriteAction { await postWriteAction() } + return percent + } +} + +private actor LegacyLogicalOnlyControlService: ControlCommandService { + let display: ControlDisplay + let logicalPercent: Double + + init(display: ControlDisplay, logicalPercent: Double) { + self.display = display + self.logicalPercent = logicalPercent + } + + func displays() async throws -> [ControlDisplay] { [display] } + func readBrightness(displayUUID: String) async throws -> Double? { logicalPercent } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } +} + +private actor MockControlService: ControlCommandService { + let inventory: [ControlDisplay] + var queuedReads: [Double?] + var writes: [Double] = [] + var brightnessStates: [BrightnessReadSnapshot] + var extraBrightnessWrites: [Bool] = [] + var hdrWrites: [Bool] = [] + + init( + displays: [ControlDisplay], + readValues: [Double?] = [42], + brightnessStates: [BrightnessReadSnapshot] = [] + ) { + inventory = displays + queuedReads = readValues + self.brightnessStates = brightnessStates + } + + func displays() async throws -> [ControlDisplay] { inventory } + + func readBrightness(displayUUID: String) async throws -> Double? { + queuedReads.isEmpty ? inventory.first(where: { $0.uuid == displayUUID })?.brightnessPercent + : queuedReads.removeFirst() + } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + writes.append(percent) + return percent + } + + func readBrightnessState(displayUUID: String) async throws -> BrightnessReadSnapshot? { + if !brightnessStates.isEmpty { return brightnessStates.removeFirst() } + return try await readBrightness(displayUUID: displayUUID).map { + BrightnessReadSnapshot(logicalPercent: $0, hardwareReadbackPercent: $0) + } + } + + func setExtraBrightness(displayUUID: String, enabled: Bool) async throws -> ExtraBrightnessSetResult { + extraBrightnessWrites.append(enabled) + let existing = inventory.first { $0.uuid == displayUUID }!.extraBrightness + let capability = if enabled { + existing + } else { + ExtraBrightnessCapability( + state: existing.state, + enabled: false, + persistedEnabled: false, + maxBrightness: 100, + headroom: existing.headroom, + reason: existing.reason, + remediation: existing.remediation + ) + } + return ExtraBrightnessSetResult(capability: capability, verification: .appStateVerified) + } + + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult { + hdrWrites.append(enabled) + let capability = HDRCapability(state: .writable, enabled: enabled) + return HDRSetResult(capability: capability, verification: .verified) + } +} + +private actor BatchMemberBoundaryGate { + private var paused = false + private var pauseContinuation: CheckedContinuation? + private var observerContinuation: CheckedContinuation? + + func pause() async { + paused = true + observerContinuation?.resume() + observerContinuation = nil + await withCheckedContinuation { pauseContinuation = $0 } + } + + func waitUntilPaused() async { + if paused { return } + await withCheckedContinuation { observerContinuation = $0 } + } + + func resume() { + pauseContinuation?.resume() + pauseContinuation = nil + } +} + +private actor PostMutationCancellationControlService: ControlCommandService { + enum Mutation: Equatable, Sendable { + case brightness + case extraBrightness + case hdr + } + + let mutation: Mutation + let gate: BatchMemberBoundaryGate + var values = ["uuid-boosted": 120.0, "uuid-hdr": 55.0] + var mutations: [Mutation] = [] + + init(mutation: Mutation, gate: BatchMemberBoundaryGate) { + self.mutation = mutation + self.gate = gate + } + + func displays() async throws -> [ControlDisplay] { [.boostedBuiltin, .hdrExternal] } + + func readBrightness(displayUUID: String) async throws -> Double? { values[displayUUID] } + + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { + mutations.append(.brightness) + await gate.pause() + values[displayUUID] = percent + return percent + } + + func setExtraBrightness( + displayUUID: String, + enabled: Bool + ) async throws -> ExtraBrightnessSetResult { + mutations.append(.extraBrightness) + await gate.pause() + return ExtraBrightnessSetResult( + capability: ExtraBrightnessCapability( + state: .writable, + enabled: enabled, + persistedEnabled: enabled, + maxBrightness: enabled ? 150 : 100 + ), + verification: .appStateVerified + ) + } + + func setHDR(displayUUID: String, enabled: Bool) async throws -> HDRSetResult { + mutations.append(.hdr) + await gate.pause() + return HDRSetResult( + capability: HDRCapability(state: .writable, enabled: enabled), + verification: .verified + ) + } +} + +private extension ControlDisplay { + static let builtin = ControlDisplay( + uuid: "uuid-built-in", name: "Built-in Display", isMain: true, isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: 100, precision: 0.1), readback: .authoritative + ), brightnessPercent: 42 + ) + static let unsupportedExternal = ControlDisplay( + uuid: "uuid-external", name: "External", isMain: false, isBuiltin: false, + brightness: .unsupported(reason: "No controllable backend") + ) + static let softwareExternal = ControlDisplay( + uuid: "uuid-software", name: "Software", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .software, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .unavailable + ), brightnessPercent: 42 + ) + static let readOnlyExternal = ControlDisplay( + uuid: "uuid-read-only", name: "Read only", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .readable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ), brightnessPercent: 42 + ) + static let deskA = ControlDisplay( + uuid: "uuid-a", name: "Desk", isMain: false, isBuiltin: false, + brightness: .unsupported(reason: "none") + ) + static let deskB = ControlDisplay( + uuid: "uuid-b", name: "Desk", isMain: false, isBuiltin: false, + brightness: .unsupported(reason: "none") + ) + static let boostedBuiltin = ControlDisplay( + uuid: "uuid-boosted", name: "Built-in XDR", isMain: true, isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: 150, precision: 0.1), readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: 150, precision: 0.1) + ), brightnessPercent: 120, + extraBrightness: ExtraBrightnessCapability( + state: .writable, enabled: true, persistedEnabled: true, maxBrightness: 150, + headroom: EDRHeadroomSnapshot(potential: 1.7, current: 1.5) + ), + hdr: .unsupported( + reason: "built-in displays do not expose an HDR preference toggle", + remediation: "use Extra Brightness when eligible" + ) + ) + static let hdrExternal = ControlDisplay( + uuid: "uuid-hdr", name: "HDR External", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .software, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .unavailable + ), brightnessPercent: 55, + extraBrightness: .unsupported(reason: "boost is off"), + hdr: HDRCapability(state: .writable, enabled: true) + ) + static let physicalA = ControlDisplay( + uuid: "uuid-a", name: "A", isMain: true, isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: 150, precision: 0.1), readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: 150, precision: 0.1) + ), brightnessPercent: 40, + extraBrightness: ExtraBrightnessCapability( + state: .writable, enabled: true, persistedEnabled: true, maxBrightness: 150 + ) + ) + static let physicalB = ControlDisplay( + uuid: "uuid-b", name: "B", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ), brightnessPercent: 45 + ) + static let physicalC = ControlDisplay( + uuid: "uuid-c", name: "C", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ), brightnessPercent: 50 + ) + static let physicalD = ControlDisplay( + uuid: "uuid-d", name: "D", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ), brightnessPercent: 55 + ) + static let physicalE = ControlDisplay( + uuid: "uuid-e", name: "E", isMain: false, isBuiltin: false, + brightness: BrightnessCapability( + state: .writable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ), brightnessPercent: 60 + ) + static let virtual = ControlDisplay( + uuid: "uuid-virtual", name: "Virtual", isMain: false, isBuiltin: false, isVirtual: true, + brightness: .unsupported(reason: "virtual display") + ) +} diff --git a/Tests/CrispControlCoreTests/DisplayConnectionCoordinatorTests.swift b/Tests/CrispControlCoreTests/DisplayConnectionCoordinatorTests.swift new file mode 100644 index 0000000..3372a08 --- /dev/null +++ b/Tests/CrispControlCoreTests/DisplayConnectionCoordinatorTests.swift @@ -0,0 +1,2656 @@ +// The connection-state safety matrix is kept in one fixture-backed suite. +// swiftlint:disable file_length +import XCTest +@testable import CrispControlCore + +@MainActor +final class DisplayConnectionCoordinatorTests: XCTestCase { + private let targetUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + private let otherUUID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + private let thirdUUID = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC" + private let targetID: UInt32 = 2 + private let otherID: UInt32 = 1 + private let bootSessionID = "boot-A" + private let loginSessionID = "login-A" + private let wakeSessionID = "mach-sleep-offset-v1:10000000000" + private let topologyFingerprint = "topology-A" +} + +extension DisplayConnectionCoordinatorTests { + func testDisconnectSucceedsOnlyAfterSameUUIDIsOfflineAndRecordIsRetained() async throws { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [targetUUID, otherUUID], records: []), + observation(online: [targetUUID, otherUUID], records: [targetUUID]), + observation(online: [otherUUID], records: [targetUUID]) + ], + dispatchOutcome: .completed, + retainedCapability: retained + ) + + let result = try await coordinator(adapter).disconnect(target(targetUUID)) + + XCTAssertEqual(result.displayUUID, targetUUID) + XCTAssertEqual(result.requestedConnectionState, .disconnected) + XCTAssertEqual(result.observedConnectionState, .disconnected) + XCTAssertEqual(result.verification, .sameUUIDEnumeration) + XCTAssertEqual(adapter.dispatched, [request(state: .disconnected)]) + XCTAssertEqual(adapter.retained, [targetUUID]) + XCTAssertEqual(adapter.confirmed, [targetUUID]) + XCTAssertTrue(adapter.removed.isEmpty) + } + + func testDisconnectWithoutRetainedRecordTruthIsIndeterminate() async { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [targetUUID, otherUUID], records: []), + observation(online: [otherUUID], records: []), + observation(online: [otherUUID], records: []) + ], + dispatchOutcome: .completed, + retainedCapability: retained + ) + + await assertFailure( + from: { try await self.coordinator(adapter).disconnect(self.target(self.targetUUID)) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertEqual(adapter.retained, [targetUUID]) + } +} + +extension DisplayConnectionCoordinatorTests { + func testDisconnectWithoutRetainedRecoveryCapabilityRejectsBeforeDispatchAndCleansRecord() async { + let adapter = FakeConnectionAdapter( + observations: [observation(online: [targetUUID, otherUUID], records: [])], + dispatchOutcome: .completed, + retainedCapability: nil + ) + + await assertFailure( + from: { try await self.coordinator(adapter).disconnect(self.target(self.targetUUID)) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertEqual(adapter.retained, [targetUUID]) + XCTAssertEqual(adapter.removed, [targetUUID]) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.confirmed.isEmpty) + XCTAssertTrue(adapter.indeterminate.isEmpty) + } + + func testDisconnectConfirmsRetainedExactBindingWhenUUIDDisappearsAfterDisable() async throws { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [targetUUID, otherUUID], records: []), + observation( + all: [otherUUID], + online: [otherUUID], + records: [targetUUID], + pending: [targetUUID], + candidates: [ + candidate(displayID: otherID, uuid: otherUUID, online: true), + candidate( + displayID: targetID, + uuid: nil, + online: false, + hardwareBacked: false, + recoveryProof: recoveryHardwareProof + ) + ], + recoveryCapabilities: [retained] + ) + ], + dispatchOutcome: .completed, + retainedCapability: retained + ) + + let result = try await coordinator(adapter).disconnect(target(targetUUID)) + + XCTAssertEqual(result.observedConnectionState, .disconnected) + XCTAssertEqual(result.verification, .retainedBindingHardwareContinuity) + XCTAssertEqual(adapter.dispatched, [request(state: .disconnected)]) + XCTAssertEqual(adapter.confirmed, [targetUUID]) + XCTAssertTrue(adapter.removed.isEmpty) + } + + func testReconnectRemovesRecordOnlyAfterSameUUIDIsOnline() async throws { + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [otherUUID], records: [targetUUID]), + observation(online: [otherUUID], records: [targetUUID]), + observation(online: [targetUUID, otherUUID], records: [targetUUID]), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.requestedConnectionState, .connected) + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(result.verification, .sameUUIDEnumeration) + XCTAssertEqual(adapter.dispatched, [request(state: .connected)]) + XCTAssertEqual(adapter.removed, [targetUUID]) + } + + func testReconnectAcceptsSameUUIDOnlineAndRecordAbsentAfterAppReconcile() async throws { + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [otherUUID], records: [targetUUID]), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.displayUUID, targetUUID) + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertTrue(adapter.removed.isEmpty) + } + + func testReconnectAlreadyOnlineClearsRecoveryStateWithoutDispatch() async throws { + let adapter = FakeConnectionAdapter( + observations: [ + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID] + ), + observation(online: [targetUUID, otherUUID], records: [], pending: []) + ], + dispatchOutcome: .completed + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.removed, [targetUUID]) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + + func testReconnectFallbackDispatchesOneEnableAndRequiresFinalExactOnlineProof() async throws { + let available = recoveryCapability(state: .available) + let adapter = FakeConnectionAdapter( + observations: [ + fallbackObservation( + recoveryCapabilities: [available], + wakeSessionID: "mach-sleep-offset-v1:10002000000" + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + retainedCapability: available + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched, [request( + state: .connected, + authorization: .oneShotRecovery + )]) + XCTAssertEqual(adapter.consumed, [targetUUID]) + XCTAssertEqual(adapter.removed, [targetUUID]) + } + + func testLiveShapedFallbackScopesAmbiguityToCandidatesClaimingRetainedProof() async throws { + let available = recoveryCapability(state: .available) + let unrelatedNilCandidate = candidate( + displayID: 3, + uuid: nil, + online: false, + hardwareBacked: false, + hasRecoveryProof: false + ) + let liveShaped = FakeConnectionAdapter( + observations: [ + fallbackObservation( + extraCandidates: [unrelatedNilCandidate], + recoveryCapabilities: [available] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + retainedCapability: available + ) + + _ = try await coordinator(liveShaped).reconnect(uuid: targetUUID) + + XCTAssertEqual(liveShaped.dispatched, [request( + state: .connected, + authorization: .oneShotRecovery + )]) + + let duplicateProof = FakeConnectionAdapter( + observations: [fallbackObservation( + extraCandidates: [candidate(displayID: 3, uuid: nil, online: false)], + recoveryCapabilities: [available] + )], + dispatchOutcome: .completed, + retainedCapability: available + ) + await assertFailure( + from: { try await self.coordinator(duplicateProof).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(duplicateProof.dispatched.isEmpty) + } + + func testFallbackEnableWithoutFinalUUIDTruthIsIndeterminateAndCannotRetry() async { + let available = recoveryCapability(state: .available) + let stillUnresolved = fallbackObservation( + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + fallbackObservation(recoveryCapabilities: [available]), + stillUnresolved, + stillUnresolved + ], + dispatchOutcome: .completed, + retainedCapability: available + ) + + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertEqual(adapter.dispatched.count, 1) + XCTAssertEqual(adapter.consumed, [targetUUID]) + XCTAssertEqual(adapter.indeterminate, [targetUUID]) + XCTAssertTrue(adapter.removed.isEmpty) + + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertEqual(adapter.dispatched.count, 1, "a consumed capability must never retry") + } + + func testFreshExactUUIDResolutionTakesPriorityOverFallback() async throws { + let adapter = FakeConnectionAdapter( + observations: [ + observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .available)] + ), + observation(online: [targetUUID, otherUUID], records: [targetUUID]), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + _ = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(adapter.dispatched, [request(state: .connected)]) + XCTAssertTrue(adapter.consumed.isEmpty) + } + + func testOverlappingExactReconnectsAreSingleFlightAcrossActorReentrancy() async throws { + let offline = observation(online: [otherUUID], records: [targetUUID]) + let reservedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID] + ) + let adapter = FakeConnectionAdapter( + observations: [ + offline, + reservedOnline, + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + suspendFirstDispatch: true + ) + let first = Task { @MainActor in + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + for _ in 0..<100 where adapter.dispatched.isEmpty { + await Task.yield() + } + XCTAssertEqual(adapter.dispatched.count, 1, "the first reconnect never reached dispatch") + + let overlapError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + XCTAssertEqual(overlapError?.classification, .preflightRejected) + XCTAssertTrue(overlapError?.message.contains("still owns") == true) + XCTAssertEqual(adapter.dispatched.count, 1) + XCTAssertTrue(adapter.hasReconnectReservation(targetUUID)) + XCTAssertTrue(adapter.reconciledOrphans.isEmpty) + + adapter.resumeFirstDispatch() + _ = try await first.value + XCTAssertEqual(adapter.dispatched.count, 1) + } + + func testQuarantinedFallbackFirstRequestReconcilesThenSecondRequestDispatches() async throws { + let prepared = recoveryCapability(state: .prepared) + let available = recoveryCapability(state: .available) + let quarantined = fallbackObservation( + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [prepared] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantined, + quarantined, + fallbackObservation(recoveryCapabilities: [available]), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(error?.classification, .indeterminate) + XCTAssertEqual(error?.retrySafe, false) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertTrue(error?.message.contains("fresh explicit user decision") == true) + XCTAssertEqual(adapter.quarantineReconciliationCount, 1) + XCTAssertFalse(adapter.hasPendingDisconnect(targetUUID)) + XCTAssertFalse(adapter.hasReconnectPersistenceUncertain(targetUUID)) + XCTAssertEqual(adapter.restoredRecoveryCapabilityStates[targetUUID], .available) + XCTAssertTrue(adapter.dispatched.isEmpty) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched, [request( + state: .connected, + authorization: .oneShotRecovery + )]) + XCTAssertEqual(adapter.consumed, [targetUUID]) + } + + func testQuarantinedExactOfflineFirstRequestReconcilesThenSecondRequestDispatches() async throws { + let prepared = recoveryCapability(state: .prepared) + let quarantined = observation( + online: [otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [prepared] + ) + let exactOffline = observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .available)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantined, + quarantined, + exactOffline, + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .available)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let firstError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(firstError?.classification, .indeterminate) + XCTAssertEqual(firstError?.retrySafe, false) + XCTAssertEqual(firstError?.mutationDispatched, false) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertFalse(adapter.hasPendingDisconnect(targetUUID)) + XCTAssertFalse(adapter.hasReconnectPersistenceUncertain(targetUUID)) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched, [request(state: .connected)]) + XCTAssertTrue(adapter.consumed.isEmpty) + } + + func testQuarantinedExactOnlineRequiresFreshPostCleanupProofWithoutDispatch() async throws { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantinedOnline, + quarantinedOnline, + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.quarantineReconciliationCount, 1) + XCTAssertEqual(adapter.removed, [targetUUID]) + XCTAssertFalse(adapter.hasPendingDisconnect(targetUUID)) + XCTAssertFalse(adapter.hasReconnectPersistenceUncertain(targetUUID)) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertEqual(adapter.observationCallCount, 3) + XCTAssertEqual(adapter.quarantineFinishCount, 1) + XCTAssertFalse(adapter.hasLiveQuarantineReconciliation(targetUUID)) + } + + func testQuarantinedAlreadyOnlinePostCleanupOfflineIsIndeterminateWithoutDispatch() async { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantinedOnline, + quarantinedOnline, + observation(online: [otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + assertPostCleanupProofFailure(error, adapter: adapter) + } + + func testQuarantinedAlreadyOnlinePostCleanupOnlineWithoutUniqueHardwareProofIsIndeterminate() + async { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let ambiguousCandidates = [ + candidate(displayID: targetID, uuid: targetUUID, online: true), + candidate(displayID: targetID + 1, uuid: targetUUID, online: true), + candidate(displayID: otherID, uuid: otherUUID, online: true) + ] + let noHardwareProofCandidates = [ + candidate( + displayID: targetID, + uuid: targetUUID, + online: true, + hardwareBacked: false, + hasRecoveryProof: false + ), + candidate(displayID: otherID, uuid: otherUUID, online: true) + ] + + for finalCandidates in [ambiguousCandidates, noHardwareProofCandidates] { + let final = observation( + online: [targetUUID, otherUUID], + records: [], + candidates: finalCandidates + ) + let adapter = FakeConnectionAdapter( + observations: [quarantinedOnline, quarantinedOnline, final], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + assertPostCleanupProofFailure(error, adapter: adapter) + } + } + + func testQuarantinedAlreadyOnlinePostCleanupRecoveryStateMustBeCompletelyAbsent() async { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let residualStates = [ + observation(online: [targetUUID, otherUUID], records: [targetUUID]), + observation( + online: [targetUUID, otherUUID], records: [], pending: [targetUUID] + ), + observation( + online: [targetUUID, otherUUID], records: [], + reconnectReservations: [targetUUID] + ), + observation( + online: [targetUUID, otherUUID], records: [], + reconnectPersistenceUncertain: [targetUUID] + ), + observation( + online: [targetUUID, otherUUID], records: [], + recoveryCapabilities: [recoveryCapability(state: .available)] + ) + ] + + for final in residualStates { + let adapter = FakeConnectionAdapter( + observations: [quarantinedOnline, quarantinedOnline, final], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + assertPostCleanupProofFailure(error, adapter: adapter) + } + } + + func testQuarantinedAlreadyOnlinePostCleanupObservationFailureReleasesOwner() async { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + + for cancellation in [false, true] { + let adapter = FakeConnectionAdapter( + observations: [quarantinedOnline, quarantinedOnline], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID], + observationFailureCalls: cancellation ? [] : [3], + observationCancellationCalls: cancellation ? [3] : [] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + if cancellation { + XCTAssertEqual(error?.classification, .indeterminate) + XCTAssertEqual(error?.retrySafe, false) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertTrue( + error?.message.contains( + "metadata cleanup occurred but the final exact-online proof or decision " + + "was cancelled" + ) == true + ) + XCTAssertTrue(error?.message.contains("no display write was issued") == true) + XCTAssertEqual(adapter.observationCallCount, 3) + XCTAssertEqual(adapter.quarantineFinishCount, 1) + XCTAssertFalse(adapter.hasLiveQuarantineReconciliation(targetUUID)) + XCTAssertTrue(adapter.dispatched.isEmpty) + } else { + assertPostCleanupProofFailure(error, adapter: adapter) + } + } + } + + func testCancelledQuarantineOwnerCannotAcceptAlreadyOnlineAfterResume() async { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantinedOnline, + quarantinedOnline, + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID], + suspendFirstQuarantineReconciliation: true + ) + let owner = Task { @MainActor in + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + for _ in 0..<100 where !adapter.isQuarantineReconciliationSuspended { + await Task.yield() + } + XCTAssertTrue(adapter.isQuarantineReconciliationSuspended) + XCTAssertTrue(adapter.hasLiveQuarantineReconciliation(targetUUID)) + + owner.cancel() + adapter.resumeFirstQuarantineReconciliation() + let error = await mutationFailure { try await owner.value } + + XCTAssertEqual(error?.classification, .indeterminate) + XCTAssertEqual(error?.retrySafe, false) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertTrue( + error?.message.contains( + "metadata cleanup occurred but the final exact-online proof or decision was cancelled" + ) == true + ) + XCTAssertTrue(error?.message.contains("no display write was issued") == true) + XCTAssertEqual(adapter.quarantineReconciliationCount, 1) + XCTAssertEqual(adapter.quarantineFinishCount, 1) + XCTAssertFalse(adapter.hasLiveQuarantineReconciliation(targetUUID)) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + + func testConcurrentQuarantinedAlreadyOnlineReconciliationRemainsSingleFlight() async throws { + let quarantinedOnline = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantinedOnline, + quarantinedOnline, + quarantinedOnline, + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID], + suspendFirstQuarantineReconciliation: true + ) + let owner = Task { @MainActor in + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + for _ in 0..<100 where !adapter.isQuarantineReconciliationSuspended { + await Task.yield() + } + XCTAssertTrue(adapter.isQuarantineReconciliationSuspended) + + let overlapError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(overlapError?.classification, .indeterminate) + XCTAssertEqual(overlapError?.retrySafe, false) + XCTAssertEqual(overlapError?.mutationDispatched, false) + XCTAssertEqual(adapter.quarantineReconciliationAttemptCount, 1) + XCTAssertEqual(adapter.quarantineFinishCount, 0) + XCTAssertTrue(adapter.hasLiveQuarantineReconciliation(targetUUID)) + XCTAssertTrue(adapter.dispatched.isEmpty) + + adapter.resumeFirstQuarantineReconciliation() + let result = try await owner.value + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.observationCallCount, 4) + XCTAssertEqual(adapter.quarantineFinishCount, 1) + XCTAssertFalse(adapter.hasLiveQuarantineReconciliation(targetUUID)) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + + func testQuarantineProofOrPersistenceFailureRetainsStateWithoutDispatch() async { + let prepared = recoveryCapability(state: .prepared) + let invalidProof = fallbackObservation( + candidateHasRecoveryProof: false, + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [prepared] + ) + let validProof = fallbackObservation( + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [prepared] + ) + let cases = [ + FakeConnectionAdapter( + observations: [invalidProof, invalidProof], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ), + FakeConnectionAdapter( + observations: [validProof, validProof], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID], + quarantineReconciliationFailure: true + ) + ] + + for adapter in cases { + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(error?.classification, .indeterminate) + XCTAssertEqual(error?.retrySafe, false) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertTrue(adapter.hasPendingDisconnect(targetUUID)) + XCTAssertTrue(adapter.hasReconnectPersistenceUncertain(targetUUID)) + XCTAssertTrue(adapter.restoredRecoveryCapabilityStates.isEmpty) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + } + + func testConcurrentQuarantineReconciliationIsSingleFlightBeforeFreshRequest() async throws { + let prepared = recoveryCapability(state: .prepared) + let available = recoveryCapability(state: .available) + let quarantined = fallbackObservation( + pending: [targetUUID], + reconnectPersistenceUncertain: [targetUUID], + recoveryCapabilities: [prepared] + ) + let adapter = FakeConnectionAdapter( + observations: [ + quarantined, + quarantined, + quarantined, + fallbackObservation(recoveryCapabilities: [available]), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + pendingDisconnectUUIDs: [targetUUID], + reconnectPersistenceUncertainUUIDs: [targetUUID], + suspendFirstQuarantineReconciliation: true + ) + let first = Task { @MainActor in + await self.mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + } + for _ in 0..<100 where !adapter.isQuarantineReconciliationSuspended { + await Task.yield() + } + XCTAssertTrue(adapter.isQuarantineReconciliationSuspended) + + let overlapError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(overlapError?.classification, .indeterminate) + XCTAssertEqual(overlapError?.retrySafe, false) + XCTAssertEqual(overlapError?.mutationDispatched, false) + XCTAssertEqual(adapter.quarantineReconciliationAttemptCount, 1) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.hasReconnectPersistenceUncertain(targetUUID)) + + adapter.resumeFirstQuarantineReconciliation() + let firstError = await first.value + XCTAssertEqual(firstError?.classification, .indeterminate) + XCTAssertEqual(firstError?.retrySafe, false) + XCTAssertTrue(adapter.dispatched.isEmpty) + + _ = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(adapter.dispatched, [request( + state: .connected, + authorization: .oneShotRecovery + )]) + } + + func testPersistedReconnectOrphanRequiresReadBackOnlyRequestBeforeFreshReconnect() async throws { + let indeterminate = recoveryCapability(state: .indeterminate) + let available = recoveryCapability(state: .available) + let persisted = DisplayConnectionPersistenceEnvelope( + records: [DisplayConnectionPersistedRecord( + uuid: targetUUID, + displayID: targetID, + name: "Target", + width: 2560, + height: 1440, + recoveryCapability: indeterminate + )], + pendingUUIDs: [], + reconnectReservationUUIDs: [targetUUID] + ) + let relaunchedState = try JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: JSONEncoder().encode(persisted) + ) + XCTAssertEqual(relaunchedState.reconnectReservationSet, [targetUUID]) + let reservedOffline = observation( + online: [otherUUID], + records: Set(relaunchedState.records.map(\.uuid)), + reconnectReservations: relaunchedState.reconnectReservationSet, + recoveryCapabilities: [indeterminate] + ) + let relaunched = FakeConnectionAdapter( + observations: [ + reservedOffline, + reservedOffline, + observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [available] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [available] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID] + ) + + let firstError = await mutationFailure { + try await self.coordinator(relaunched).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(firstError?.classification, .indeterminate) + XCTAssertEqual(firstError?.retrySafe, false) + XCTAssertEqual(firstError?.mutationDispatched, false) + XCTAssertTrue(firstError?.message.contains("prior reconnect attempt") == true) + XCTAssertTrue(firstError?.message.contains("fresh read-back") == true) + XCTAssertTrue(firstError?.message.contains("fresh explicit user decision") == true) + XCTAssertTrue(relaunched.dispatched.isEmpty) + XCTAssertFalse(relaunched.hasReconnectReservation(targetUUID)) + XCTAssertEqual(relaunched.restoredRecoveryCapabilityStates[targetUUID], .available) + XCTAssertTrue(relaunched.removed.isEmpty) + + let result = try await coordinator(relaunched).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(relaunched.dispatched, [request(state: .connected)]) + XCTAssertEqual(relaunched.removed, [targetUUID]) + } + + func testPersistedFallbackOrphanRestoresValidatedCapabilityBeforeFreshRequestDispatches() async { + let indeterminate = recoveryCapability(state: .indeterminate) + let available = recoveryCapability(state: .available) + let unrelatedNilCandidate = candidate( + displayID: 3, + uuid: nil, + online: false, + hardwareBacked: false, + hasRecoveryProof: false + ) + let orphan = fallbackObservation( + extraCandidates: [unrelatedNilCandidate], + reconnectReservations: [targetUUID], + recoveryCapabilities: [indeterminate] + ) + let adapter = FakeConnectionAdapter( + observations: [ + orphan, + orphan, + fallbackObservation( + extraCandidates: [unrelatedNilCandidate], + recoveryCapabilities: [available] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .consumed)] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID] + ) + + let firstError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(firstError?.classification, .indeterminate) + XCTAssertEqual(firstError?.retrySafe, false) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertFalse(adapter.hasReconnectReservation(targetUUID)) + XCTAssertEqual(adapter.restoredRecoveryCapabilityStates[targetUUID], .available) + + let result = try? await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result?.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched, [request( + state: .connected, + authorization: .oneShotRecovery + )]) + XCTAssertEqual(adapter.consumed, [targetUUID]) + } + + func testPersistedFallbackOrphanRejectsAmbiguousOrDiscontinuousProofWithoutCleanup() async { + let consumed = recoveryCapability(state: .consumed) + let cases: [DisplayConnectionObservation] = [ + fallbackObservation( + extraCandidates: [candidate(displayID: 3, uuid: nil, online: false)], + reconnectReservations: [targetUUID], + recoveryCapabilities: [consumed] + ), + fallbackObservation( + reconnectReservations: [targetUUID], + recoveryCapabilities: [consumed], + topologyFingerprint: "topology-B" + ), + fallbackObservation( + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .invalidatedByWake)] + ), + fallbackObservation( + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability( + wakeSessionID: "0:0", + state: .consumed + )] + ), + fallbackObservation( + candidateUUID: thirdUUID, + reconnectReservations: [targetUUID], + recoveryCapabilities: [consumed] + ), + fallbackObservation( + reconnectReservations: [targetUUID], + recoveryCapabilities: [recoveryCapability(state: .prepared)] + ) + ] + + for orphan in cases { + let adapter = FakeConnectionAdapter( + observations: [orphan, orphan], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID] + ) + + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.hasReconnectReservation(targetUUID)) + XCTAssertTrue(adapter.restoredRecoveryCapabilityStates.isEmpty) + } + } + + func testPersistedReconnectOrphanAlreadyOnlineCleansUpWithoutDispatch() async throws { + let adapter = FakeConnectionAdapter( + observations: [ + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID] + ) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertEqual(adapter.removed, [targetUUID]) + XCTAssertFalse(adapter.hasReconnectReservation(targetUUID)) + XCTAssertTrue(adapter.reconciledOrphans.isEmpty) + } + + func testExactOrphanIgnoresInvalidatedFallbackAfterReadBackOnlyReconciliation() async throws { + let invalidated = recoveryCapability(state: .invalidatedByWake) + let reservedOffline = observation( + online: [otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [invalidated] + ) + let adapter = FakeConnectionAdapter( + observations: [ + reservedOffline, + reservedOffline, + observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [invalidated] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + reconnectReservations: [targetUUID], + recoveryCapabilities: [invalidated] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID] + ) + + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertFalse(adapter.hasReconnectReservation(targetUUID)) + XCTAssertTrue(adapter.restoredRecoveryCapabilityStates.isEmpty) + + _ = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(adapter.dispatched, [request(state: .connected)]) + XCTAssertTrue(adapter.consumed.isEmpty) + } + + func testPersistedReconnectOrphanPersistenceFailureRemainsFailClosed() async { + let consumed = recoveryCapability(state: .consumed) + let orphan = fallbackObservation( + reconnectReservations: [targetUUID], + recoveryCapabilities: [consumed] + ) + let adapter = FakeConnectionAdapter( + observations: [orphan, orphan], + dispatchOutcome: .completed, + reconnectReservations: [targetUUID], + orphanReconciliationFailure: true + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(error?.classification, .indeterminate) + XCTAssertEqual(error?.retrySafe, false) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.hasReconnectReservation(targetUUID)) + XCTAssertTrue(adapter.restoredRecoveryCapabilityStates.isEmpty) + } + + func testWakeInvalidatesFallbackWithoutBlockingFreshExactReconnect() async throws { + let invalidated = recoveryCapability(state: .invalidatedByWake) + let fallback = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: [invalidated])], + dispatchOutcome: .completed, + retainedCapability: invalidated + ) + + await assertFailure( + from: { try await self.coordinator(fallback).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(fallback.dispatched.isEmpty) + + let exact = FakeConnectionAdapter( + observations: [ + observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [invalidated] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + recoveryCapabilities: [invalidated] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + _ = try await coordinator(exact).reconnect(uuid: targetUUID) + + XCTAssertEqual(exact.dispatched, [request(state: .connected)]) + XCTAssertTrue(exact.consumed.isEmpty) + } + + func testMalformedWakeTokenBlocksOnlyFallbackAndFreshExactUUIDStillReconnects() async throws { + let legacy = recoveryCapability(wakeSessionID: "0:0", state: .available) + let fallback = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: [legacy])], + dispatchOutcome: .completed + ) + + await assertFailure( + from: { try await self.coordinator(fallback).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(fallback.dispatched.isEmpty) + + let exact = FakeConnectionAdapter( + observations: [ + observation( + online: [otherUUID], + records: [targetUUID], + recoveryCapabilities: [legacy] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + recoveryCapabilities: [legacy] + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + _ = try await coordinator(exact).reconnect(uuid: targetUUID) + + XCTAssertEqual(exact.dispatched, [request(state: .connected)]) + XCTAssertEqual(exact.removed, [targetUUID]) + } + + func testLegacyPersistenceEnvelopeDecodesWithoutFallbackOrReservationAndOnlineCleans() async throws { + let legacy = Data(#""" + { + "records": [{ + "uuid": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "displayID": 2, + "name": "Target", + "width": 2560, + "height": 1440 + }], + "pendingUUIDs": [] + } + """#.utf8) + let state = try JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: legacy + ) + let record = try XCTUnwrap(state.records.first) + XCTAssertNil(record.recoveryCapability) + XCTAssertTrue(state.reconnectReservationUUIDs.isEmpty) + + let adapter = FakeConnectionAdapter( + observations: [ + observation( + online: [targetUUID, otherUUID], + records: Set(state.records.map(\.uuid)), + pending: state.pendingSet, + reconnectReservations: state.reconnectReservationSet, + recoveryCapabilities: state.records.compactMap(\.recoveryCapability) + ), + observation(online: [targetUUID, otherUUID], records: []) + ], + dispatchOutcome: .completed + ) + + _ = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertEqual(adapter.removed, [targetUUID]) + } + + func testPersistenceEnvelopeRejectsDuplicateRecordUUIDs() throws { + let duplicate = Data(#""" + { + "records": [ + {"uuid":"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA","displayID":2, + "name":"First","width":2560,"height":1440}, + {"uuid":"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA","displayID":3, + "name":"Second","width":1920,"height":1080} + ], + "pendingUUIDs": [], + "reconnectReservationUUIDs": [] + } + """#.utf8) + + XCTAssertThrowsError(try JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: duplicate + )) + } + + func testReconnectFallbackRejectsReusedAmbiguousOrMissingCandidatesWithoutDispatch() async { + let available = recoveryCapability(state: .available) + let unsafeObservations = [ + fallbackObservation(candidateUUID: thirdUUID, recoveryCapabilities: [available]), + fallbackObservation(candidateOnline: true, recoveryCapabilities: [available]), + fallbackObservation( + extraCandidates: [candidate(displayID: 3, uuid: nil, online: false)], + recoveryCapabilities: [available] + ), + fallbackObservation( + extraCandidates: [candidate(displayID: targetID, uuid: nil, online: false)], + recoveryCapabilities: [available] + ), + fallbackObservation( + candidateDisplayID: 0, + recoveryCapabilities: [recoveryCapability(displayID: 0, state: .available)] + ), + fallbackObservation(includeTargetCandidate: false, recoveryCapabilities: [available]), + fallbackObservation( + records: [targetUUID, thirdUUID], + recoveryCapabilities: [ + available, + recoveryCapability(uuid: thirdUUID, state: .available) + ] + ) + ] + + for unsafe in unsafeObservations { + let adapter = FakeConnectionAdapter( + observations: [unsafe], + dispatchOutcome: .completed, + retainedCapability: available + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.consumed.isEmpty) + } + } + + func testReconnectFallbackRejectsMissingIncompleteOrNonuniqueHardwareProof() async { + let available = recoveryCapability(state: .available) + let incompleteProof = DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity(vendorID: 1715, productID: 10068, serialNumber: nil) + ) + let unsafeObservations = [ + fallbackObservation( + candidateHasRecoveryProof: false, + recoveryCapabilities: [available] + ), + fallbackObservation( + candidateProof: incompleteProof, + recoveryCapabilities: [recoveryCapability( + hardwareProof: incompleteProof, + state: .available + )] + ), + fallbackObservation( + extraCandidates: [candidate( + displayID: 3, + uuid: thirdUUID, + online: false, + recoveryProof: recoveryHardwareProof + )], + recoveryCapabilities: [available] + ) + ] + + for unsafe in unsafeObservations { + let adapter = FakeConnectionAdapter( + observations: [unsafe], + dispatchOutcome: .completed, + retainedCapability: available + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + } + + func testReconnectFallbackRejectsChangedMissingOrLegacyContinuity() async { + let available = recoveryCapability(state: .available) + let continuityChanges = [ + fallbackObservation(recoveryCapabilities: [available], bootSessionID: "boot-B"), + fallbackObservation(recoveryCapabilities: [available], loginSessionID: "login-B"), + fallbackObservation( + recoveryCapabilities: [available], + wakeSessionID: "mach-sleep-offset-v1:10002000001" + ), + fallbackObservation(recoveryCapabilities: [available], wakeSessionID: "0:0"), + fallbackObservation( + recoveryCapabilities: [available], + topologyFingerprint: "topology-B" + ), + fallbackObservation(recoveryCapabilities: [available], bootSessionID: nil), + fallbackObservation(recoveryCapabilities: [available], wakeSessionID: nil), + fallbackObservation(recoveryCapabilities: [available], topologyFingerprint: nil) + ] + + for changed in continuityChanges { + let adapter = FakeConnectionAdapter( + observations: [changed], + dispatchOutcome: .completed, + retainedCapability: available + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + } + + func testReconnectFallbackRejectsOldConsumedAndIndeterminateRecords() async { + let unavailableCapabilities: [[DisplayConnectionRecoveryCapability]] = [ + [], + [recoveryCapability(state: .prepared)], + [recoveryCapability(state: .consumed)], + [recoveryCapability(state: .indeterminate)] + ] + + for capabilities in unavailableCapabilities { + let adapter = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: capabilities)], + dispatchOutcome: .completed + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + } + + func testReconnectFallbackEnumerationOrPersistenceFailureNeverDispatches() async { + let available = recoveryCapability(state: .available) + let enumerationFailure = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: [available])], + dispatchOutcome: .completed, + observationFailure: true + ) + await assertFailure( + from: { try await self.coordinator(enumerationFailure).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(enumerationFailure.dispatched.isEmpty) + + let persistenceFailure = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: [available])], + dispatchOutcome: .completed, + retainedCapability: available, + consumeFailure: true + ) + await assertFailure( + from: { try await self.coordinator(persistenceFailure).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(persistenceFailure.dispatched.isEmpty) + } + + func testReconnectThatNeverSettlesKeepsRecordAndIsIndeterminate() async { + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [otherUUID], records: [targetUUID]), + observation(online: [otherUUID], records: [targetUUID]), + observation(online: [otherUUID], records: [targetUUID]) + ], + dispatchOutcome: .completed + ) + + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertTrue(adapter.removed.isEmpty) + } +} + +extension DisplayConnectionCoordinatorTests { + func testFinalExactDisconnectAuthorizationRequiresFreshPreparedRecoveryContinuity() { + let prepared = recoveryCapability(state: .prepared) + let authorized = observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + recoveryCapabilities: [prepared] + ) + XCTAssertTrue(DisplayConnectionRecoveryResolver.authorizesExactDisconnect( + uuid: targetUUID, + displayID: targetID, + observation: authorized + )) + + let unsafe = [ + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + recoveryCapabilities: [prepared] + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + recoveryCapabilities: [prepared], + wakeSessionID: "mach-sleep-offset-v1:10002000001" + ), + observation( + online: [targetUUID, otherUUID], + records: [targetUUID], + pending: [targetUUID], + recoveryCapabilities: [recoveryCapability( + wakeSessionID: "0:0", + state: .prepared + )] + ) + ] + for observation in unsafe { + XCTAssertFalse(DisplayConnectionRecoveryResolver.authorizesExactDisconnect( + uuid: targetUUID, + displayID: targetID, + observation: observation + )) + } + } + + func testDisconnectPreflightGatesNeverDispatchMutation() async { + let cases: [(DisplayConnectionTarget, DisplayConnectionObservation)] = [ + (target(targetUUID), observation( + platformSupported: false, online: [targetUUID, otherUUID], records: [] + )), + (target(targetUUID, isHardwareBackedPhysical: false), observation( + online: [targetUUID, otherUUID], records: [], virtual: [targetUUID] + )), + (target(targetUUID), observation( + online: [targetUUID], records: [], activePhysical: [targetUUID] + )), + (target(targetUUID), observation( + all: [otherUUID], online: [otherUUID], records: [] + )) + ] + + for (target, firstObservation) in cases { + let adapter = FakeConnectionAdapter( + observations: [firstObservation], dispatchOutcome: .completed + ) + await assertFailure( + from: { try await self.coordinator(adapter).disconnect(target) }, + classification: .preflightRejected, + uuid: target.uuid + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.retained.isEmpty) + } + } + + func testThirdPartyVirtualWithoutHardwareBackingIsUnsupportedExcludedAndNeverDispatched() async throws { + let thirdPartyVendorID: UInt32 = 0x1234 + XCTAssertNotEqual(thirdPartyVendorID, 0xEEEE) + let unbackedEvidence = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: false, + isKnownVirtual: false, + hasIOServicePort: false, + ioServiceConformsToDisplayConnect: false + ) + + XCTAssertFalse(HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(unbackedEvidence)) + let capability = try XCTUnwrap( + HardwareBackedPhysicalDisplayClassifier.unsupportedConnectionCapability( + for: unbackedEvidence, + connected: true + ) + ) + XCTAssertEqual(capability.state, .unsupported) + XCTAssertFalse(capability.disconnectAllowed) + XCTAssertTrue(capability.reason?.contains("hardware-backed physical") == true) + + let activeCandidates = [ + (targetUUID, unbackedEvidence), + (otherUUID, HardwareBackedPhysicalDisplayEvidence( + isBuiltin: false, + isKnownVirtual: false, + hasIOServicePort: true, + ioServiceConformsToDisplayConnect: true + )) + ] + let activePhysical = Set(activeCandidates.compactMap { uuid, evidence in + HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(evidence) ? uuid : nil + }) + XCTAssertEqual(activePhysical, [otherUUID]) + + let adapter = FakeConnectionAdapter( + observations: [observation( + online: [targetUUID, otherUUID], + records: [], + virtual: [targetUUID], + activePhysical: activePhysical + )], + dispatchOutcome: .completed + ) + await assertFailure( + from: { + try await self.coordinator(adapter).disconnect( + self.target(self.targetUUID, isHardwareBackedPhysical: false) + ) + }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + XCTAssertTrue(adapter.retained.isEmpty) + } + + func testPositiveHardwareBackingProofAcceptsBuiltInAndIODisplayConnectExternal() { + let builtIn = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: true, + isKnownVirtual: false, + hasIOServicePort: false, + ioServiceConformsToDisplayConnect: false + ) + let external = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: false, + isKnownVirtual: false, + hasIOServicePort: true, + ioServiceConformsToDisplayConnect: true + ) + let unprovenExternal = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: false, + isKnownVirtual: false, + hasIOServicePort: true, + ioServiceConformsToDisplayConnect: false + ) + + XCTAssertTrue(HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(builtIn)) + XCTAssertTrue(HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(external)) + XCTAssertFalse(HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(unprovenExternal)) + XCTAssertNil( + HardwareBackedPhysicalDisplayClassifier.unsupportedConnectionCapability( + for: builtIn, + connected: true + ) + ) + XCTAssertNil( + HardwareBackedPhysicalDisplayClassifier.unsupportedConnectionCapability( + for: external, + connected: true + ) + ) + } + + func testReconnectRequiresFreshExactRecordAndResolvableUUIDBeforeDispatch() async { + for uuid in ["main", "Fixture Display", targetUUID] { + let records: Set = uuid == targetUUID ? [] : [uuid] + let adapter = FakeConnectionAdapter( + observations: [observation(online: [otherUUID], records: records)], + dispatchOutcome: .completed + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: uuid) }, + classification: .preflightRejected, + uuid: uuid + ) + XCTAssertTrue(adapter.dispatched.isEmpty) + } + + let stale = FakeConnectionAdapter( + observations: [observation( + all: [otherUUID], online: [otherUUID], records: [targetUUID] + )], + dispatchOutcome: .completed + ) + await assertFailure( + from: { try await self.coordinator(stale).reconnect(uuid: self.targetUUID) }, + classification: .preflightRejected, + uuid: targetUUID + ) + XCTAssertTrue(stale.dispatched.isEmpty) + } + + func testPreDispatchRejectionIsDefiniteAndCleansNewDisconnectRecord() async { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [observation(online: [targetUUID, otherUUID], records: [])], + dispatchOutcome: .rejectedBeforeDispatch("target disappeared during re-resolution"), + retainedCapability: retained + ) + + await assertFailure( + from: { try await self.coordinator(adapter).disconnect(self.target(self.targetUUID)) }, + classification: .definiteFailure, + uuid: targetUUID + ) + XCTAssertEqual(adapter.retained, [targetUUID]) + XCTAssertEqual(adapter.removed, [targetUUID]) + } + + func testEveryPostDispatchNoncompletionIsIndeterminateAndRetainsRecoveryState() async { + let outcomes: [DisplayConnectionDispatchOutcome] = [ + .failedAfterDispatch("configuration completion failed"), + .timedOut, + .cancelled + ] + for outcome in outcomes { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [observation(online: [targetUUID, otherUUID], records: [])], + dispatchOutcome: outcome, + retainedCapability: retained + ) + await assertFailure( + from: { try await self.coordinator(adapter).disconnect(self.target(self.targetUUID)) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertEqual(adapter.retained, [targetUUID]) + XCTAssertTrue(adapter.confirmed.isEmpty) + XCTAssertTrue(adapter.removed.isEmpty) + } + } + + func testEveryPostDispatchConsumedFallbackNoncompletionIsIndeterminateAndNeverRetries() async { + let outcomes: [DisplayConnectionDispatchOutcome] = [ + .failedAfterDispatch("configuration completion failed"), + .timedOut, + .cancelled + ] + for outcome in outcomes { + let available = recoveryCapability(state: .available) + let adapter = FakeConnectionAdapter( + observations: [fallbackObservation(recoveryCapabilities: [available])], + dispatchOutcome: outcome, + retainedCapability: available + ) + await assertFailure( + from: { try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertEqual(adapter.dispatched.count, 1) + XCTAssertEqual(adapter.consumed, [targetUUID]) + XCTAssertEqual(adapter.indeterminate, [targetUUID]) + XCTAssertTrue(adapter.removed.isEmpty) + } + } + + func testFallbackRejectedBeforeDispatchAtomicallyRestoresCapabilityForFreshRetry() async throws { + let adapter = ProductionShapedReconnectAdapter( + uuid: targetUUID, + displayID: targetID, + otherUUID: otherUUID, + recoveryCapability: recoveryCapability(state: .available), + resolution: .fallback, + dispatchOutcomes: [ + .rejectedBeforeDispatch("final continuity check failed"), + .completed + ] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(error?.classification, .definiteFailure) + XCTAssertEqual(error?.retrySafe, true) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertFalse(adapter.hasReconnectReservation) + XCTAssertFalse(adapter.hasLiveReconnectOwner) + XCTAssertEqual(adapter.recoveryCapabilityState, .available) + XCTAssertEqual(adapter.rollbackPersistenceWriteCount, 1) + XCTAssertEqual(adapter.osDisplayCallCount, 0) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched.count, 2) + XCTAssertEqual(adapter.consumeCount, 2) + XCTAssertEqual(adapter.osDisplayCallCount, 1) + } + + func testRejectedBeforeDispatchRollbackFailureRetainsOrphanProtocolState() async throws { + let adapter = ProductionShapedReconnectAdapter( + uuid: targetUUID, + displayID: targetID, + otherUUID: otherUUID, + recoveryCapability: recoveryCapability(state: .available), + resolution: .fallback, + dispatchOutcomes: [ + .rejectedBeforeDispatch("final continuity check failed"), + .completed + ], + rollbackFailures: 1 + ) + + let rollbackError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(rollbackError?.classification, .indeterminate) + XCTAssertEqual(rollbackError?.retrySafe, false) + XCTAssertEqual(rollbackError?.mutationDispatched, false) + XCTAssertTrue(adapter.hasReconnectReservation) + XCTAssertFalse(adapter.hasLiveReconnectOwner) + XCTAssertEqual(adapter.recoveryCapabilityState, .consumed) + XCTAssertEqual(adapter.rollbackPersistenceWriteCount, 0) + XCTAssertEqual(adapter.osDisplayCallCount, 0) + + let orphanError = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(orphanError?.classification, .indeterminate) + XCTAssertEqual(orphanError?.retrySafe, false) + XCTAssertEqual(orphanError?.mutationDispatched, false) + XCTAssertEqual(adapter.orphanReconciliationCount, 1) + XCTAssertFalse(adapter.hasReconnectReservation) + XCTAssertEqual(adapter.recoveryCapabilityState, .available) + XCTAssertEqual(adapter.dispatched.count, 1) + + let result = try await coordinator(adapter).reconnect(uuid: targetUUID) + + XCTAssertEqual(result.observedConnectionState, .connected) + XCTAssertEqual(adapter.dispatched.count, 2) + XCTAssertEqual(adapter.osDisplayCallCount, 1) + } + + func testExactRejectedBeforeDispatchClearsReservationWithoutCapabilityCorruption() async { + let adapter = ProductionShapedReconnectAdapter( + uuid: targetUUID, + displayID: targetID, + otherUUID: otherUUID, + recoveryCapability: recoveryCapability(state: .invalidatedByWake), + resolution: .exactUUID, + dispatchOutcomes: [.rejectedBeforeDispatch("exact UUID continuity changed")] + ) + + let error = await mutationFailure { + try await self.coordinator(adapter).reconnect(uuid: self.targetUUID) + } + + XCTAssertEqual(error?.classification, .definiteFailure) + XCTAssertEqual(error?.retrySafe, true) + XCTAssertEqual(error?.mutationDispatched, false) + XCTAssertFalse(adapter.hasReconnectReservation) + XCTAssertFalse(adapter.hasLiveReconnectOwner) + XCTAssertEqual(adapter.recoveryCapabilityState, .invalidatedByWake) + XCTAssertEqual(adapter.rollbackPersistenceWriteCount, 1) + XCTAssertEqual(adapter.osDisplayCallCount, 0) + } + + func testCancellationDuringSettlementIsIndeterminateWithoutAutomaticRetry() async { + let retained = recoveryCapability(state: .prepared) + let adapter = FakeConnectionAdapter( + observations: [ + observation(online: [targetUUID, otherUUID], records: []), + observation(online: [targetUUID, otherUUID], records: [targetUUID]) + ], + dispatchOutcome: .completed, + retainedCapability: retained + ) + let coordinator = DisplayConnectionMutationCoordinator( + adapter: adapter, + settlementAttempts: 3, + settlementInterval: .milliseconds(1), + sleep: { _ in throw CancellationError() } + ) + + await assertFailure( + from: { try await coordinator.disconnect(self.target(self.targetUUID)) }, + classification: .indeterminate, + uuid: targetUUID + ) + XCTAssertEqual(adapter.dispatched.count, 1) + } +} + +extension DisplayConnectionCoordinatorTests { + private func coordinator( + _ adapter: any DisplayConnectionMutationAdapter + ) -> DisplayConnectionMutationCoordinator { + DisplayConnectionMutationCoordinator( + adapter: adapter, + settlementAttempts: 2, + settlementInterval: .zero, + sleep: { _ in } + ) + } + + private func target( + _ uuid: String, + isHardwareBackedPhysical: Bool = true + ) -> DisplayConnectionTarget { + DisplayConnectionTarget( + uuid: uuid, + displayID: targetID, + name: "Target", + width: 2560, + height: 1440, + isHardwareBackedPhysical: isHardwareBackedPhysical + ) + } + + private func observation( + platformSupported: Bool = true, + all: Set? = nil, + online: Set, + records: Set, + pending: Set = [], + reconnectReservations: Set = [], + reconnectPersistenceUncertain: Set = [], + virtual: Set = [], + activePhysical: Set? = nil, + candidates: [DisplayConnectionCandidate]? = nil, + recoveryCapabilities: [DisplayConnectionRecoveryCapability] = [], + bootSessionID: String? = "boot-A", + loginSessionID: String? = "login-A", + wakeSessionID: String? = "mach-sleep-offset-v1:10000000000", + topologyFingerprint: String? = "topology-A" + ) -> DisplayConnectionObservation { + let allUUIDs = all ?? online.union(records) + let resolvedCandidates = candidates ?? allUUIDs.sorted().map { uuid in + candidate( + displayID: uuid == targetUUID ? targetID : otherID, + uuid: uuid, + online: online.contains(uuid), + hardwareBacked: !virtual.contains(uuid) + ) + } + return DisplayConnectionObservation( + platformSupported: platformSupported, + allUUIDs: allUUIDs, + onlineUUIDs: online, + intentionalDisconnectedUUIDs: records, + pendingDisconnectUUIDs: pending, + reconnectReservationUUIDs: reconnectReservations, + reconnectPersistenceUncertainUUIDs: reconnectPersistenceUncertain, + virtualUUIDs: virtual, + activePhysicalViewableUUIDs: activePhysical ?? online.subtracting(virtual), + candidates: resolvedCandidates, + recoveryCapabilities: recoveryCapabilities, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint + ) + } + + private func fallbackObservation( + candidateDisplayID: UInt32? = nil, + candidateUUID: String? = nil, + candidateOnline: Bool = false, + candidateHasRecoveryProof: Bool = true, + candidateProof: DisplayConnectionRecoveryHardwareProof? = nil, + includeTargetCandidate: Bool = true, + extraCandidates: [DisplayConnectionCandidate] = [], + records: Set? = nil, + pending: Set = [], + reconnectReservations: Set = [], + reconnectPersistenceUncertain: Set = [], + recoveryCapabilities: [DisplayConnectionRecoveryCapability], + bootSessionID: String? = "boot-A", + loginSessionID: String? = "login-A", + wakeSessionID: String? = "mach-sleep-offset-v1:10000000000", + topologyFingerprint: String? = "topology-A" + ) -> DisplayConnectionObservation { + let resolvedDisplayID = candidateDisplayID ?? targetID + let targetCandidate = candidate( + displayID: resolvedDisplayID, + uuid: candidateUUID, + online: candidateOnline, + hardwareBacked: false, + recoveryProof: candidateProof ?? recoveryHardwareProof, + hasRecoveryProof: candidateHasRecoveryProof + ) + let candidates = [candidate(displayID: otherID, uuid: otherUUID, online: true)] + + (includeTargetCandidate ? [targetCandidate] : []) + + extraCandidates + let onlineUUIDs = Set(candidates.compactMap { candidate in + candidate.isOnline ? candidate.stableUUID : nil + }) + return observation( + all: Set(candidates.compactMap(\.stableUUID)), + online: onlineUUIDs, + records: records ?? [targetUUID], + pending: pending, + reconnectReservations: reconnectReservations, + reconnectPersistenceUncertain: reconnectPersistenceUncertain, + candidates: candidates, + recoveryCapabilities: recoveryCapabilities, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint + ) + } + + private func candidate( + displayID: UInt32, + uuid: String?, + online: Bool, + hardwareBacked: Bool = true, + recoveryProof: DisplayConnectionRecoveryHardwareProof? = nil, + hasRecoveryProof: Bool = true + ) -> DisplayConnectionCandidate { + let defaultProof = displayID == otherID ? otherRecoveryHardwareProof : recoveryHardwareProof + return DisplayConnectionCandidate( + displayID: displayID, + stableUUID: uuid, + isOnline: online, + isHardwareBackedPhysical: hardwareBacked, + recoveryHardwareProof: hasRecoveryProof ? (recoveryProof ?? defaultProof) : nil + ) + } + + private func recoveryCapability( + uuid: String? = nil, + displayID: UInt32? = nil, + hardwareProof: DisplayConnectionRecoveryHardwareProof? = nil, + wakeSessionID: String? = nil, + state: DisplayConnectionRecoveryCapabilityState + ) -> DisplayConnectionRecoveryCapability { + DisplayConnectionRecoveryCapability( + uuid: uuid ?? targetUUID, + displayID: displayID ?? targetID, + hardwareProof: hardwareProof ?? recoveryHardwareProof, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID ?? self.wakeSessionID, + topologyFingerprint: topologyFingerprint, + state: state + ) + } + + private func request( + state: DisplayConnectionState, + authorization: DisplayConnectionDispatchAuthorization = .exactUUID + ) -> DisplayConnectionDispatchRequest { + DisplayConnectionDispatchRequest( + uuid: targetUUID, + displayID: targetID, + requestedState: state, + authorization: authorization + ) + } + + private var recoveryHardwareProof: DisplayConnectionRecoveryHardwareProof { + DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: 1715, + productID: 10068, + serialNumber: 16843009 + ) + ) + } + + private var otherRecoveryHardwareProof: DisplayConnectionRecoveryHardwareProof { + DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: 1552, + productID: 41202, + serialNumber: 33624064 + ) + ) + } + + private func assertFailure( + from operation: () async throws -> DisplayConnectionSetResult, + classification: DisplayConnectionFailureClassification, + uuid: String, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + _ = try await operation() + XCTFail("expected display connection failure", file: file, line: line) + } catch let error as DisplayConnectionMutationError { + XCTAssertEqual(error.classification, classification, file: file, line: line) + XCTAssertEqual(error.displayUUID, uuid, file: file, line: line) + XCTAssertEqual( + error.retrySafe, + classification != .indeterminate, + file: file, + line: line + ) + } catch { + XCTFail("unexpected error: \(error)", file: file, line: line) + } + } + + private func mutationFailure( + from operation: () async throws -> DisplayConnectionSetResult, + file: StaticString = #filePath, + line: UInt = #line + ) async -> DisplayConnectionMutationError? { + do { + _ = try await operation() + XCTFail("expected display connection failure", file: file, line: line) + return nil + } catch let error as DisplayConnectionMutationError { + return error + } catch { + XCTFail("unexpected error: \(error)", file: file, line: line) + return nil + } + } + + private func assertPostCleanupProofFailure( + _ error: DisplayConnectionMutationError?, + adapter: FakeConnectionAdapter, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(error?.classification, .indeterminate, file: file, line: line) + XCTAssertEqual(error?.retrySafe, false, file: file, line: line) + XCTAssertEqual(error?.mutationDispatched, false, file: file, line: line) + XCTAssertTrue( + error?.message.contains( + "metadata cleanup occurred but final exact-online proof could not be verified" + ) == true, + file: file, + line: line + ) + XCTAssertTrue(error?.message.contains("no display write was issued") == true, + file: file, line: line) + XCTAssertEqual(adapter.observationCallCount, 3, file: file, line: line) + XCTAssertEqual(adapter.quarantineFinishCount, 1, file: file, line: line) + XCTAssertFalse(adapter.hasLiveQuarantineReconciliation(targetUUID), file: file, line: line) + XCTAssertTrue(adapter.dispatched.isEmpty, file: file, line: line) + } +} + +@MainActor +private final class FakeConnectionAdapter: DisplayConnectionMutationAdapter { + private var observations: [DisplayConnectionObservation] + private let dispatchOutcome: DisplayConnectionDispatchOutcome + private let retainedCapability: DisplayConnectionRecoveryCapability? + private let observationFailure: Bool + private let consumeFailure: Bool + private let suspendFirstDispatch: Bool + private let orphanReconciliationFailure: Bool + private let quarantineReconciliationFailure: Bool + private let suspendFirstQuarantineReconciliation: Bool + private let observationFailureCalls: Set + private let observationCancellationCalls: Set + private var firstDispatchContinuation: CheckedContinuation? + private var firstQuarantineContinuation: CheckedContinuation? + private var reconnectReservations: Set + private var pendingDisconnectUUIDs: Set + private var reconnectPersistenceUncertainUUIDs: Set + private var liveReconnectReservations: Set = [] + private var liveQuarantineReconciliations: Set = [] + private var didSuspendQuarantineReconciliation = false + private(set) var dispatched: [DisplayConnectionDispatchRequest] = [] + private(set) var retained: [String] = [] + private(set) var confirmed: [String] = [] + private(set) var removed: [String] = [] + private(set) var consumed: [String] = [] + private(set) var indeterminate: [String] = [] + private(set) var reconciledOrphans: [String] = [] + private(set) var quarantineReconciliationCount = 0 + private(set) var quarantineReconciliationAttemptCount = 0 + private(set) var quarantineFinishCount = 0 + private(set) var observationCallCount = 0 + private(set) var restoredRecoveryCapabilityStates: [ + String: DisplayConnectionRecoveryCapabilityState + ] = [:] + + init( + observations: [DisplayConnectionObservation], + dispatchOutcome: DisplayConnectionDispatchOutcome, + retainedCapability: DisplayConnectionRecoveryCapability? = nil, + observationFailure: Bool = false, + consumeFailure: Bool = false, + suspendFirstDispatch: Bool = false, + reconnectReservations: Set = [], + pendingDisconnectUUIDs: Set = [], + reconnectPersistenceUncertainUUIDs: Set = [], + orphanReconciliationFailure: Bool = false, + quarantineReconciliationFailure: Bool = false, + suspendFirstQuarantineReconciliation: Bool = false, + observationFailureCalls: Set = [], + observationCancellationCalls: Set = [] + ) { + self.observations = observations + self.dispatchOutcome = dispatchOutcome + self.retainedCapability = retainedCapability + self.observationFailure = observationFailure + self.consumeFailure = consumeFailure + self.suspendFirstDispatch = suspendFirstDispatch + self.reconnectReservations = reconnectReservations + self.pendingDisconnectUUIDs = pendingDisconnectUUIDs + self.reconnectPersistenceUncertainUUIDs = reconnectPersistenceUncertainUUIDs + self.orphanReconciliationFailure = orphanReconciliationFailure + self.quarantineReconciliationFailure = quarantineReconciliationFailure + self.suspendFirstQuarantineReconciliation = suspendFirstQuarantineReconciliation + self.observationFailureCalls = observationFailureCalls + self.observationCancellationCalls = observationCancellationCalls + } + + func connectionObservation() throws -> DisplayConnectionObservation { + observationCallCount += 1 + if observationCancellationCalls.contains(observationCallCount) { + throw CancellationError() + } + if observationFailureCalls.contains(observationCallCount) { + throw FakeError.enumerationFailed + } + guard !observationFailure else { throw FakeError.enumerationFailed } + guard !observations.isEmpty else { throw FakeError.noObservation } + if observations.count == 1 { return observations[0] } + return observations.removeFirst() + } + + func retainDisconnectedRecord( + _ target: DisplayConnectionTarget + ) throws -> DisplayConnectionRecoveryCapability? { + retained.append(target.uuid) + return retainedCapability + } + + func removeDisconnectedRecord(uuid: String) throws { + removed.append(uuid) + reconnectReservations.remove(uuid) + liveReconnectReservations.remove(uuid) + } + + func confirmDisconnectedRecord(uuid: String) throws { + confirmed.append(uuid) + } + + func consumeRecoveryCapability( + _ capability: DisplayConnectionRecoveryCapability + ) throws -> DisplayConnectionRecoveryCapability { + guard !consumeFailure else { throw FakeError.persistenceFailed } + consumed.append(capability.uuid) + return DisplayConnectionRecoveryCapability( + uuid: capability.uuid, + displayID: capability.displayID, + hardwareProof: capability.hardwareProof, + bootSessionID: capability.bootSessionID, + loginSessionID: capability.loginSessionID, + wakeSessionID: capability.wakeSessionID, + topologyFingerprint: capability.topologyFingerprint, + state: .consumed + ) + } + + func reserveReconnect(uuid: String) throws { + guard !liveReconnectReservations.contains(uuid), + reconnectReservations.insert(uuid).inserted else { + throw FakeError.persistenceFailed + } + liveReconnectReservations.insert(uuid) + } + + func releaseReconnectReservation(uuid: String) throws { + guard liveReconnectReservations.contains(uuid), + reconnectReservations.remove(uuid) != nil else { + throw FakeError.persistenceFailed + } + liveReconnectReservations.remove(uuid) + } + + func rollbackRejectedReconnectBeforeDispatch( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) throws { + guard liveReconnectReservations.contains(uuid), + reconnectReservations.contains(uuid) else { + throw FakeError.persistenceFailed + } + if let consumedRecoveryCapability { + guard consumedRecoveryCapability.uuid == uuid, + consumedRecoveryCapability.state == .consumed else { + throw FakeError.persistenceFailed + } + restoredRecoveryCapabilityStates[uuid] = .available + } + reconnectReservations.remove(uuid) + liveReconnectReservations.remove(uuid) + } + + func reconcileOrphanedReconnectAttempt( + uuid: String + ) throws -> DisplayReconnectOrphanReconciliation { + guard reconnectReservations.contains(uuid) else { return .unavailable } + guard !liveReconnectReservations.contains(uuid) else { return .liveAttempt } + let observation = try connectionObservation() + if case .alreadyOnline = DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: uuid, + observation: observation + ) { + return .alreadyOnline + } + let resolution = DisplayConnectionRecoveryResolver.orphanedReconnectResolution( + uuid: uuid, + observation: observation + ) + var restoredState: DisplayConnectionRecoveryCapabilityState? + switch resolution { + case .exactUUID: + if let capability = DisplayConnectionRecoveryResolver + .restorableRecoveryCapabilityForExactOrphan( + uuid: uuid, + observation: observation + ), [.consumed, .indeterminate].contains(capability.state) { + restoredState = .available + } + case let .oneShotRecovery(capability): + restoredState = capability.changingState(to: .available).state + case .alreadyOnline, .unavailable: + return .unavailable + } + guard !orphanReconciliationFailure else { throw FakeError.persistenceFailed } + if let restoredState { + restoredRecoveryCapabilityStates[uuid] = restoredState + } + reconnectReservations.remove(uuid) + reconciledOrphans.append(uuid) + return .reconciled + } + + func reconcileQuarantinedReconnectAttempt( + uuid: String + ) async throws -> DisplayReconnectQuarantineReconciliation { + guard reconnectPersistenceUncertainUUIDs.contains(uuid) else { return .unavailable } + guard liveQuarantineReconciliations.insert(uuid).inserted else { return .liveAttempt } + quarantineReconciliationAttemptCount += 1 + let observation = try connectionObservation() + if suspendFirstQuarantineReconciliation, + !didSuspendQuarantineReconciliation { + didSuspendQuarantineReconciliation = true + await withCheckedContinuation { continuation in + firstQuarantineContinuation = continuation + } + } + guard !quarantineReconciliationFailure else { throw FakeError.persistenceFailed } + switch DisplayConnectionRecoveryResolver.quarantinedReconnectResolution( + uuid: uuid, + observation: observation + ) { + case .alreadyOnline: + removed.append(uuid) + case .exactUUID: + if DisplayConnectionRecoveryResolver.restorableRecoveryCapabilityForExactQuarantine( + uuid: uuid, + observation: observation + ) != nil { + restoredRecoveryCapabilityStates[uuid] = .available + } + case .oneShotRecovery: + restoredRecoveryCapabilityStates[uuid] = .available + case .unavailable: + return .unavailable + } + reconnectPersistenceUncertainUUIDs.remove(uuid) + pendingDisconnectUUIDs.remove(uuid) + reconnectReservations.remove(uuid) + quarantineReconciliationCount += 1 + return removed.last == uuid ? .alreadyOnline : .reconciled + } + + func finishQuarantinedReconnectAttempt(uuid: String) { + quarantineFinishCount += 1 + liveQuarantineReconciliations.remove(uuid) + } + + func markReconnectAttemptIndeterminate(uuid: String) throws { + liveReconnectReservations.remove(uuid) + indeterminate.append(uuid) + } + + func markRecoveryCapabilityIndeterminate(uuid: String) throws { + indeterminate.append(uuid) + } + + func dispatchConnectionChange( + _ request: DisplayConnectionDispatchRequest + ) async -> DisplayConnectionDispatchOutcome { + let shouldSuspend = suspendFirstDispatch && dispatched.isEmpty + dispatched.append(request) + if shouldSuspend { + await withCheckedContinuation { continuation in + firstDispatchContinuation = continuation + } + } + return dispatchOutcome + } + + func resumeFirstDispatch() { + firstDispatchContinuation?.resume() + firstDispatchContinuation = nil + } + + var isQuarantineReconciliationSuspended: Bool { + firstQuarantineContinuation != nil + } + + func resumeFirstQuarantineReconciliation() { + firstQuarantineContinuation?.resume() + firstQuarantineContinuation = nil + } + + func hasReconnectReservation(_ uuid: String) -> Bool { + reconnectReservations.contains(uuid) + } + + func hasPendingDisconnect(_ uuid: String) -> Bool { + pendingDisconnectUUIDs.contains(uuid) + } + + func hasReconnectPersistenceUncertain(_ uuid: String) -> Bool { + reconnectPersistenceUncertainUUIDs.contains(uuid) + } + + func hasLiveQuarantineReconciliation(_ uuid: String) -> Bool { + liveQuarantineReconciliations.contains(uuid) + } + + private enum FakeError: Error { + case noObservation + case enumerationFailed + case persistenceFailed + } +} + +@MainActor +private final class ProductionShapedReconnectAdapter: DisplayConnectionMutationAdapter { + enum Resolution: Equatable { + case exactUUID + case fallback + } + + private let uuid: String + private let displayID: UInt32 + private let otherUUID: String + private let resolution: Resolution + private var recoveryCapability: DisplayConnectionRecoveryCapability? + private var dispatchOutcomes: [DisplayConnectionDispatchOutcome] + private var rollbackFailures: Int + private var recordExists = true + private var targetIsOnline = false + private var reconnectReservation = false + private var liveReconnectOwner = false + + private(set) var dispatched: [DisplayConnectionDispatchRequest] = [] + private(set) var consumeCount = 0 + private(set) var osDisplayCallCount = 0 + private(set) var orphanReconciliationCount = 0 + private(set) var rollbackPersistenceWriteCount = 0 + + init( + uuid: String, + displayID: UInt32, + otherUUID: String, + recoveryCapability: DisplayConnectionRecoveryCapability?, + resolution: Resolution, + dispatchOutcomes: [DisplayConnectionDispatchOutcome], + rollbackFailures: Int = 0 + ) { + self.uuid = uuid + self.displayID = displayID + self.otherUUID = otherUUID + self.recoveryCapability = recoveryCapability + self.resolution = resolution + self.dispatchOutcomes = dispatchOutcomes + self.rollbackFailures = rollbackFailures + } + + var hasReconnectReservation: Bool { reconnectReservation } + var hasLiveReconnectOwner: Bool { liveReconnectOwner } + var recoveryCapabilityState: DisplayConnectionRecoveryCapabilityState? { + recoveryCapability?.state + } + + func connectionObservation() throws -> DisplayConnectionObservation { + let otherCandidate = DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: otherHardwareProof + ) + let targetCandidate = DisplayConnectionCandidate( + displayID: displayID, + stableUUID: targetIsOnline || resolution == .exactUUID ? uuid : nil, + isOnline: targetIsOnline, + isHardwareBackedPhysical: targetIsOnline || resolution == .exactUUID, + recoveryHardwareProof: recoveryCapability?.hardwareProof + ) + let candidates = [otherCandidate, targetCandidate] + let allUUIDs = Set(candidates.compactMap(\.stableUUID)) + let onlineUUIDs = Set(candidates.compactMap { candidate in + candidate.isOnline ? candidate.stableUUID : nil + }) + let sessionCapability = recoveryCapability + return DisplayConnectionObservation( + platformSupported: true, + allUUIDs: allUUIDs, + onlineUUIDs: onlineUUIDs, + intentionalDisconnectedUUIDs: recordExists ? [uuid] : [], + reconnectReservationUUIDs: recordExists && reconnectReservation ? [uuid] : [], + virtualUUIDs: [], + activePhysicalViewableUUIDs: onlineUUIDs, + candidates: candidates, + recoveryCapabilities: recordExists ? [recoveryCapability].compactMap { $0 } : [], + bootSessionID: sessionCapability?.bootSessionID, + loginSessionID: sessionCapability?.loginSessionID, + wakeSessionID: sessionCapability?.wakeSessionID, + topologyFingerprint: sessionCapability?.topologyFingerprint + ) + } + + func retainDisconnectedRecord( + _ target: DisplayConnectionTarget + ) throws -> DisplayConnectionRecoveryCapability? { + throw StateError.unsupportedOperation + } + + func confirmDisconnectedRecord(uuid: String) throws { + throw StateError.unsupportedOperation + } + + func removeDisconnectedRecord(uuid: String) throws { + guard uuid == self.uuid else { throw StateError.invalidState } + recordExists = false + recoveryCapability = nil + reconnectReservation = false + liveReconnectOwner = false + } + + func consumeRecoveryCapability( + _ capability: DisplayConnectionRecoveryCapability + ) throws -> DisplayConnectionRecoveryCapability { + guard reconnectReservation, + capability.uuid == uuid, + capability.state == .available, + recoveryCapability == capability else { + throw StateError.invalidState + } + let consumed = capability.changingState(to: .consumed) + recoveryCapability = consumed + consumeCount += 1 + return consumed + } + + func reserveReconnect(uuid: String) throws { + guard uuid == self.uuid, recordExists, + !reconnectReservation, !liveReconnectOwner else { + throw StateError.invalidState + } + reconnectReservation = true + liveReconnectOwner = true + } + + func releaseReconnectReservation(uuid: String) throws { + guard uuid == self.uuid, reconnectReservation, liveReconnectOwner else { + throw StateError.invalidState + } + if rollbackFailures > 0 { + rollbackFailures -= 1 + throw StateError.persistenceFailed + } + reconnectReservation = false + liveReconnectOwner = false + } + + func rollbackRejectedReconnectBeforeDispatch( + uuid: String, + consumedRecoveryCapability: DisplayConnectionRecoveryCapability? + ) throws { + guard uuid == self.uuid, recordExists, + reconnectReservation, liveReconnectOwner else { + throw StateError.invalidState + } + defer { liveReconnectOwner = false } + if let consumedRecoveryCapability { + guard consumedRecoveryCapability.uuid == uuid, + consumedRecoveryCapability.state == .consumed, + recoveryCapability == consumedRecoveryCapability else { + throw StateError.invalidState + } + } else if let capability = recoveryCapability { + guard ![.consumed, .indeterminate].contains(capability.state) else { + throw StateError.invalidState + } + } + if rollbackFailures > 0 { + rollbackFailures -= 1 + throw StateError.persistenceFailed + } + if let consumedRecoveryCapability { + recoveryCapability = consumedRecoveryCapability.changingState(to: .available) + } + reconnectReservation = false + rollbackPersistenceWriteCount += 1 + } + + func reconcileOrphanedReconnectAttempt( + uuid: String + ) throws -> DisplayReconnectOrphanReconciliation { + guard uuid == self.uuid, reconnectReservation else { return .unavailable } + guard !liveReconnectOwner else { return .liveAttempt } + let observation = try connectionObservation() + let reconnectResolution = DisplayConnectionRecoveryResolver.orphanedReconnectResolution( + uuid: uuid, + observation: observation + ) + switch reconnectResolution { + case .exactUUID: + if let capability = recoveryCapability, + [.consumed, .indeterminate].contains(capability.state) { + recoveryCapability = capability.changingState(to: .available) + } + case let .oneShotRecovery(capability): + guard recoveryCapability == capability else { return .unavailable } + recoveryCapability = capability.changingState(to: .available) + case .alreadyOnline: + return .alreadyOnline + case .unavailable: + return .unavailable + } + reconnectReservation = false + orphanReconciliationCount += 1 + return .reconciled + } + + func reconcileQuarantinedReconnectAttempt( + uuid: String + ) async throws -> DisplayReconnectQuarantineReconciliation { + .unavailable + } + + func finishQuarantinedReconnectAttempt(uuid: String) {} + + func markReconnectAttemptIndeterminate(uuid: String) throws { + guard uuid == self.uuid else { throw StateError.invalidState } + liveReconnectOwner = false + if let capability = recoveryCapability, + capability.state != .indeterminate { + recoveryCapability = capability.changingState(to: .indeterminate) + } + } + + func markRecoveryCapabilityIndeterminate(uuid: String) throws { + try markReconnectAttemptIndeterminate(uuid: uuid) + } + + func dispatchConnectionChange( + _ request: DisplayConnectionDispatchRequest + ) async -> DisplayConnectionDispatchOutcome { + dispatched.append(request) + guard !dispatchOutcomes.isEmpty else { + return .rejectedBeforeDispatch("missing test dispatch outcome") + } + let outcome = dispatchOutcomes.removeFirst() + switch outcome { + case .completed: + osDisplayCallCount += 1 + targetIsOnline = true + case .failedAfterDispatch, .timedOut, .cancelled: + osDisplayCallCount += 1 + case .rejectedBeforeDispatch: + break + } + return outcome + } + + private var otherHardwareProof: DisplayConnectionRecoveryHardwareProof { + DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: 1552, + productID: 41202, + serialNumber: 33624064 + ) + ) + } + + private enum StateError: Error { + case invalidState + case persistenceFailed + case unsupportedOperation + } +} diff --git a/Tests/CrispControlCoreTests/DisplayConnectionDispatcherTests.swift b/Tests/CrispControlCoreTests/DisplayConnectionDispatcherTests.swift new file mode 100644 index 0000000..d7f7fcb --- /dev/null +++ b/Tests/CrispControlCoreTests/DisplayConnectionDispatcherTests.swift @@ -0,0 +1,536 @@ +import XCTest +@testable import CrispControlCore + +final class DisplayConnectionDispatcherTests: XCTestCase { + private let targetUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + private let otherUUID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + func testDisconnectedInventoryIsUUIDSortedAndCarriesConnectionTruth() async throws { + let service = DisconnectedInventoryService(disconnected: [ + disconnectedDisplay(uuid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", name: "B"), + disconnectedDisplay(uuid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", name: "A") + ]) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest(requestID: "inventory", command: "displays.disconnected") + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual( + response.result?["displays"]?[0]?["uuid"], + .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + ) + XCTAssertEqual(response.result?["displays"]?[0]?["name"], .string("A")) + XCTAssertEqual(response.result?["displays"]?[0]?["width"], .number(2560)) + XCTAssertEqual(response.result?["displays"]?[0]?["height"], .number(1440)) + XCTAssertEqual(response.result?["displays"]?[0]?["connection"]?["state"], .string("writable")) + XCTAssertEqual(response.result?["displays"]?[0]?["connection"]?["connected"], .bool(false)) + XCTAssertEqual( + response.result?["displays"]?[0]?["connection"]?["reconnectAllowed"], + .bool(true) + ) + XCTAssertEqual( + response.result?["displays"]?[0]?["connection"]?["platformSupported"], + .bool(true) + ) + } + + func testExistingServiceConformerGetsFailClosedConnectionDefaults() async { + let response = await ControlCommandDispatcher( + service: LegacyControlService(), appVersion: "test" + ).handle(ControlRequest(requestID: "legacy", command: "displays.disconnected")) + + XCTAssertEqual(response.result?["displays"], .array([])) + } + + func testDisplayCapabilitiesIncludesConnectionCapability() async throws { + let response = await ControlCommandDispatcher( + service: DisconnectedInventoryService(disconnected: []), appVersion: "test" + ).handle(ControlRequest( + requestID: "capability", + command: "displays.capabilities", + arguments: ["selector": .string("online-display")] + )) + + XCTAssertEqual(response.result?["connection"]?["state"], .string("writable")) + XCTAssertEqual(response.result?["connection"]?["connected"], .bool(true)) + XCTAssertEqual(response.result?["connection"]?["disconnectAllowed"], .bool(true)) + } + + func testDisconnectReResolvesExactUUIDAndReturnsStructuredVerifiedResult() async { + let target = onlineDisplay(uuid: targetUUID, name: "Duplicate Name") + let service = ConnectionMutationService(displayInventories: [[target], [target]]) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest( + requestID: "disconnect", + command: "displays.disconnect", + arguments: ["uuid": .string(targetUUID)] + ) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["displayUUID"], .string(targetUUID)) + XCTAssertEqual(response.result?["requestedConnectionState"], .string("disconnected")) + XCTAssertEqual(response.result?["observedConnectionState"], .string("disconnected")) + XCTAssertEqual(response.result?["verification"], .string("same_uuid_enumeration")) + let disconnectCalls = await service.disconnectCalls + let displayCallCount = await service.displayCallCount + XCTAssertEqual(disconnectCalls, [targetUUID]) + XCTAssertEqual(displayCallCount, 2) + } + + func testDisconnectNeverSwitchesToAnotherDisplayWhenTargetDisappears() async { + let target = onlineDisplay(uuid: targetUUID, name: "Target") + let other = onlineDisplay(uuid: otherUUID, name: "Target") + let service = ConnectionMutationService(displayInventories: [[target], [other]]) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest( + requestID: "stale", + command: "displays.disconnect", + arguments: ["uuid": .string(targetUUID)] + ) + ) + + XCTAssertEqual(response.error?.code, .selectorNotFound) + XCTAssertEqual(response.error?.details?["displayUUID"], .string(targetUUID)) + let disconnectCalls = await service.disconnectCalls + XCTAssertEqual(disconnectCalls, []) + } + + func testDisconnectRejectsNonUUIDAndLegacySelectorRequestsBeforeInventoryOrMutation() async { + let invalidArguments: [[String: JSONValue]] = [ + ["uuid": .string("main")], + ["uuid": .string("builtin")], + ["uuid": .string("Duplicate")], + ["selector": .string(targetUUID)], + ["selector": .string("Duplicate")] + ] + + for arguments in invalidArguments { + let service = ConnectionMutationService(displayInventories: [[ + onlineDisplay(uuid: targetUUID, name: "Duplicate"), + onlineDisplay(uuid: otherUUID, name: "Duplicate") + ]]) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest( + requestID: "invalid-disconnect", + command: "displays.disconnect", + arguments: arguments + ) + ) + + XCTAssertEqual(response.error?.code, .invalidArguments, "\(arguments)") + let displayCallCount = await service.displayCallCount + let disconnectCalls = await service.disconnectCalls + XCTAssertEqual(displayCallCount, 0, "\(arguments)") + XCTAssertEqual(disconnectCalls, [], "\(arguments)") + } + } + + func testReconnectRequiresExactUUIDInFreshDisconnectedInventory() async { + for requestedUUID in ["main", "Fixture Display", targetUUID.lowercased()] { + let service = ConnectionMutationService( + disconnectedInventories: [[disconnectedDisplay(uuid: targetUUID, name: "Fixture Display")]] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest( + requestID: "invalid-reconnect", + command: "displays.reconnect", + arguments: ["uuid": .string(requestedUUID)] + ) + ) + XCTAssertEqual( + response.error?.code, + requestedUUID == targetUUID.lowercased() ? .selectorNotFound : .invalidArguments + ) + let reconnectCalls = await service.reconnectCalls + XCTAssertEqual(reconnectCalls, []) + } + + let absent = ConnectionMutationService(disconnectedInventories: [[]]) + let response = await ControlCommandDispatcher(service: absent, appVersion: "test").handle( + ControlRequest( + requestID: "absent", + command: "displays.reconnect", + arguments: ["uuid": .string(targetUUID)] + ) + ) + XCTAssertEqual(response.error?.code, .selectorNotFound) + let reconnectCalls = await absent.reconnectCalls + XCTAssertEqual(reconnectCalls, []) + } + + func testReconnectReturnsStructuredVerifiedResultForFreshExactUUID() async { + let service = ConnectionMutationService( + disconnectedInventories: [[disconnectedDisplay(uuid: targetUUID, name: "Target")]] + ) + let response = await ControlCommandDispatcher(service: service, appVersion: "test").handle( + ControlRequest( + requestID: "reconnect", + command: "displays.reconnect", + arguments: ["uuid": .string(targetUUID)] + ) + ) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["displayUUID"], .string(targetUUID)) + XCTAssertEqual(response.result?["requestedConnectionState"], .string("connected")) + XCTAssertEqual(response.result?["observedConnectionState"], .string("connected")) + let reconnectCalls = await service.reconnectCalls + XCTAssertEqual(reconnectCalls, [targetUUID]) + } + + func testExplicitReconnectDelegatesOrphanCapabilityToServiceAndPreservesIndeterminateTruth() async { + let service = OrphanReconnectService( + disconnected: ControlDisconnectedDisplay( + uuid: targetUUID, + name: "Orphaned Target", + width: 2560, + height: 1440, + connection: .unsupported( + connected: false, + platformSupported: true, + reason: "a durable reconnect reservation requires authoritative reconciliation" + ) + ) + ) + let dispatcher = ControlCommandDispatcher(service: service, appVersion: "test") + let request = ControlRequest( + requestID: "orphan-reconnect", + command: "displays.reconnect", + arguments: ["uuid": .string(targetUUID)] + ) + + let first = await dispatcher.handle(request) + + XCTAssertEqual(first.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(first.error?.code.exitCode, 5) + XCTAssertEqual(first.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(first.error?.details?["mutationDispatched"], .bool(false)) + XCTAssertEqual(first.error?.details?["displayUUID"], .string(targetUUID)) + let callsAfterFirstRequest = await service.reconnectCalls + XCTAssertEqual(callsAfterFirstRequest, [targetUUID]) + + let second = await dispatcher.handle(ControlRequest( + requestID: "fresh-reconnect", + command: "displays.reconnect", + arguments: ["uuid": .string(targetUUID)] + )) + + XCTAssertTrue(second.ok) + XCTAssertEqual(second.result?["displayUUID"], .string(targetUUID)) + let callsAfterSecondRequest = await service.reconnectCalls + XCTAssertEqual(callsAfterSecondRequest, [targetUUID, targetUUID]) + } + + func testReconnectMalformedAbsentAndDuplicateRecordsNeverReachService() async { + let cases: [(arguments: [String: JSONValue], records: [ControlDisconnectedDisplay])] = [ + (["uuid": .string("main")], [disconnectedDisplay(uuid: targetUUID, name: "Target")]), + (["uuid": .string(targetUUID)], []), + (["uuid": .string(targetUUID)], [ + disconnectedDisplay(uuid: targetUUID, name: "First"), + disconnectedDisplay(uuid: targetUUID, name: "Duplicate") + ]) + ] + + for testCase in cases { + let service = ConnectionMutationService( + disconnectedInventories: [testCase.records] + ) + let response = await ControlCommandDispatcher( + service: service, + appVersion: "test" + ).handle(ControlRequest( + requestID: "reconnect-pre-service", + command: "displays.reconnect", + arguments: testCase.arguments + )) + + XCTAssertFalse(response.ok, "\(testCase.arguments)") + let reconnectCalls = await service.reconnectCalls + XCTAssertEqual(reconnectCalls, [], "\(testCase.arguments)") + } + } + + func testConnectionPreflightAndPostDispatchErrorsRemainDistinct() async { + let target = onlineDisplay(uuid: targetUUID, name: "Target") + let preflight = DisplayConnectionMutationError( + classification: .preflightRejected, + displayUUID: targetUUID, + requestedConnectionState: .disconnected, + message: "last viewable display" + ) + let preflightService = ConnectionMutationService( + displayInventories: [[target], [target]], mutationError: preflight + ) + let preflightResponse = await ControlCommandDispatcher( + service: preflightService, appVersion: "test" + ).handle(disconnectRequest()) + XCTAssertEqual(preflightResponse.error?.code, .unsupportedCapability) + XCTAssertEqual(preflightResponse.error?.details?["phase"], .string("preflight")) + XCTAssertEqual(preflightResponse.error?.details?["retrySafe"], .bool(true)) + XCTAssertEqual(preflightResponse.error?.details?["mutationDispatched"], .bool(false)) + + let definite = DisplayConnectionMutationError( + classification: .definiteFailure, + displayUUID: targetUUID, + requestedConnectionState: .disconnected, + message: "configuration was rejected before hardware dispatch" + ) + let definiteService = ConnectionMutationService( + displayInventories: [[target], [target]], mutationError: definite + ) + let definiteResponse = await ControlCommandDispatcher( + service: definiteService, appVersion: "test" + ).handle(disconnectRequest()) + XCTAssertEqual(definiteResponse.error?.code, .writeVerificationFailed) + XCTAssertEqual(definiteResponse.error?.details?["phase"], .string("preflight")) + XCTAssertEqual(definiteResponse.error?.details?["retrySafe"], .bool(true)) + XCTAssertEqual(definiteResponse.error?.details?["mutationDispatched"], .bool(false)) + + let unknown = DisplayConnectionMutationError( + classification: .indeterminate, + displayUUID: targetUUID, + requestedConnectionState: .disconnected, + message: "configuration timed out after dispatch" + ) + let unknownService = ConnectionMutationService( + displayInventories: [[target], [target]], mutationError: unknown + ) + let unknownResponse = await ControlCommandDispatcher( + service: unknownService, appVersion: "test" + ).handle(disconnectRequest()) + XCTAssertEqual(unknownResponse.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(unknownResponse.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(unknownResponse.error?.details?["command"], .string("displays.disconnect")) + XCTAssertEqual(unknownResponse.error?.details?["displayUUID"], .string(targetUUID)) + XCTAssertEqual( + unknownResponse.error?.details?["requestedConnectionState"], + .string("disconnected") + ) + XCTAssertEqual(unknownResponse.error?.code.exitCode, 5) + } + + func testCancellationAfterDispatchIsIndeterminateForDisconnectAndReconnect() async { + for command in ["displays.disconnect", "displays.reconnect"] { + let started = MutationStartedSignal() + let service = ConnectionMutationService( + displayInventories: [[onlineDisplay(uuid: targetUUID)], [onlineDisplay(uuid: targetUUID)]], + disconnectedInventories: [[disconnectedDisplay(uuid: targetUUID, name: "Target")]], + started: started + ) + let request = command == "displays.disconnect" + ? disconnectRequest() + : ControlRequest( + requestID: "cancel-reconnect", + command: command, + arguments: ["uuid": .string(targetUUID)] + ) + let task = Task { + await ControlCommandDispatcher(service: service, appVersion: "test").handle(request) + } + await started.waitUntilStarted() + task.cancel() + let response = await task.value + + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(response.error?.details?["command"], .string(command)) + XCTAssertEqual(response.error?.details?["displayUUID"], .string(targetUUID)) + } + } + + private func disconnectRequest() -> ControlRequest { + ControlRequest( + requestID: "disconnect-error", + command: "displays.disconnect", + arguments: ["uuid": .string(targetUUID)] + ) + } +} + +private func disconnectedDisplay(uuid: String, name: String) -> ControlDisconnectedDisplay { + ControlDisconnectedDisplay( + uuid: uuid, + name: name, + width: 2560, + height: 1440, + connection: DisplayConnectionCapability( + state: .writable, + connected: false, + disconnectAllowed: false, + reconnectAllowed: true, + platformSupported: true + ) + ) +} + +private actor DisconnectedInventoryService: ControlCommandService { + let disconnected: [ControlDisconnectedDisplay] + + init(disconnected: [ControlDisconnectedDisplay]) { self.disconnected = disconnected } + + func displays() async throws -> [ControlDisplay] { + [ControlDisplay( + uuid: "online-display", + name: "Online Display", + isMain: true, + isBuiltin: true, + brightness: .unsupported(reason: "fixture"), + connection: DisplayConnectionCapability( + state: .writable, + connected: true, + disconnectAllowed: true, + reconnectAllowed: false, + platformSupported: true + ) + )] + } + + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { disconnected } + func readBrightness(displayUUID: String) async throws -> Double? { nil } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } +} + +private actor LegacyControlService: ControlCommandService { + func displays() async throws -> [ControlDisplay] { [] } + func readBrightness(displayUUID: String) async throws -> Double? { nil } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } +} + +private func onlineDisplay( + uuid: String, + name: String = "Target", + connection: DisplayConnectionCapability? = nil +) -> ControlDisplay { + ControlDisplay( + uuid: uuid, + name: name, + isMain: false, + isBuiltin: false, + brightness: .unsupported(reason: "fixture"), + connection: connection ?? DisplayConnectionCapability( + state: .writable, + connected: true, + disconnectAllowed: true, + reconnectAllowed: false, + platformSupported: true + ) + ) +} + +private actor ConnectionMutationService: ControlCommandService { + private var displayInventories: [[ControlDisplay]] + private var disconnectedInventories: [[ControlDisconnectedDisplay]] + private let mutationError: DisplayConnectionMutationError? + private let started: MutationStartedSignal? + private(set) var displayCallCount = 0 + private(set) var disconnectCalls: [String] = [] + private(set) var reconnectCalls: [String] = [] + + init( + displayInventories: [[ControlDisplay]] = [], + disconnectedInventories: [[ControlDisconnectedDisplay]] = [], + mutationError: DisplayConnectionMutationError? = nil, + started: MutationStartedSignal? = nil + ) { + self.displayInventories = displayInventories + self.disconnectedInventories = disconnectedInventories + self.mutationError = mutationError + self.started = started + } + + func displays() async throws -> [ControlDisplay] { + displayCallCount += 1 + guard !displayInventories.isEmpty else { return [] } + if displayInventories.count == 1 { return displayInventories[0] } + return displayInventories.removeFirst() + } + + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { + guard !disconnectedInventories.isEmpty else { return [] } + if disconnectedInventories.count == 1 { return disconnectedInventories[0] } + return disconnectedInventories.removeFirst() + } + + func disconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + disconnectCalls.append(displayUUID) + try await waitIfRequested() + if let mutationError { throw mutationError } + return result(uuid: displayUUID, state: .disconnected) + } + + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + reconnectCalls.append(displayUUID) + try await waitIfRequested() + if let mutationError { throw mutationError } + return result(uuid: displayUUID, state: .connected) + } + + func readBrightness(displayUUID: String) async throws -> Double? { nil } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } + + private func waitIfRequested() async throws { + guard let started else { return } + await started.markStarted() + try await Task.sleep(for: .seconds(10)) + } + + private func result( + uuid: String, + state: DisplayConnectionState + ) -> DisplayConnectionSetResult { + DisplayConnectionSetResult( + displayUUID: uuid, + requestedConnectionState: state, + observedConnectionState: state, + verification: .sameUUIDEnumeration + ) + } +} + +private actor OrphanReconnectService: ControlCommandService { + private let disconnected: ControlDisconnectedDisplay + private(set) var reconnectCalls: [String] = [] + + init(disconnected: ControlDisconnectedDisplay) { + self.disconnected = disconnected + } + + func displays() async throws -> [ControlDisplay] { [] } + + func disconnectedDisplays() async throws -> [ControlDisconnectedDisplay] { + [disconnected] + } + + func reconnectDisplay(displayUUID: String) async throws -> DisplayConnectionSetResult { + reconnectCalls.append(displayUUID) + if reconnectCalls.count == 1 { + throw DisplayConnectionMutationError( + classification: .indeterminate, + displayUUID: displayUUID, + requestedConnectionState: .connected, + mutationDispatched: false, + message: "prior reconnect was reconciled without a display write" + ) + } + return DisplayConnectionSetResult( + displayUUID: displayUUID, + requestedConnectionState: .connected, + observedConnectionState: .connected, + verification: .sameUUIDEnumeration + ) + } + + func readBrightness(displayUUID: String) async throws -> Double? { nil } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } +} + +private actor MutationStartedSignal { + private var started = false + + func markStarted() { started = true } + + func waitUntilStarted() async { + while !started { await Task.yield() } + } +} diff --git a/Tests/CrispControlCoreTests/DisplayConnectionPersistenceBoundaryTests.swift b/Tests/CrispControlCoreTests/DisplayConnectionPersistenceBoundaryTests.swift new file mode 100644 index 0000000..24cd4ec --- /dev/null +++ b/Tests/CrispControlCoreTests/DisplayConnectionPersistenceBoundaryTests.swift @@ -0,0 +1,1035 @@ +// The persistence and quarantine safety matrix intentionally shares one stateful store fixture. +import XCTest +@testable import CrispControlCore + +// swiftlint:disable:next type_body_length +final class ConnectionPersistenceBoundaryTests: XCTestCase { + private let targetUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + private let otherUUID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + func testDestructiveWriteWithOldReadBackPreservesConsumedReservationEnvelope() throws { + let old = envelope(capabilityState: .consumed, reservation: true) + let proposed = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore( + initial: old, + reads: [.envelope(old), .envelope(old)] + ) + let boundary = store.makeBoundary() + + let result = try boundary.replace( + oldState: old, + proposedState: proposed, + quarantiningUUIDs: [targetUUID] + ) + + XCTAssertEqual(result.disposition, .preservedOld) + XCTAssertEqual(result.snapshot.envelope, old) + XCTAssertEqual(result.snapshot.envelope.reconnectReservationSet, [targetUUID]) + XCTAssertEqual( + result.snapshot.envelope.records.first?.recoveryCapability?.state, + .consumed + ) + XCTAssertEqual(try store.storedEnvelope(), old) + XCTAssertEqual(store.writeCount, 1) + try assertNextObservation( + from: boundary, + store: store, + equals: old, + expectedResolution: .unavailable + ) + } + + func testDestructiveWriteWithProposedReadBackAdoptsProposedEnvelope() throws { + let old = envelope(capabilityState: .consumed, reservation: true) + let proposed = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore( + initial: old, + reads: [.stored, .stored] + ) + let boundary = store.makeBoundary() + + let result = try boundary.replace( + oldState: old, + proposedState: proposed, + quarantiningUUIDs: [targetUUID] + ) + + XCTAssertEqual(result.disposition, .committedProposed) + XCTAssertEqual(result.snapshot.envelope, proposed) + XCTAssertEqual(try store.storedEnvelope(), proposed) + XCTAssertEqual(store.writeCount, 1) + try assertNextObservation( + from: boundary, + store: store, + equals: proposed, + expectedResolution: .oneShotRecovery(capability(state: .available)) + ) + } + + func testDestructiveWriteWithMissingReadBackPersistsFailClosedQuarantine() throws { + _ = try assertAnomalousReadBackIsQuarantined(.missing) + } + + func testDestructiveWriteWithCorruptReadBackPersistsFailClosedQuarantine() throws { + _ = try assertAnomalousReadBackIsQuarantined(.corrupt) + } + + func testDestructiveWriteWithUnexpectedValidReadBackPersistsFailClosedQuarantine() throws { + let unexpected = DisplayConnectionPersistenceEnvelope( + records: [ + record(capabilityState: .available), + DisplayConnectionPersistedRecord( + uuid: otherUUID, + displayID: 3, + name: "Other", + width: 1920, + height: 1080 + ) + ], + pendingUUIDs: [], + reconnectReservationUUIDs: [] + ) + let result = try assertAnomalousReadBackIsQuarantined(.envelope(unexpected)) + + XCTAssertEqual( + Set(result.snapshot.envelope.records.map(\.uuid)), + [targetUUID, otherUUID], + "compensation must not discard another valid orphan from unexpected read-back" + ) + } + + func testCorruptEnvelopeOnRelaunchFailsClosedWithoutInventingReconnect() throws { + let store = ScriptedConnectionStore( + initialData: Data("not-json".utf8), + reads: [.stored] + ) + let boundary = store.makeBoundary() + + XCTAssertThrowsError(try boundary.snapshot()) { error in + XCTAssertEqual(error as? DisplayConnectionPersistenceError, .corrupt) + } + XCTAssertEqual(store.readCount, 1) + XCTAssertEqual(store.writeCount, 0) + } + + func testUnverifiedCompensationStaysSyntheticUntilKnownDurableReadBack() throws { + let old = envelope(capabilityState: .consumed, reservation: true) + let proposed = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore( + initial: old, + reads: [.missing, .corrupt, .missing, .stored] + ) + let boundary = store.makeBoundary() + + let result = try boundary.replace( + oldState: old, + proposedState: proposed, + quarantiningUUIDs: [targetUUID] + ) + + XCTAssertEqual(result.disposition, .quarantined) + XCTAssertEqual(result.snapshot.authority, .syntheticQuarantine) + XCTAssertFalse(result.snapshot.authorizesConnectionMutation) + XCTAssertEqual(store.writeCount, 2) + XCTAssertEqual(boundary.publishedRecords, result.snapshot.envelope.records) + + let stillUnknown = try boundary.snapshot() + XCTAssertEqual(stillUnknown, result.snapshot) + let observation = connectionObservation(snapshot: stillUnknown) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: observation + ), + .unavailable + ) + XCTAssertFalse(DisplayConnectionRecoveryResolver.authorizesConsumedRecoveryDispatch( + uuid: targetUUID, + displayID: 2, + observation: observation + )) + + try store.simulateExternalDurableState(result.snapshot.envelope) + let durable = try boundary.snapshot() + XCTAssertEqual(durable.authority, .durable) + XCTAssertEqual(durable.envelope, result.snapshot.envelope) + XCTAssertFalse(durable.authorizesConnectionMutation) + } + + func testConnectedCapabilityQueryDoesNotPersistOrChangePublishedRecords() throws { + let stale = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore(initial: stale, reads: [.stored]) + let boundary = store.makeBoundary(initialPublishedRecords: stale.records) + let publishedBefore = boundary.publishedRecords + let snapshot = try boundary.snapshot() + + let capability = DisplayConnectionReadOnlyQueries.connectedCapability( + uuid: targetUUID, + observation: connectedObservation(snapshot: snapshot) + ) + + XCTAssertEqual(capability.state, .unsupported) + XCTAssertEqual(capability.connected, true) + XCTAssertEqual(capability.disconnectAllowed, false) + XCTAssertEqual(store.readCount, 1) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, publishedBefore) + } + + func testConnectedCapabilitiesBatchReadsOneSnapshotForEveryDisplay() throws { + let empty = DisplayConnectionPersistenceEnvelope( + records: [], + pendingUUIDs: [], + reconnectReservationUUIDs: [] + ) + let store = try ScriptedConnectionStore( + initial: empty, + reads: [.stored, .missing] + ) + let boundary = store.makeBoundary(initialPublishedRecords: [record(capabilityState: .available)]) + let publishedBefore = boundary.publishedRecords + var observationCount = 0 + + let capabilities = DisplayConnectionReadOnlyQueries.connectedCapabilities( + subjects: [ + DisplayConnectionCapabilitySubject(uuid: targetUUID), + DisplayConnectionCapabilitySubject(uuid: otherUUID) + ], + loadSnapshot: { try boundary.snapshot() }, + buildObservation: { snapshot in + observationCount += 1 + return connectedObservation(snapshot: snapshot) + } + ) + + XCTAssertEqual(capabilities.count, 2) + XCTAssertEqual(capabilities[targetUUID]?.state, .writable) + XCTAssertEqual(capabilities[otherUUID]?.state, .writable) + XCTAssertEqual(store.readCount, 1) + XCTAssertEqual(observationCount, 1) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, publishedBefore) + } + + func testConnectedCapabilitiesBatchFailsClosedFromOneUnavailableBoundary() throws { + let empty = DisplayConnectionPersistenceEnvelope( + records: [], + pendingUUIDs: [], + reconnectReservationUUIDs: [] + ) + let staticUnsupported = DisplayConnectionCapability.unsupported( + connected: true, + reason: "static hardware proof is unavailable" + ) + let subjects = [ + DisplayConnectionCapabilitySubject(uuid: targetUUID), + DisplayConnectionCapabilitySubject( + uuid: otherUUID, + staticUnsupportedCapability: staticUnsupported + ) + ] + let store = try ScriptedConnectionStore(initial: empty, reads: [.stored]) + let boundary = store.makeBoundary() + var observationCount = 0 + + let enumerationFailure = DisplayConnectionReadOnlyQueries.connectedCapabilities( + subjects: subjects, + loadSnapshot: { try boundary.snapshot() }, + buildObservation: { _ in + observationCount += 1 + throw DisplayConnectionPersistenceError.corrupt + } + ) + + XCTAssertEqual(enumerationFailure[targetUUID]?.state, .unsupported) + XCTAssertEqual( + enumerationFailure[targetUUID]?.reason, + "fresh WindowServer enumeration is unavailable" + ) + XCTAssertEqual(enumerationFailure[otherUUID], staticUnsupported) + XCTAssertEqual(store.readCount, 1) + XCTAssertEqual(observationCount, 1) + XCTAssertEqual(store.writeCount, 0) + + var unavailableObservationCount = 0 + let missingStore = ScriptedConnectionStore(initialData: nil, reads: [.stored]) + let missingBoundary = missingStore.makeBoundary() + let persistenceFailure = DisplayConnectionReadOnlyQueries.connectedCapabilities( + subjects: subjects, + loadSnapshot: { try missingBoundary.snapshot() }, + buildObservation: { snapshot in + unavailableObservationCount += 1 + return connectedObservation(snapshot: snapshot) + } + ) + + XCTAssertEqual(persistenceFailure[targetUUID]?.state, .unsupported) + XCTAssertEqual( + persistenceFailure[targetUUID]?.reason, + "persisted connection recovery state is unavailable or corrupt" + ) + XCTAssertEqual(persistenceFailure[otherUUID], staticUnsupported) + XCTAssertEqual(missingStore.readCount, 1) + XCTAssertEqual(unavailableObservationCount, 0) + XCTAssertEqual(missingStore.writeCount, 0) + } + + func testDisconnectedInventoryQueryDoesNotConfirmPendingOrChangePublishedRecords() throws { + let pending = DisplayConnectionPersistenceEnvelope( + records: [record(capabilityState: .prepared)], + pendingUUIDs: [targetUUID], + reconnectReservationUUIDs: [] + ) + let store = try ScriptedConnectionStore(initial: pending, reads: [.stored]) + let boundary = store.makeBoundary(initialPublishedRecords: pending.records) + let publishedBefore = boundary.publishedRecords + let snapshot = try boundary.snapshot() + + let inventory = DisplayConnectionReadOnlyQueries.disconnectedDisplays( + persistenceSnapshot: snapshot, + observation: connectionObservation(snapshot: snapshot) + ) + + XCTAssertEqual(inventory.count, 1) + XCTAssertEqual(inventory.first?.uuid, targetUUID) + XCTAssertEqual(inventory.first?.connection.state, .unsupported) + XCTAssertEqual(inventory.first?.connection.reconnectAllowed, false) + XCTAssertEqual(store.readCount, 1) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, publishedBefore) + } + + func testRelaunchedDurableQuarantineReconcilesDirectOfflineProofAtomically() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let store = try ScriptedConnectionStore( + initial: quarantined, + reads: [.stored, .stored, .stored] + ) + let relaunchedBoundary = store.makeBoundary( + initialPublishedRecords: quarantined.records + ) + let snapshot = try relaunchedBoundary.snapshot() + + let result = try XCTUnwrap(relaunchedBoundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: directOfflineObservation(snapshot: snapshot) + )) + + XCTAssertEqual(result.kind, .reconciledOffline) + XCTAssertEqual(result.writeResult.disposition, .committedProposed) + XCTAssertEqual(result.writeResult.snapshot.envelope.pendingSet, []) + XCTAssertEqual(result.writeResult.snapshot.envelope.reconnectReservationSet, []) + XCTAssertEqual( + result.writeResult.snapshot.envelope.reconnectPersistenceUncertainSet, + [] + ) + XCTAssertEqual( + result.writeResult.snapshot.envelope.records.first?.recoveryCapability?.state, + .available + ) + XCTAssertEqual(store.readCount, 2) + XCTAssertEqual(store.writeCount, 1) + XCTAssertEqual( + relaunchedBoundary.publishedRecords, + result.writeResult.snapshot.envelope.records + ) + + let nextLaunch = store.makeBoundary() + let recovered = try nextLaunch.snapshot() + XCTAssertEqual(recovered.authority, .durable) + XCTAssertTrue(recovered.authorizesConnectionMutation) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: directOfflineObservation(snapshot: recovered) + ), + .oneShotRecovery(capability(state: .available)) + ) + } + + func testQuarantineReconciliationAcceptsFreshExactOfflineProof() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared, reservation: true) + let store = try ScriptedConnectionStore(initial: quarantined, reads: [.stored, .stored]) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + let snapshot = try boundary.snapshot() + + let result = try XCTUnwrap(boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: exactOfflineObservation(snapshot: snapshot) + )) + + XCTAssertEqual(result.kind, .reconciledOffline) + XCTAssertEqual(result.writeResult.disposition, .committedProposed) + XCTAssertEqual(result.writeResult.snapshot.envelope.pendingSet, []) + XCTAssertEqual(result.writeResult.snapshot.envelope.reconnectReservationSet, []) + XCTAssertEqual( + result.writeResult.snapshot.envelope.reconnectPersistenceUncertainSet, + [] + ) + XCTAssertEqual(store.writeCount, 1) + } + + func testQuarantineReconciliationCleansFreshExactOnlineProofWithoutDisplayWrite() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared, reservation: true) + let store = try ScriptedConnectionStore(initial: quarantined, reads: [.stored, .stored]) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + let snapshot = try boundary.snapshot() + + let result = try XCTUnwrap(boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: connectedObservation(snapshot: snapshot) + )) + + XCTAssertEqual(result.kind, .alreadyOnline) + XCTAssertEqual(result.writeResult.disposition, .committedProposed) + XCTAssertEqual(result.writeResult.snapshot.envelope.records, []) + XCTAssertEqual(result.writeResult.snapshot.envelope.pendingSet, []) + XCTAssertEqual(result.writeResult.snapshot.envelope.reconnectReservationSet, []) + XCTAssertEqual( + result.writeResult.snapshot.envelope.reconnectPersistenceUncertainSet, + [] + ) + XCTAssertEqual(boundary.publishedRecords, []) + XCTAssertEqual(store.writeCount, 1) + } + + func testQuarantineReconciliationReadBackFailureRetainsDurableQuarantine() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let store = try ScriptedConnectionStore( + initial: quarantined, + reads: [.stored, .envelope(quarantined)] + ) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + let snapshot = try boundary.snapshot() + + let result = try XCTUnwrap(boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: directOfflineObservation(snapshot: snapshot) + )) + + XCTAssertEqual(result.writeResult.disposition, .preservedOld) + XCTAssertEqual(result.writeResult.snapshot.envelope, quarantined) + XCTAssertEqual( + result.writeResult.snapshot.envelope.reconnectPersistenceUncertainSet, + [targetUUID] + ) + XCTAssertEqual(result.writeResult.snapshot.envelope.pendingSet, [targetUUID]) + XCTAssertEqual(store.writeCount, 1) + } + + func testQuarantineReconciliationCompensatesMissingReadBackToDurableQuarantine() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let store = try ScriptedConnectionStore( + initial: quarantined, + reads: [.stored, .missing, .stored] + ) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + let snapshot = try boundary.snapshot() + + let result = try XCTUnwrap(boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: directOfflineObservation(snapshot: snapshot) + )) + + XCTAssertEqual(result.writeResult.disposition, .preservedOld) + XCTAssertEqual(result.writeResult.snapshot.authority, .durable) + XCTAssertEqual( + result.writeResult.snapshot.envelope.reconnectPersistenceUncertainSet, + [targetUUID] + ) + XCTAssertFalse(result.writeResult.snapshot.authorizesConnectionMutation) + XCTAssertEqual(store.writeCount, 2) + } + + func testOrdinaryTopologyReconcileDoesNotClearOfflineQuarantine() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let store = try ScriptedConnectionStore(initial: quarantined, reads: [.stored]) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + let snapshot = try boundary.snapshot() + + let result = try boundary.reconcileTopologyMetadata( + snapshot: snapshot, + observation: exactOfflineObservation(snapshot: snapshot) + ) + + XCTAssertNil(result) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, quarantined.records) + } + + func testSyntheticQuarantineCannotReconcileOrAuthorizeMutation() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let synthetic = DisplayConnectionPersistenceSnapshot( + envelope: quarantined, + authority: .syntheticQuarantine + ) + let store = try ScriptedConnectionStore(initial: quarantined, reads: []) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + + let result = try boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: synthetic, + observation: directOfflineObservation(snapshot: synthetic) + ) + + XCTAssertNil(result) + XCTAssertFalse(synthetic.authorizesConnectionMutation) + XCTAssertEqual(store.readCount, 0) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, quarantined.records) + } + + func testQuarantineReconciliationRejectsInvalidDirectAndContinuityProofWithoutCleanup() throws { + let quarantined = quarantineEnvelope(capabilityState: .prepared) + let snapshot = DisplayConnectionPersistenceSnapshot( + envelope: quarantined, + authority: .durable + ) + let duplicateProofCandidate = DisplayConnectionCandidate( + displayID: 3, + stableUUID: nil, + isOnline: false, + isHardwareBackedPhysical: false, + recoveryHardwareProof: capability(state: .prepared).hardwareProof + ) + let invalidObservations = [ + directOfflineObservation(snapshot: snapshot, includeTargetCandidate: false), + directOfflineObservation( + snapshot: snapshot, + candidateHasProof: false + ), + directOfflineObservation( + snapshot: snapshot, + extraCandidates: [duplicateProofCandidate] + ), + directOfflineObservation(snapshot: snapshot, candidateDisplayID: 0), + directOfflineObservation( + snapshot: snapshot, + candidateProof: otherHardwareProof + ), + directOfflineObservation(snapshot: snapshot, bootSessionID: "boot-B"), + directOfflineObservation(snapshot: snapshot, loginSessionID: "login-B"), + directOfflineObservation( + snapshot: snapshot, + wakeSessionID: "mach-sleep-offset-v1:10002000001" + ), + directOfflineObservation( + snapshot: snapshot, + topologyFingerprint: "topology-B" + ), + ambiguousObservation(snapshot: snapshot) + ] + + for observation in invalidObservations { + let store = try ScriptedConnectionStore(initial: quarantined, reads: []) + let boundary = store.makeBoundary(initialPublishedRecords: quarantined.records) + + let result = try boundary.reconcileQuarantinedReconnect( + uuid: targetUUID, + snapshot: snapshot, + observation: observation + ) + + XCTAssertNil(result) + XCTAssertEqual(store.readCount, 0) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, quarantined.records) + } + } + + func testTopologyReconcileRemovesFreshOnlineStaleRecordWithMetadataWriteOnly() throws { + let stale = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore( + initial: stale, + reads: [.stored, .stored] + ) + let boundary = store.makeBoundary(initialPublishedRecords: stale.records) + let snapshot = try boundary.snapshot() + + let result = try boundary.reconcileTopologyMetadata( + snapshot: snapshot, + observation: connectedObservation(snapshot: snapshot) + ) + + XCTAssertEqual(result?.disposition, .committedProposed) + XCTAssertEqual(result?.snapshot.envelope.records, []) + XCTAssertEqual(boundary.publishedRecords, []) + XCTAssertEqual(store.readCount, 2) + XCTAssertEqual(store.writeCount, 1) + } + + func testTopologyReconcileConfirmsFreshExactOfflinePendingRecord() throws { + let pending = DisplayConnectionPersistenceEnvelope( + records: [record(capabilityState: .prepared)], + pendingUUIDs: [targetUUID], + reconnectReservationUUIDs: [] + ) + let store = try ScriptedConnectionStore( + initial: pending, + reads: [.stored, .stored] + ) + let boundary = store.makeBoundary(initialPublishedRecords: pending.records) + let snapshot = try boundary.snapshot() + + let result = try boundary.reconcileTopologyMetadata( + snapshot: snapshot, + observation: exactOfflineObservation(snapshot: snapshot) + ) + + XCTAssertEqual(result?.disposition, .committedProposed) + XCTAssertEqual(result?.snapshot.envelope.pendingSet, []) + XCTAssertEqual( + result?.snapshot.envelope.records.first?.recoveryCapability?.state, + .available + ) + XCTAssertEqual(store.readCount, 2) + XCTAssertEqual(store.writeCount, 1) + } + + func testTopologyReconcileAmbiguousEnumerationDoesNotWrite() throws { + let pending = DisplayConnectionPersistenceEnvelope( + records: [record(capabilityState: .prepared)], + pendingUUIDs: [targetUUID], + reconnectReservationUUIDs: [] + ) + let store = try ScriptedConnectionStore(initial: pending, reads: [.stored]) + let boundary = store.makeBoundary(initialPublishedRecords: pending.records) + let snapshot = try boundary.snapshot() + let publishedBefore = boundary.publishedRecords + + let result = try boundary.reconcileTopologyMetadata( + snapshot: snapshot, + observation: ambiguousObservation(snapshot: snapshot) + ) + + XCTAssertNil(result) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, publishedBefore) + } + + func testTopologyReconcileFailedEnumerationDoesNotWrite() throws { + let stale = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore(initial: stale, reads: [.stored]) + let boundary = store.makeBoundary(initialPublishedRecords: stale.records) + let snapshot = try boundary.snapshot() + let publishedBefore = boundary.publishedRecords + + let result = try boundary.reconcileTopologyMetadata( + snapshot: snapshot, + observation: nil + ) + + XCTAssertNil(result) + XCTAssertEqual(store.writeCount, 0) + XCTAssertEqual(boundary.publishedRecords, publishedBefore) + } + + private func assertAnomalousReadBackIsQuarantined( + _ firstRead: ScriptedConnectionStore.Read + ) throws -> DisplayConnectionPersistenceWriteResult { + let old = envelope(capabilityState: .consumed, reservation: true) + let proposed = envelope(capabilityState: .available, reservation: false) + let store = try ScriptedConnectionStore( + initial: old, + reads: [firstRead, .stored, .stored, .stored] + ) + let boundary = store.makeBoundary() + + let result = try boundary.replace( + oldState: old, + proposedState: proposed, + quarantiningUUIDs: [targetUUID] + ) + + XCTAssertEqual(result.disposition, .quarantined) + XCTAssertEqual(result.snapshot.authority, .durable) + XCTAssertEqual(result.snapshot.envelope.reconnectPersistenceUncertainSet, [targetUUID]) + XCTAssertEqual(result.snapshot.envelope.reconnectReservationSet, [targetUUID]) + XCTAssertEqual( + result.snapshot.envelope.records.first?.recoveryCapability?.state, + .consumed + ) + XCTAssertFalse(result.snapshot.authorizesConnectionMutation) + XCTAssertEqual(try store.storedEnvelope(), result.snapshot.envelope) + XCTAssertEqual(store.writeCount, 2, "the second write is explicit quarantine compensation") + try assertNextObservation( + from: boundary, + store: store, + equals: result.snapshot.envelope, + expectedResolution: .unavailable + ) + + let relaunched = store.makeBoundary() + let relaunchedSnapshot = try relaunched.snapshot() + XCTAssertEqual(relaunchedSnapshot, result.snapshot) + XCTAssertFalse(relaunchedSnapshot.authorizesConnectionMutation) + return result + } + + private func assertNextObservation( + from boundary: DisplayConnectionPersistenceBoundary, + store: ScriptedConnectionStore, + equals expected: DisplayConnectionPersistenceEnvelope, + expectedResolution: DisplayConnectionReconnectResolution + ) throws { + let readsBefore = store.readCount + let snapshot = try boundary.snapshot() + let observation = connectionObservation(snapshot: snapshot) + + XCTAssertEqual(store.readCount, readsBefore + 1) + XCTAssertEqual(Set(observation.intentionalDisconnectedUUIDs), Set(expected.records.map(\.uuid))) + XCTAssertEqual(observation.pendingDisconnectUUIDs, expected.pendingSet) + XCTAssertEqual(observation.reconnectReservationUUIDs, expected.reconnectReservationSet) + XCTAssertEqual( + observation.reconnectPersistenceUncertainUUIDs, + expected.reconnectPersistenceUncertainSet + ) + XCTAssertEqual( + observation.recoveryCapabilities, + expected.records.compactMap(\.recoveryCapability) + ) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: observation + ), + expectedResolution + ) + if expected.reconnectPersistenceUncertainSet.contains(targetUUID) { + XCTAssertFalse(DisplayConnectionRecoveryResolver.authorizesConsumedRecoveryDispatch( + uuid: targetUUID, + displayID: 2, + observation: observation + )) + } + } + + private func connectionObservation( + snapshot: DisplayConnectionPersistenceSnapshot + ) -> DisplayConnectionObservation { + DisplayConnectionObservation( + persistenceSnapshot: snapshot, + platformSupported: true, + allUUIDs: [otherUUID], + onlineUUIDs: [otherUUID], + virtualUUIDs: [], + activePhysicalViewableUUIDs: [otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: nil + ), + DisplayConnectionCandidate( + displayID: 2, + stableUUID: nil, + isOnline: false, + isHardwareBackedPhysical: false, + recoveryHardwareProof: capability(state: .consumed).hardwareProof + ) + ], + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A" + ) + } + + private func directOfflineObservation( + snapshot: DisplayConnectionPersistenceSnapshot, + candidateDisplayID: UInt32 = 2, + candidateProof: DisplayConnectionRecoveryHardwareProof? = nil, + candidateHasProof: Bool = true, + includeTargetCandidate: Bool = true, + extraCandidates: [DisplayConnectionCandidate] = [], + bootSessionID: String? = "boot-A", + loginSessionID: String? = "login-A", + wakeSessionID: String? = "mach-sleep-offset-v1:10000000000", + topologyFingerprint: String? = "topology-A" + ) -> DisplayConnectionObservation { + DisplayConnectionObservation( + persistenceSnapshot: snapshot, + platformSupported: true, + allUUIDs: [otherUUID], + onlineUUIDs: [otherUUID], + virtualUUIDs: [], + activePhysicalViewableUUIDs: [otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: nil + ) + ] + (includeTargetCandidate ? [DisplayConnectionCandidate( + displayID: candidateDisplayID, + stableUUID: nil, + isOnline: false, + isHardwareBackedPhysical: false, + recoveryHardwareProof: candidateHasProof + ? (candidateProof ?? capability(state: .prepared).hardwareProof) + : nil + )] : []) + extraCandidates, + bootSessionID: bootSessionID, + loginSessionID: loginSessionID, + wakeSessionID: wakeSessionID, + topologyFingerprint: topologyFingerprint + ) + } + + private func connectedObservation( + snapshot: DisplayConnectionPersistenceSnapshot + ) -> DisplayConnectionObservation { + DisplayConnectionObservation( + persistenceSnapshot: snapshot, + platformSupported: true, + allUUIDs: [targetUUID, otherUUID], + onlineUUIDs: [targetUUID, otherUUID], + virtualUUIDs: [], + activePhysicalViewableUUIDs: [targetUUID, otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: nil + ), + DisplayConnectionCandidate( + displayID: 2, + stableUUID: targetUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: capability(state: .available).hardwareProof + ) + ], + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A" + ) + } + + private func exactOfflineObservation( + snapshot: DisplayConnectionPersistenceSnapshot + ) -> DisplayConnectionObservation { + DisplayConnectionObservation( + persistenceSnapshot: snapshot, + platformSupported: true, + allUUIDs: [targetUUID, otherUUID], + onlineUUIDs: [otherUUID], + virtualUUIDs: [], + activePhysicalViewableUUIDs: [otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: nil + ), + DisplayConnectionCandidate( + displayID: 2, + stableUUID: targetUUID, + isOnline: false, + isHardwareBackedPhysical: true, + recoveryHardwareProof: capability(state: .prepared).hardwareProof + ) + ], + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A" + ) + } + + private func ambiguousObservation( + snapshot: DisplayConnectionPersistenceSnapshot + ) -> DisplayConnectionObservation { + DisplayConnectionObservation( + persistenceSnapshot: snapshot, + platformSupported: true, + allUUIDs: [targetUUID, otherUUID], + onlineUUIDs: [otherUUID], + virtualUUIDs: [], + activePhysicalViewableUUIDs: [otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: nil + ), + DisplayConnectionCandidate( + displayID: 2, + stableUUID: targetUUID, + isOnline: false, + isHardwareBackedPhysical: true, + recoveryHardwareProof: capability(state: .prepared).hardwareProof + ), + DisplayConnectionCandidate( + displayID: 3, + stableUUID: targetUUID, + isOnline: false, + isHardwareBackedPhysical: true, + recoveryHardwareProof: capability(state: .prepared).hardwareProof + ) + ], + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A" + ) + } + + private func envelope( + capabilityState: DisplayConnectionRecoveryCapabilityState, + reservation: Bool + ) -> DisplayConnectionPersistenceEnvelope { + DisplayConnectionPersistenceEnvelope( + records: [record(capabilityState: capabilityState)], + pendingUUIDs: [], + reconnectReservationUUIDs: reservation ? [targetUUID] : [] + ) + } + + private func quarantineEnvelope( + capabilityState: DisplayConnectionRecoveryCapabilityState, + reservation: Bool = false + ) -> DisplayConnectionPersistenceEnvelope { + DisplayConnectionPersistenceEnvelope( + records: [record(capabilityState: capabilityState)], + pendingUUIDs: [targetUUID], + reconnectReservationUUIDs: reservation ? [targetUUID] : [], + reconnectPersistenceUncertainUUIDs: [targetUUID] + ) + } + + private func record( + capabilityState: DisplayConnectionRecoveryCapabilityState + ) -> DisplayConnectionPersistedRecord { + DisplayConnectionPersistedRecord( + uuid: targetUUID, + displayID: 2, + name: "Target", + width: 2560, + height: 1440, + recoveryCapability: capability(state: capabilityState) + ) + } + + private func capability( + state: DisplayConnectionRecoveryCapabilityState + ) -> DisplayConnectionRecoveryCapability { + DisplayConnectionRecoveryCapability( + uuid: targetUUID, + displayID: 2, + hardwareProof: DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: 1715, + productID: 10068, + serialNumber: 16843009 + ) + ), + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A", + state: state + ) + } + + private var otherHardwareProof: DisplayConnectionRecoveryHardwareProof { + DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: 1552, + productID: 41202, + serialNumber: 33624064 + ) + ) + } +} + +private final class ScriptedConnectionStore { + enum Read { + case stored + case missing + case corrupt + case envelope(DisplayConnectionPersistenceEnvelope) + } + + private var storedData: Data? + private var reads: [Read] + private(set) var readCount = 0 + private(set) var writeCount = 0 + + convenience init( + initial: DisplayConnectionPersistenceEnvelope, + reads: [Read] + ) throws { + try self.init(initialData: JSONEncoder().encode(initial), reads: reads) + } + + init(initialData: Data?, reads: [Read]) { + storedData = initialData + self.reads = reads + } + + func makeBoundary( + initialPublishedRecords: [DisplayConnectionPersistedRecord] = [] + ) -> DisplayConnectionPersistenceBoundary { + DisplayConnectionPersistenceBoundary( + initialPublishedRecords: initialPublishedRecords, + read: { [unowned self] in read() }, + write: { [unowned self] data in write(data) } + ) + } + + private func read() -> Data? { + readCount += 1 + let behavior = reads.isEmpty ? .stored : reads.removeFirst() + switch behavior { + case .stored: + return storedData + case .missing: + storedData = nil + return nil + case .corrupt: + let corrupt = Data("not-json".utf8) + storedData = corrupt + return corrupt + case let .envelope(envelope): + storedData = try? JSONEncoder().encode(envelope) + return storedData + } + } + + private func write(_ data: Data) { + writeCount += 1 + storedData = data + } + + func storedEnvelope() throws -> DisplayConnectionPersistenceEnvelope { + try JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: XCTUnwrap(storedData) + ) + } + + func simulateExternalDurableState( + _ envelope: DisplayConnectionPersistenceEnvelope + ) throws { + storedData = try JSONEncoder().encode(envelope) + } +} diff --git a/Tests/CrispControlCoreTests/DisplayConnectionRecoveryTests.swift b/Tests/CrispControlCoreTests/DisplayConnectionRecoveryTests.swift new file mode 100644 index 0000000..61b22d1 --- /dev/null +++ b/Tests/CrispControlCoreTests/DisplayConnectionRecoveryTests.swift @@ -0,0 +1,446 @@ +import XCTest +@testable import CrispControlCore + +final class DisplayConnectionRecoveryTests: XCTestCase { + private let targetUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + private let otherUUID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + func testMachSleepOffsetTokenParsesAndMatchesOnlyNarrowSamplingJitter() { + let nanoseconds: UInt64 = 10_000_000_000 + let token = DisplayConnectionMachSleepOffsetToken.encode(nanoseconds: nanoseconds) + let jittered = DisplayConnectionMachSleepOffsetToken.encode( + nanoseconds: nanoseconds + + DisplayConnectionMachSleepOffsetToken.matchingToleranceNanoseconds + ) + + XCTAssertEqual(token, "mach-sleep-offset-v1:10000000000") + XCTAssertEqual(DisplayConnectionMachSleepOffsetToken.parse(token), nanoseconds) + XCTAssertTrue(DisplayConnectionMachSleepOffsetToken.matches( + persisted: token, + current: token + )) + XCTAssertTrue(DisplayConnectionMachSleepOffsetToken.matches( + persisted: token, + current: jittered + )) + } + + func testMachSleepOffsetTokenRejectsLegacyAndMalformedValues() { + let valid = DisplayConnectionMachSleepOffsetToken.encode(nanoseconds: 0) + let malformed = [ + "0:0", + "", + "mach-sleep-offset-v1:", + "mach-sleep-offset-v1:-1", + "mach-sleep-offset-v1:+1", + "mach-sleep-offset-v1:01", + "mach-sleep-offset-v2:0" + ] + + for token in malformed { + XCTAssertNil(DisplayConnectionMachSleepOffsetToken.parse(token)) + XCTAssertFalse(DisplayConnectionMachSleepOffsetToken.matches( + persisted: token, + current: valid + )) + } + } + + func testMachSleepOffsetTokenRejectsOffsetChangeBeyondSamplingTolerance() { + let nanoseconds: UInt64 = 10_000_000_000 + let persisted = DisplayConnectionMachSleepOffsetToken.encode(nanoseconds: nanoseconds) + let afterSleep = DisplayConnectionMachSleepOffsetToken.encode( + nanoseconds: nanoseconds + + DisplayConnectionMachSleepOffsetToken.matchingToleranceNanoseconds + + 1 + ) + + XCTAssertFalse(DisplayConnectionMachSleepOffsetToken.matches( + persisted: persisted, + current: afterSleep + )) + } + + func testMachSleepOffsetTokenUsesBoundedContinuousClockSandwich() throws { + let awake = try XCTUnwrap(DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: 1_000, + absoluteTicks: 1_001, + continuousAfterTicks: 1_002, + timebaseNumerator: 1, + timebaseDenominator: 1 + )) + let awakeWithOrderingJitter = try XCTUnwrap(DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: 1_000, + absoluteTicks: 1_003, + continuousAfterTicks: 1_002, + timebaseNumerator: 1, + timebaseDenominator: 1 + )) + let afterOneSecondOfSleep = try XCTUnwrap(DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: 1_000_001_000, + absoluteTicks: 1_001, + continuousAfterTicks: 1_000_001_002, + timebaseNumerator: 1, + timebaseDenominator: 1 + )) + + XCTAssertEqual(DisplayConnectionMachSleepOffsetToken.parse(awake), 0) + XCTAssertEqual(DisplayConnectionMachSleepOffsetToken.parse(awakeWithOrderingJitter), 0) + XCTAssertEqual( + DisplayConnectionMachSleepOffsetToken.parse(afterOneSecondOfSleep), + 1_000_000_000 + ) + XCTAssertNil(DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: 0, + absoluteTicks: 0, + continuousAfterTicks: + DisplayConnectionMachSleepOffsetToken.maximumSamplingIntervalNanoseconds + 1, + timebaseNumerator: 1, + timebaseDenominator: 1 + )) + XCTAssertNil(DisplayConnectionMachSleepOffsetToken.make( + continuousBeforeTicks: 0, + absoluteTicks: 0, + continuousAfterTicks: 1, + timebaseNumerator: 1, + timebaseDenominator: 0 + )) + } + + func testFallbackRequiresConfirmedPendingFreeIntentionalRecordAtEveryResolverBoundary() { + let available = capability(state: .available) + let pending = observation( + records: [targetUUID], + pending: [targetUUID], + capability: available + ) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: pending + ), + .unavailable + ) + + let missingRecord = observation(records: [], capability: available) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: missingRecord + ), + .unavailable + ) + XCTAssertFalse( + DisplayConnectionRecoveryResolver.authorizesConsumedRecoveryDispatch( + uuid: targetUUID, + displayID: 2, + observation: observation( + records: [], + capability: capability(state: .consumed) + ) + ) + ) + } + + func testTopologyFingerprintIsOrderIndependentAndRejectsIncompleteEnumeration() { + let first = framebuffer( + registryEntryID: 20, + vendor: 1715, + product: 10068, + serial: 16843009 + ) + let second = framebuffer( + registryEntryID: 10, + vendor: 1552, + product: 41202, + serial: 33624064 + ) + XCTAssertEqual( + DisplayConnectionTopologyFingerprint.make( + displayIDs: [2, 1], + framebufferSnapshot: [first, second] + ), + DisplayConnectionTopologyFingerprint.make( + displayIDs: [1, 2], + framebufferSnapshot: [second, first] + ) + ) + XCTAssertNil(DisplayConnectionTopologyFingerprint.make( + displayIDs: [1, 1], + framebufferSnapshot: [first] + )) + XCTAssertNil(DisplayConnectionTopologyFingerprint.make( + displayIDs: [1], + framebufferSnapshot: [framebuffer( + registryEntryID: nil, + vendor: 1715, + product: 10068, + serial: 16843009 + )] + )) + XCTAssertNil(DisplayConnectionTopologyFingerprint.make( + displayIDs: [1, 2], + framebufferSnapshot: [first, framebuffer( + registryEntryID: 20, + vendor: 1552, + product: 41202, + serial: 33624064 + )] + )) + } + + func testRetainedProofRequiresDirectSavedIDIdentityAndUniqueFramebufferBinding() { + let retained = hardwareProof(vendor: 1715, product: 10068, serial: 16843009) + let exactIdentity = retained.identity + let exactFramebuffer = framebuffer( + registryEntryID: 20, + vendor: 1715, + product: 10068, + serial: 16843009 + ) + + XCTAssertTrue(DisplayConnectionRecoveryProofBinder.isDirectlyBound( + retainedProof: retained, + currentIsBuiltIn: false, + currentIdentity: exactIdentity, + framebufferSnapshot: [exactFramebuffer] + )) + + let unsafeCurrentIdentities: [HardwareDisplayIdentity?] = [ + nil, + HardwareDisplayIdentity(vendorID: 0, productID: 0, serialNumber: 0), + HardwareDisplayIdentity(vendorID: 1715, productID: nil, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: 10068, serialNumber: 99) + ] + for currentIdentity in unsafeCurrentIdentities { + XCTAssertFalse(DisplayConnectionRecoveryProofBinder.isDirectlyBound( + retainedProof: retained, + currentIsBuiltIn: false, + currentIdentity: currentIdentity, + framebufferSnapshot: [exactFramebuffer] + )) + } + + XCTAssertFalse(DisplayConnectionRecoveryProofBinder.isDirectlyBound( + retainedProof: retained, + currentIsBuiltIn: false, + currentIdentity: exactIdentity, + framebufferSnapshot: [exactFramebuffer, exactFramebuffer] + )) + } + + func testFallbackRejectsInternallyContradictoryEnumeration() { + let available = capability(state: .available) + let contradictory = observation( + records: [targetUUID], + capability: available, + activePhysicalViewableUUIDs: [targetUUID] + ) + XCTAssertEqual( + DisplayConnectionRecoveryResolver.reconnectResolution( + uuid: targetUUID, + observation: contradictory + ), + .unavailable + ) + } + + func testRejectedFallbackRollbackPersistsOneEnvelopeAndRestoresOnlyExactConsumedCapability() throws { + let consumed = capability(state: .consumed) + let current = persistenceEnvelope( + capability: consumed, + reconnectReservations: [targetUUID] + ) + var persisted: [DisplayConnectionPersistenceEnvelope] = [] + + let rolledBack = try RejectedReconnectRollback.persistAndVerify( + uuid: targetUUID, + consumedRecoveryCapability: consumed, + currentState: current + ) { proposed in + persisted.append(proposed) + return try JSONDecoder().decode( + DisplayConnectionPersistenceEnvelope.self, + from: JSONEncoder().encode(proposed) + ) + } + + XCTAssertEqual(persisted.count, 1) + XCTAssertEqual(persisted, [rolledBack]) + XCTAssertFalse(rolledBack.reconnectReservationSet.contains(targetUUID)) + XCTAssertEqual(rolledBack.records.first?.recoveryCapability?.state, .available) + } + + func testRejectedExactRollbackPreservesCapabilityWhileClearingReservation() throws { + let invalidated = capability(state: .invalidatedByWake) + let current = persistenceEnvelope( + capability: invalidated, + reconnectReservations: [targetUUID] + ) + var persistenceWriteCount = 0 + + let rolledBack = try RejectedReconnectRollback.persistAndVerify( + uuid: targetUUID, + consumedRecoveryCapability: nil, + currentState: current + ) { proposed in + persistenceWriteCount += 1 + return proposed + } + + XCTAssertEqual(persistenceWriteCount, 1) + XCTAssertFalse(rolledBack.reconnectReservationSet.contains(targetUUID)) + XCTAssertEqual(rolledBack.records.first?.recoveryCapability, invalidated) + } + + func testRejectedFallbackRollbackRejectsInvalidStateBeforePersistence() { + let unsafeStates: [DisplayConnectionRecoveryCapabilityState] = [ + .prepared, .available, .invalidatedByWake, .indeterminate + ] + for state in unsafeStates { + let invalid = capability(state: state) + let current = persistenceEnvelope( + capability: invalid, + reconnectReservations: [targetUUID] + ) + var persistenceWriteCount = 0 + + XCTAssertThrowsError(try RejectedReconnectRollback.persistAndVerify( + uuid: targetUUID, + consumedRecoveryCapability: invalid, + currentState: current + ) { proposed in + persistenceWriteCount += 1 + return proposed + }) + XCTAssertEqual(persistenceWriteCount, 0) + XCTAssertEqual(current.reconnectReservationSet, [targetUUID]) + XCTAssertEqual(current.records.first?.recoveryCapability?.state, state) + } + } + + func testRejectedFallbackRollbackReadBackFailureLeavesOriginalUncertainEnvelope() { + let consumed = capability(state: .consumed) + let current = persistenceEnvelope( + capability: consumed, + reconnectReservations: [targetUUID] + ) + var persistenceWriteCount = 0 + + XCTAssertThrowsError(try RejectedReconnectRollback.persistAndVerify( + uuid: targetUUID, + consumedRecoveryCapability: consumed, + currentState: current + ) { _ in + persistenceWriteCount += 1 + return current + }) + XCTAssertEqual(persistenceWriteCount, 1) + XCTAssertEqual(current.reconnectReservationSet, [targetUUID]) + XCTAssertEqual(current.records.first?.recoveryCapability?.state, .consumed) + } + + private func observation( + records: Set, + pending: Set = [], + capability: DisplayConnectionRecoveryCapability, + activePhysicalViewableUUIDs: Set? = nil + ) -> DisplayConnectionObservation { + let targetProof = hardwareProof(vendor: 1715, product: 10068, serial: 16843009) + return DisplayConnectionObservation( + platformSupported: true, + allUUIDs: [otherUUID], + onlineUUIDs: [otherUUID], + intentionalDisconnectedUUIDs: records, + pendingDisconnectUUIDs: pending, + virtualUUIDs: [], + activePhysicalViewableUUIDs: activePhysicalViewableUUIDs ?? [otherUUID], + candidates: [ + DisplayConnectionCandidate( + displayID: 1, + stableUUID: otherUUID, + isOnline: true, + isHardwareBackedPhysical: true, + recoveryHardwareProof: hardwareProof(vendor: 1552, product: 41202, serial: 33624064) + ), + DisplayConnectionCandidate( + displayID: 2, + stableUUID: nil, + isOnline: false, + isHardwareBackedPhysical: false, + recoveryHardwareProof: targetProof + ) + ], + recoveryCapabilities: [capability], + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A" + ) + } + + private func capability( + state: DisplayConnectionRecoveryCapabilityState + ) -> DisplayConnectionRecoveryCapability { + DisplayConnectionRecoveryCapability( + uuid: targetUUID, + displayID: 2, + hardwareProof: hardwareProof(vendor: 1715, product: 10068, serial: 16843009), + bootSessionID: "boot-A", + loginSessionID: "login-A", + wakeSessionID: "mach-sleep-offset-v1:10000000000", + topologyFingerprint: "topology-A", + state: state + ) + } + + private func persistenceEnvelope( + capability: DisplayConnectionRecoveryCapability?, + reconnectReservations: Set + ) -> DisplayConnectionPersistenceEnvelope { + DisplayConnectionPersistenceEnvelope( + records: [DisplayConnectionPersistedRecord( + uuid: targetUUID, + displayID: 2, + name: "Target", + width: 2560, + height: 1440, + recoveryCapability: capability + )], + pendingUUIDs: [], + reconnectReservationUUIDs: reconnectReservations + ) + } + + private func hardwareProof( + vendor: UInt32, + product: UInt32, + serial: UInt32 + ) -> DisplayConnectionRecoveryHardwareProof { + DisplayConnectionRecoveryHardwareProof( + isBuiltIn: false, + identity: HardwareDisplayIdentity( + vendorID: vendor, + productID: product, + serialNumber: serial + ) + ) + } + + private func framebuffer( + registryEntryID: UInt64?, + vendor: UInt32, + product: UInt32, + serial: UInt32 + ) -> HardwareFramebufferIdentityEvidence { + HardwareFramebufferIdentityEvidence( + registryEntryID: registryEntryID, + hasEDIDUUID: true, + identity: HardwareDisplayIdentity( + vendorID: vendor, + productID: product, + serialNumber: serial + ) + ) + } +} diff --git a/Tests/CrispControlCoreTests/EDRHeadroomSettlementTests.swift b/Tests/CrispControlCoreTests/EDRHeadroomSettlementTests.swift new file mode 100644 index 0000000..4e9e385 --- /dev/null +++ b/Tests/CrispControlCoreTests/EDRHeadroomSettlementTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import CrispControlCore + +@MainActor +final class EDRHeadroomSettlementTests: XCTestCase { + func testDelayedHeadroomBecomesReadyWithinBoundedSamples() async { + var samples = [1.0, 1.03, 1.2] + let result = await EDRHeadroomSettlement.wait( + maxSamples: 3, + threshold: 1.05, + isCurrent: { true }, + isCapable: { true }, + potentialHeadroom: { samples.removeFirst() }, + pause: {} + ) + + XCTAssertEqual(result, .ready(potentialHeadroom: 1.2)) + XCTAssertTrue(samples.isEmpty) + } + + func testHeadroomTimeoutIsDistinctFromPreferenceVerification() async { + var sampleCount = 0 + let result = await EDRHeadroomSettlement.wait( + maxSamples: 3, + threshold: 1.05, + isCurrent: { true }, + isCapable: { true }, + potentialHeadroom: { + sampleCount += 1 + return 1.0 + }, + pause: {} + ) + + XCTAssertEqual(result, .timedOut) + XCTAssertEqual(sampleCount, 3) + } + + func testDisconnectOrDisplayReassignmentInvalidatesSettlement() async { + var current = true + let result = await EDRHeadroomSettlement.wait( + maxSamples: 4, + threshold: 1.05, + isCurrent: { current }, + isCapable: { true }, + potentialHeadroom: { 1.0 }, + pause: { current = false } + ) + + XCTAssertEqual(result, .invalidated) + } + + func testNewerToggleSupersedesOlderSettlement() async { + var generationIsCurrent = true + let result = await EDRHeadroomSettlement.wait( + maxSamples: 4, + threshold: 1.05, + isCurrent: { generationIsCurrent }, + isCapable: { true }, + potentialHeadroom: { 1.0 }, + pause: { generationIsCurrent = false } + ) + + XCTAssertEqual(result, .invalidated) + } + + func testCancellationFromCommandOwnedPausePropagates() async { + do { + _ = try await EDRHeadroomSettlement.wait( + maxSamples: 3, + threshold: 1.05, + isCurrent: { true }, + isCapable: { true }, + potentialHeadroom: { 1.0 }, + pause: { throw CancellationError() } + ) + XCTFail("cancellation must escape the settlement helper") + } catch is CancellationError { + // Expected: the dispatcher classifies a post-mutation cancellation. + } catch { + XCTFail("unexpected error: \(error)") + } + } +} diff --git a/Tests/CrispControlCoreTests/ExtraBrightnessHostContractTests.swift b/Tests/CrispControlCoreTests/ExtraBrightnessHostContractTests.swift new file mode 100644 index 0000000..b949287 --- /dev/null +++ b/Tests/CrispControlCoreTests/ExtraBrightnessHostContractTests.swift @@ -0,0 +1,131 @@ +import Foundation +import XCTest +@testable import CrispControlCore + +final class ExtraBrightnessHostContractTests: XCTestCase { + func testSettlingDisableSerializesNonfailureEnvelope() async throws { + let service = HostMappedExtraBrightnessControlService(outcome: .settling) + let response = await dispatch(using: service) + let envelope = try encodedObject(response) + let result = try XCTUnwrap(envelope["result"] as? [String: Any]) + + XCTAssertEqual(envelope["ok"] as? Bool, true) + XCTAssertEqual(result["verification"] as? String, "settling") + XCTAssertEqual(result["persistedEnabled"] as? Bool, false) + XCTAssertEqual(result["enabled"] as? Bool, true) + XCTAssertEqual(result["maxBrightness"] as? Double, 150) + XCTAssertFalse(try XCTUnwrap(result["warnings"] as? [String]).isEmpty) + let mutationCount = await service.mutationCount + XCTAssertEqual(mutationCount, 1) + } + + func testIndeterminateDisableSerializesFailClosedEnvelope() async throws { + let service = HostMappedExtraBrightnessControlService(outcome: .indeterminate) + let response = await dispatch(using: service) + let envelope = try encodedObject(response) + let error = try XCTUnwrap(envelope["error"] as? [String: Any]) + let details = try XCTUnwrap(error["details"] as? [String: Any]) + + XCTAssertEqual(envelope["ok"] as? Bool, false) + XCTAssertEqual(error["code"] as? String, "write_outcome_indeterminate") + XCTAssertEqual(details["retrySafe"] as? Bool, false) + XCTAssertEqual(response.error?.code.exitCode, 5) + let mutationCount = await service.mutationCount + XCTAssertEqual(mutationCount, 1) + } + + func testNonOwnedStaleLiveDisableSerializesIndeterminateEnvelope() async throws { + let outcome = ExtraBrightnessControlMutationOutcome.classify( + mutationAccepted: true, + operationCompleted: false, + identityMatches: true, + persistedEnabled: false, + liveEnabled: true, + maxBrightness: 150, + cleanupInProgress: false + ) + let service = HostMappedExtraBrightnessControlService(outcome: outcome) + let response = await dispatch(using: service) + let envelope = try encodedObject(response) + let error = try XCTUnwrap(envelope["error"] as? [String: Any]) + let details = try XCTUnwrap(error["details"] as? [String: Any]) + + XCTAssertEqual(envelope["ok"] as? Bool, false) + XCTAssertEqual(error["code"] as? String, "write_outcome_indeterminate") + XCTAssertEqual(details["retrySafe"] as? Bool, false) + XCTAssertEqual(response.error?.code.exitCode, 5) + let mutationCount = await service.mutationCount + XCTAssertEqual(mutationCount, 1) + } + + private func dispatch( + using service: HostMappedExtraBrightnessControlService + ) async -> ControlResponse { + await ControlCommandDispatcher(service: service, appVersion: "1.5.0").handle( + ControlRequest( + requestID: "req-host-mapping", + command: "extra-brightness.set", + arguments: [ + "selector": .string("uuid-settling-boost"), + "enabled": .bool(false) + ] + ) + ) + } + + private func encodedObject(_ response: ControlResponse) throws -> [String: Any] { + try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(response)) as? [String: Any] + ) + } +} + +private actor HostMappedExtraBrightnessControlService: ControlCommandService { + let outcome: ExtraBrightnessControlMutationOutcome + private(set) var mutationCount = 0 + + init(outcome: ExtraBrightnessControlMutationOutcome) { + self.outcome = outcome + } + + func displays() async throws -> [ControlDisplay] { [.settlingExtraBrightness] } + func readBrightness(displayUUID: String) async throws -> Double? { 120 } + func writeBrightness(displayUUID: String, percent: Double) async throws -> Double { percent } + + func setExtraBrightness( + displayUUID: String, + enabled: Bool + ) async throws -> ExtraBrightnessSetResult { + mutationCount += 1 + guard let result = try outcome.resolvedControlResult( + capability: ControlDisplay.settlingExtraBrightness.extraBrightness + ) else { + throw ControlServiceError.writeFailed("test fixture expected a resolved outcome") + } + return result + } +} + +private extension ControlDisplay { + static let settlingExtraBrightness = ControlDisplay( + uuid: "uuid-settling-boost", + name: "Settling Boost", + isMain: true, + isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, + backend: .displayServices, + range: ControlRange(min: 0, max: 150, precision: 0.1), + readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: 150, precision: 0.1) + ), + brightnessPercent: 120, + extraBrightness: ExtraBrightnessCapability( + state: .writable, + enabled: true, + persistedEnabled: false, + maxBrightness: 150 + ) + ) +} diff --git a/Tests/CrispControlCoreTests/HDRMutationCoordinatorTests.swift b/Tests/CrispControlCoreTests/HDRMutationCoordinatorTests.swift new file mode 100644 index 0000000..ad2b258 --- /dev/null +++ b/Tests/CrispControlCoreTests/HDRMutationCoordinatorTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import CrispControlCore + +final class HDRMutationCoordinatorTests: XCTestCase { + func testHDRSetWaitsForMatchingReadbackAndRoutesOnlyAfterVerification() { + var coordinator = HDRMutationCoordinator() + let token = coordinator.begin(uuid: "uuid-a", identity: "object-a", requested: true) + XCTAssertTrue(coordinator.recordSetterInvocation(token)) + + XCTAssertFalse(coordinator.observe( + token, currentUUID: "uuid-a", currentIdentity: "object-a", readback: false + )) + XCTAssertNil(coordinator.verifiedRoutingState(for: token)) + XCTAssertTrue(coordinator.observe( + token, currentUUID: "uuid-a", currentIdentity: "object-a", readback: true + )) + XCTAssertEqual(coordinator.verifiedRoutingState(for: token), true) + } + + func testDisplayReassignmentDuringHDRSetFailsClosed() { + var coordinator = HDRMutationCoordinator() + let token = coordinator.begin(uuid: "uuid-a", identity: "object-a", requested: true) + XCTAssertFalse(coordinator.observe( + token, currentUUID: "uuid-b", currentIdentity: "object-b", readback: true + )) + XCTAssertNil(coordinator.verifiedRoutingState(for: token)) + } + + func testNewerHDRRequestInvalidatesStaleReadback() { + var coordinator = HDRMutationCoordinator() + let stale = coordinator.begin(uuid: "uuid-a", identity: "object-a", requested: false) + let newest = coordinator.begin(uuid: "uuid-a", identity: "object-a", requested: true) + XCTAssertTrue(coordinator.recordSetterInvocation(newest)) + + XCTAssertFalse(coordinator.observe( + stale, currentUUID: "uuid-a", currentIdentity: "object-a", readback: false + )) + XCTAssertTrue(coordinator.observe( + newest, currentUUID: "uuid-a", currentIdentity: "object-a", readback: true + )) + } +} diff --git a/Tests/CrispControlCoreTests/HDRPreferenceAdapterTests.swift b/Tests/CrispControlCoreTests/HDRPreferenceAdapterTests.swift new file mode 100644 index 0000000..8d7e42b --- /dev/null +++ b/Tests/CrispControlCoreTests/HDRPreferenceAdapterTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import CrispControlCore + +@MainActor +final class HDRPreferenceAdapterTests: XCTestCase { + func testMissingManagerOrGetterOrSetterFailsUnsupportedWithoutInvokingSetter() { + let cases: [HDRAdapterState?] = [ + nil, + HDRAdapterState(supportsHDR: false, prefersHDR: false, canSet: true, identity: "display"), + HDRAdapterState(supportsHDR: true, prefersHDR: false, canSet: false, identity: "display") + ] + + for state in cases { + let adapter = FakeHDRPreferenceAdapter(state: state) + XCTAssertNil(HDRPreferenceAdapterDriver.beginSet( + using: adapter, displayID: 7, requested: true + )) + XCTAssertEqual(adapter.setterCalls, 0) + } + } + + func testWritableAdapterInvocationCarriesStableIdentity() { + let adapter = FakeHDRPreferenceAdapter(state: HDRAdapterState( + supportsHDR: true, prefersHDR: false, canSet: true, identity: "mp-object-a" + )) + + let identity = HDRPreferenceAdapterDriver.beginSet( + using: adapter, displayID: 7, requested: true + ) + + XCTAssertEqual(identity, "mp-object-a") + XCTAssertEqual(adapter.expectedIdentities, ["mp-object-a"]) + XCTAssertEqual(adapter.setterCalls, 1) + } + + func testMonitorPanelMethodSignaturesAcceptOnlyExpectedABI() { + XCTAssertTrue(MonitorPanelABISignatureValidator.isCompatible( + .init(returnType: "@", argumentTypes: ["@", ":"]), + with: .displaysGetter + )) + XCTAssertTrue(MonitorPanelABISignatureValidator.isCompatible( + .init(returnType: "I", argumentTypes: ["@", ":"]), + with: .displayIDGetter + )) + for boolEncoding in ["B", "c"] { + XCTAssertTrue(MonitorPanelABISignatureValidator.isCompatible( + .init(returnType: boolEncoding, argumentTypes: ["@", ":"]), + with: .boolGetter + )) + XCTAssertTrue(MonitorPanelABISignatureValidator.isCompatible( + .init(returnType: "v", argumentTypes: ["@", ":", boolEncoding]), + with: .boolSetter + )) + } + } + + func testMonitorPanelSignatureDriftFailsClosedBeforeInvocation() { + let incompatible: [(ObjectiveCMethodEncoding, MonitorPanelABIMethod)] = [ + (.init(returnType: "v", argumentTypes: ["@", ":"]), .displaysGetter), + (.init(returnType: "Q", argumentTypes: ["@", ":"]), .displayIDGetter), + (.init(returnType: "i", argumentTypes: ["@", ":"]), .boolGetter), + (.init(returnType: "B", argumentTypes: ["@", ":", "B"]), .boolSetter), + (.init(returnType: "v", argumentTypes: ["@", ":"]), .boolSetter), + (.init(returnType: "v", argumentTypes: ["@", ":", "i"]), .boolSetter) + ] + + for (encoding, method) in incompatible { + XCTAssertFalse(MonitorPanelABISignatureValidator.isCompatible(encoding, with: method)) + } + } +} + +@MainActor +private final class FakeHDRPreferenceAdapter: HDRPreferenceAdapting { + var state: HDRAdapterState? + var setterCalls = 0 + var expectedIdentities: [String] = [] + + init(state: HDRAdapterState?) { self.state = state } + func readState(displayID: UInt32) -> HDRAdapterState? { state } + func setPreference(_ enabled: Bool, displayID: UInt32, expectedIdentity: String) -> Bool { + setterCalls += 1 + expectedIdentities.append(expectedIdentity) + return state?.identity == expectedIdentity + } +} diff --git a/Tests/CrispControlCoreTests/HardwareBackedPhysicalDisplayClassifierTests.swift b/Tests/CrispControlCoreTests/HardwareBackedPhysicalDisplayClassifierTests.swift new file mode 100644 index 0000000..d6defbb --- /dev/null +++ b/Tests/CrispControlCoreTests/HardwareBackedPhysicalDisplayClassifierTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import CrispControlCore + +final class HardwareProofClassifierTests: XCTestCase { + private let targetIdentity = HardwareDisplayIdentity( + vendorID: 1715, + productID: 10068, + serialNumber: 16843009 + ) + + func testRealFramebufferTopologyAcceptsSingleEDIDBackedExactIdentity() { + let completeFramebufferSnapshot = [ + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: false, + identity: HardwareDisplayIdentity( + vendorID: 1552, + productID: nil, + serialNumber: nil + ) + ), + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: true, + identity: targetIdentity + ), + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: false, + identity: nil + ) + ] + + XCTAssertTrue( + isHardwareBacked(framebufferSnapshot: completeFramebufferSnapshot), + "non-EDID built-in and placeholder entries must not hide one exact EDID-backed match" + ) + } + + func testExternalWithUniqueExactFramebufferIdentityIsAccepted() { + XCTAssertTrue(isHardwareBacked(framebufferSnapshot: [ + candidate( + HardwareDisplayIdentity( + vendorID: 1552, + productID: 41202, + serialNumber: 33624064 + ) + ), + candidate(targetIdentity) + ])) + } + + func testNoEDIDBackedExactFramebufferMatchIsRejected() { + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: [ + candidate( + HardwareDisplayIdentity( + vendorID: 1715, + productID: 10069, + serialNumber: 16843009 + ) + ) + ])) + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: [ + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: false, + identity: targetIdentity + ) + ])) + } + + func testDuplicateEDIDBackedExactFramebufferIdentitiesAreRejected() { + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: [ + candidate(targetIdentity), + candidate(targetIdentity) + ])) + } + + func testNonEDIDCompleteDuplicateDoesNotCreateAmbiguity() { + XCTAssertTrue(isHardwareBacked(framebufferSnapshot: [ + candidate(targetIdentity), + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: false, + identity: targetIdentity + ) + ])) + } + + func testZeroOrMissingTargetIdentityFieldsAreRejected() { + let incompleteIdentities = [ + HardwareDisplayIdentity(vendorID: 0, productID: 10068, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: 0, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: 10068, serialNumber: 0), + HardwareDisplayIdentity(vendorID: nil, productID: 10068, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: nil, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: 10068, serialNumber: nil) + ] + + for identity in incompleteIdentities { + XCTAssertFalse(isHardwareBacked( + target: identity, + framebufferSnapshot: [candidate(identity)] + )) + } + } + + func testEDIDBackedIncompleteIdentityRejectsAll() { + let incompleteCandidates = [ + HardwareDisplayIdentity(vendorID: 0, productID: 10068, serialNumber: 16843009), + HardwareDisplayIdentity(vendorID: 1715, productID: nil, serialNumber: 16843009), + nil + ] + + for incompleteCandidate in incompleteCandidates { + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: [ + candidate(targetIdentity), + HardwareFramebufferIdentityEvidence( + hasEDIDUUID: true, + identity: incompleteCandidate + ) + ])) + } + } + + func testVendorAndProductOnlyMatchIsRejected() { + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: [ + candidate( + HardwareDisplayIdentity( + vendorID: 1715, + productID: 10068, + serialNumber: 16843010 + ) + ) + ])) + } + + func testEnumerationFailureFailsClosed() { + XCTAssertFalse(isHardwareBacked(framebufferSnapshot: nil)) + } + + func testKnownVirtualIsRejectedEvenWithUniqueExactFramebufferMatch() { + XCTAssertFalse(isHardwareBacked( + isKnownVirtual: true, + framebufferSnapshot: [candidate(targetIdentity)] + )) + } + + func testBuiltInAndDisplayConnectProofRemainAccepted() { + XCTAssertTrue(isHardwareBacked(isBuiltin: true, framebufferSnapshot: nil)) + + let displayConnectEvidence = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: false, + isKnownVirtual: false, + hasIOServicePort: true, + ioServiceConformsToDisplayConnect: true + ) + XCTAssertTrue( + HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(displayConnectEvidence) + ) + } + + private func candidate( + _ identity: HardwareDisplayIdentity + ) -> HardwareFramebufferIdentityEvidence { + HardwareFramebufferIdentityEvidence(hasEDIDUUID: true, identity: identity) + } + + private func isHardwareBacked( + target: HardwareDisplayIdentity? = nil, + isBuiltin: Bool = false, + isKnownVirtual: Bool = false, + framebufferSnapshot: [HardwareFramebufferIdentityEvidence]? + ) -> Bool { + let evidence = HardwareBackedPhysicalDisplayEvidence( + isBuiltin: isBuiltin, + isKnownVirtual: isKnownVirtual, + hasIOServicePort: false, + ioServiceConformsToDisplayConnect: false, + coreGraphicsIdentity: target ?? targetIdentity, + framebufferSnapshot: framebufferSnapshot + ) + return HardwareBackedPhysicalDisplayClassifier.isHardwareBacked(evidence) + } +} diff --git a/Tests/CrispControlCoreTests/PhysicalDisplaySafetyPolicyTests.swift b/Tests/CrispControlCoreTests/PhysicalDisplaySafetyPolicyTests.swift new file mode 100644 index 0000000..a7ad5be --- /dev/null +++ b/Tests/CrispControlCoreTests/PhysicalDisplaySafetyPolicyTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import CrispControlCore + +final class PhysicalDisplaySafetyPolicyTests: XCTestCase { + private let duplicateUUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + private let uniqueUUID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + func testUnknownActivePhysicalEnumerationFailsClosedForDisconnectAndEmergencyRecovery() { + XCTAssertTrue( + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: true, + activePhysicalDisplayCount: nil + ) + ) + XCTAssertTrue( + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: false, + activePhysicalDisplayCount: nil + ) + ) + XCTAssertFalse( + PhysicalDisplaySafetyPolicy.authorizesEmergencyRecovery( + activePhysicalDisplayCount: nil + ) + ) + XCTAssertTrue( + PhysicalDisplaySafetyPolicy.authorizesEmergencyRecovery( + activePhysicalDisplayCount: 0 + ) + ) + XCTAssertFalse( + PhysicalDisplaySafetyPolicy.authorizesEmergencyRecovery( + activePhysicalDisplayCount: 1 + ) + ) + } + + func testUniqueExactUUIDSelectionDropsDuplicatesMissingAndLegacyIdentities() { + let selected = PhysicalDisplaySafetyPolicy.uniqueExactUUIDDisplayIDs([ + (uuid: duplicateUUID, displayID: 10), + (uuid: duplicateUUID, displayID: 11), + (uuid: uniqueUUID, displayID: 12), + (uuid: nil, displayID: 13), + (uuid: "id-14", displayID: 14), + (uuid: "Fixture External", displayID: 15) + ]) + + XCTAssertNil(selected[duplicateUUID]) + XCTAssertEqual(selected[uniqueUUID], 12) + XCTAssertEqual(selected.count, 1) + } + + func testKnownCountsPreserveLastDisplayProtectionWithoutInventingZero() { + XCTAssertTrue( + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: true, + activePhysicalDisplayCount: 1 + ) + ) + XCTAssertFalse( + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: true, + activePhysicalDisplayCount: 2 + ) + ) + XCTAssertFalse( + PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect( + targetIsActive: false, + activePhysicalDisplayCount: 1 + ) + ) + } + + func testExactStableUUIDValidationRejectsLegacyAndHumanReadableIdentities() { + for invalid in ["id-123", "Fixture External", "main", "builtin", "not-a-uuid"] { + XCTAssertFalse(ControlRequest.isExactDisplayUUID(invalid), invalid) + } + XCTAssertTrue( + ControlRequest.isExactDisplayUUID("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + ) + } +} diff --git a/Tests/CrispControlCoreTests/ProtocolTests.swift b/Tests/CrispControlCoreTests/ProtocolTests.swift new file mode 100644 index 0000000..7283a65 --- /dev/null +++ b/Tests/CrispControlCoreTests/ProtocolTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import CrispControlCore + +final class ProtocolTests: XCTestCase { + func testSuccessEnvelopeRoundTripsWithoutError() throws { + let response = ControlResponse.success( + requestID: "req-1", + result: .object(["version": .string("1.5.0")]) + ) + let data = try ControlJSON.encoder.encode(response) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(object["protocolVersion"] as? Int, 1) + XCTAssertEqual(object["requestID"] as? String, "req-1") + XCTAssertEqual(object["ok"] as? Bool, true) + XCTAssertNotNil(object["result"]) + XCTAssertNil(object["error"]) + XCTAssertEqual(try ControlJSON.decoder.decode(ControlResponse.self, from: data), response) + } + + func testFailureEnvelopePreservesRequestIDAndStructuredDetails() throws { + let response = ControlResponse.failure( + requestID: "req-2", + code: .ambiguousSelector, + message: "selector matches multiple displays", + details: .object(["candidates": .array([.string("uuid-a"), .string("uuid-b")])]) + ) + let data = try ControlJSON.encoder.encode(response) + let decoded = try ControlJSON.decoder.decode(ControlResponse.self, from: data) + + XCTAssertFalse(decoded.ok) + XCTAssertNil(decoded.result) + XCTAssertEqual(decoded.error?.code, .ambiguousSelector) + XCTAssertEqual(decoded.error?.details?["candidates"], .array([.string("uuid-a"), .string("uuid-b")])) + XCTAssertEqual(decoded.requestID, "req-2") + } + + func testCapabilityCarriesOperationLevelTruth() throws { + let capability = BrightnessCapability( + state: .writable, + backend: .displayServices, + range: ControlRange(min: 0, max: 100, precision: 0.1), + readback: .authoritative, + reason: nil, + remediation: nil + ) + + XCTAssertEqual(capability.state, .writable) + XCTAssertEqual(capability.backend, .displayServices) + XCTAssertEqual(capability.range.max, 100) + XCTAssertEqual(capability.readback, .authoritative) + } + + func testP0CapabilitiesRoundTripDynamicRangeAndDistinctToggleTruth() throws { + let display = ControlDisplay( + uuid: "xdr", name: "Built-in XDR", isMain: true, isBuiltin: true, + brightness: BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: 162.5, precision: 0.1), readback: .authoritative, + hardwareRange: ControlRange(min: 0, max: 100, precision: 0.1), + logicalRange: ControlRange(min: 0, max: 162.5, precision: 0.1) + ), + brightnessPercent: 120, + extraBrightness: ExtraBrightnessCapability( + state: .writable, enabled: true, persistedEnabled: true, maxBrightness: 162.5, + headroom: EDRHeadroomSnapshot( + potential: 1.8, current: 1.62, + appliedFactor: 1.4, factorVerification: "app_state" + ) + ), + hdr: HDRCapability.unsupported( + enabled: nil, + reason: "built-in displays do not expose an HDR preference toggle", + remediation: "use Extra Brightness when eligible" + ) + ) + + let data = try ControlJSON.encoder.encode(display) + let decoded = try ControlJSON.decoder.decode(ControlDisplay.self, from: data) + + XCTAssertEqual(decoded.brightness.hardwareRange.max, 100) + XCTAssertEqual(decoded.brightness.logicalRange.max, 162.5) + XCTAssertEqual(decoded.extraBrightness.state, .writable) + XCTAssertEqual(decoded.extraBrightness.enabled, true) + XCTAssertEqual(decoded.extraBrightness.persistedEnabled, true) + XCTAssertEqual(decoded.extraBrightness.maxBrightness, 162.5) + XCTAssertEqual(decoded.extraBrightness.headroom?.potential, 1.8) + XCTAssertEqual(decoded.extraBrightness.headroom?.current, 1.62) + XCTAssertEqual(decoded.extraBrightness.headroom?.appliedFactor, 1.4) + XCTAssertEqual(decoded.extraBrightness.headroom?.factorVerification, "app_state") + XCTAssertEqual(decoded.hdr.state, .unsupported) + XCTAssertTrue(decoded.hdr.remediation?.contains("Extra Brightness") == true) + } + + func testIneligibleCapabilitiesCarryReasonsWithoutPretendingHDRMeansBoostReady() throws { + let boost = ExtraBrightnessCapability.unsupported( + enabled: true, + persistedEnabled: false, + maxBrightness: 100, + headroom: EDRHeadroomSnapshot(potential: 1, current: 1), + reason: "no usable EDR headroom and no writable external HDR toggle", + remediation: "enable HDR in the display or macOS settings if available" + ) + let hdr = HDRCapability( + state: .readable, enabled: true, + reason: "HDR is live but Crisp does not expose a writable toggle for this display" + ) + + XCTAssertEqual(boost.state, .unsupported) + XCTAssertEqual(boost.enabled, true) + XCTAssertEqual(boost.maxBrightness, 100) + XCTAssertEqual(hdr.state, .readable) + XCTAssertEqual(hdr.enabled, true) + } + + func testDisplayConnectionCapabilityCarriesFailClosedPlatformAndActionTruth() throws { + let capability = DisplayConnectionCapability( + state: .writable, + connected: true, + disconnectAllowed: true, + reconnectAllowed: false, + platformSupported: true, + reason: "another physical display remains viewable", + remediation: nil + ) + + let data = try ControlJSON.encoder.encode(capability) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["state"] as? String, "writable") + XCTAssertEqual(object["connected"] as? Bool, true) + XCTAssertEqual(object["disconnectAllowed"] as? Bool, true) + XCTAssertEqual(object["reconnectAllowed"] as? Bool, false) + XCTAssertEqual(object["platformSupported"] as? Bool, true) + XCTAssertEqual( + try ControlJSON.decoder.decode(DisplayConnectionCapability.self, from: data), + capability + ) + } + + func testEveryErrorHasAStableExitCode() { + XCTAssertEqual(ControlErrorCode.appNotRunning.exitCode, 3) + XCTAssertEqual(ControlErrorCode.invalidArguments.exitCode, 2) + XCTAssertEqual(ControlErrorCode.unsupportedCapability.exitCode, 4) + XCTAssertEqual(ControlErrorCode.writeVerificationFailed.exitCode, 5) + XCTAssertEqual(ControlErrorCode.writeOutcomeIndeterminate.exitCode, 5) + XCTAssertEqual(ControlErrorCode.batchPartialFailure.exitCode, 5) + XCTAssertEqual(ControlErrorCode.batchPreflightFailed.exitCode, 4) + XCTAssertEqual(ControlErrorCode.emptyPhysicalInventory.exitCode, 4) + XCTAssertEqual(ControlErrorCode.internalError.exitCode, 1) + } + + func testUnknownFutureSetCommandDefaultsToIndeterminateMutationSafety() { + let request = ControlRequest( + requestID: "future", command: "future-display.set", + arguments: ["selector": .string("uuid-a")] + ) + + XCTAssertEqual(request.mutationKind, .unknown) + let response = ControlResponse.timeout(for: request) + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + XCTAssertEqual(response.error?.details?["command"], .string("future-display.set")) + } + + func testBrightnessBatchRestoreOverrideIsAnAdditiveV1RequestOption() throws { + let strict = ControlRequest( + requestID: "strict", + command: "brightness.set-all", + arguments: ["percent": .number(50)] + ) + let override = ControlRequest( + requestID: "override", + command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .bool(true)] + ) + let invalid = ControlRequest( + requestID: "invalid", + command: "brightness.set-all", + arguments: ["percent": .number(50), "allowUnrestorable": .string("true")] + ) + + XCTAssertEqual(strict.brightnessBatchRestoreMode, .strict) + XCTAssertEqual(override.brightnessBatchRestoreMode, .allowUnrestorable) + XCTAssertNil(invalid.brightnessBatchRestoreMode) + let data = try ControlJSON.encoder.encode(override) + let decoded = try ControlJSON.decoder.decode(ControlRequest.self, from: data) + XCTAssertEqual(decoded.protocolVersion, 1) + XCTAssertEqual(decoded.brightnessBatchRestoreMode, .allowUnrestorable) + XCTAssertEqual(crispControlProtocolVersion, 1) + } + + func testDisplayConnectionTimeoutsCarryExactUUIDCommandStateAndNoRetryTruth() { + let uuid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + let cases: [(String, [String: JSONValue], String)] = [ + ("displays.disconnect", ["uuid": .string(uuid)], "disconnected"), + ("displays.reconnect", ["uuid": .string(uuid)], "connected") + ] + + for (command, arguments, requestedState) in cases { + let response = ControlResponse.timeout(for: ControlRequest( + requestID: command, + command: command, + arguments: arguments + )) + + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate, command) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false), command) + XCTAssertEqual(response.error?.details?["command"], .string(command), command) + XCTAssertEqual(response.error?.details?["displayUUID"], .string(uuid), command) + XCTAssertNil(response.error?.details?["selector"], command) + XCTAssertEqual( + response.error?.details?["requestedConnectionState"], + .string(requestedState), + command + ) + XCTAssertEqual(response.error?.code.exitCode, 5, command) + } + } + + func testOldV1DisplayResponseDecodesWithAdditiveDefaults() throws { + let fixture = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/old-v1-display.json") + let oldV1 = try Data(contentsOf: fixture) + + let decoded = try ControlJSON.decoder.decode(ControlDisplay.self, from: oldV1) + + XCTAssertEqual(decoded.uuid, "old-uuid") + XCTAssertEqual(decoded.brightnessPercent, 42) + XCTAssertEqual(decoded.brightness.range.max, 100) + XCTAssertEqual(decoded.brightness.hardwareRange.max, 100) + XCTAssertEqual(decoded.brightness.logicalRange.max, 100) + XCTAssertFalse(decoded.isVirtual) + XCTAssertEqual(decoded.extraBrightness.state, .unsupported) + XCTAssertEqual(decoded.hdr.state, .unsupported) + XCTAssertEqual(decoded.connection.state, .unsupported) + XCTAssertTrue(decoded.connection.connected) + XCTAssertFalse(decoded.connection.disconnectAllowed) + XCTAssertFalse(decoded.connection.reconnectAllowed) + XCTAssertFalse(decoded.connection.platformSupported) + XCTAssertEqual(crispControlProtocolVersion, 1) + } +} diff --git a/Tests/CrispControlCoreTests/SelectorTests.swift b/Tests/CrispControlCoreTests/SelectorTests.swift new file mode 100644 index 0000000..987a21c --- /dev/null +++ b/Tests/CrispControlCoreTests/SelectorTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import CrispControlCore + +final class SelectorTests: XCTestCase { + private let displays = [ + ControlDisplay(uuid: "uuid-a", name: "Studio", isMain: true, isBuiltin: true, + brightness: .writableDisplayServices), + ControlDisplay(uuid: "uuid-b", name: "Desk", isMain: false, isBuiltin: false, + brightness: .writableDDC), + ControlDisplay(uuid: "uuid-c", name: "Desk", isMain: false, isBuiltin: false, + brightness: .unsupported(reason: "No DDC channel")) + ] + + func testStableUUIDAndAliasesResolveExactly() throws { + XCTAssertEqual(try DisplaySelector.resolve("uuid-b", in: displays).uuid, "uuid-b") + XCTAssertEqual(try DisplaySelector.resolve("main", in: displays).uuid, "uuid-a") + XCTAssertEqual(try DisplaySelector.resolve("builtin", in: displays).uuid, "uuid-a") + } + + func testNamesAreCaseInsensitive() throws { + XCTAssertEqual(try DisplaySelector.resolve("studio", in: displays).uuid, "uuid-a") + } + + func testAmbiguousNameReturnsCandidatesWithoutPicking() { + XCTAssertThrowsError(try DisplaySelector.resolve("Desk", in: displays)) { error in + guard case let SelectorError.ambiguous(candidates) = error else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(candidates.map(\.uuid), ["uuid-b", "uuid-c"]) + } + } + + func testMissingAliasDoesNotFallbackToArbitraryDisplay() { + let externalOnly = displays.filter { !$0.isBuiltin } + XCTAssertThrowsError(try DisplaySelector.resolve("builtin", in: externalOnly)) { error in + guard case SelectorError.notFound = error else { return XCTFail("unexpected error: \(error)") } + } + } +} + +private extension BrightnessCapability { + static let writableDisplayServices = BrightnessCapability( + state: .writable, backend: .displayServices, + range: ControlRange(min: 0, max: 100, precision: 0.1), readback: .authoritative + ) + static let writableDDC = BrightnessCapability( + state: .writable, backend: .ddc, + range: ControlRange(min: 0, max: 100, precision: 1), readback: .approximate + ) +} diff --git a/Tests/CrispControlCoreTests/TransportTests.swift b/Tests/CrispControlCoreTests/TransportTests.swift new file mode 100644 index 0000000..b714ac7 --- /dev/null +++ b/Tests/CrispControlCoreTests/TransportTests.swift @@ -0,0 +1,575 @@ +import Darwin +import XCTest +@testable import CrispControlCore + +final class TransportTests: XCTestCase { + private var directory: URL! + private var socketPath: String { directory.appendingPathComponent("control.sock").path } + + override func setUpWithError() throws { + directory = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("crisp-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + func testRoundTripPreservesRequestIDAndSocketIsOwnerOnly() async throws { + let server = UnixSocketServer(path: socketPath) { request in + .success(requestID: request.requestID, result: .object(["command": .string(request.command)])) + } + try server.start() + defer { server.stop() } + + let request = ControlRequest(requestID: "round-trip", command: "status") + let response = try UnixSocketClient(path: socketPath, timeout: 1).send(request) + let attributes = try FileManager.default.attributesOfItem(atPath: socketPath) + + XCTAssertEqual(response.requestID, "round-trip") + XCTAssertEqual(response.result?["command"], .string("status")) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + } + + func testMalformedRequestReturnsStructuredFailure() throws { + let server = UnixSocketServer(path: socketPath) { request in + .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient(path: socketPath, timeout: 1) + .sendRaw(Data("not json\n".utf8)) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, .malformedRequest) + XCTAssertFalse(response.requestID.isEmpty) + } + + func testProtocolMismatchNeverReachesHandler() throws { + let handled = LockedFlag() + let server = UnixSocketServer(path: socketPath) { request in + handled.set() + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient(path: socketPath, timeout: 1).send( + ControlRequest(protocolVersion: 999, requestID: "wrong-version", command: "status") + ) + + XCTAssertEqual(response.requestID, "wrong-version") + XCTAssertEqual(response.error?.code, .protocolMismatch) + XCTAssertFalse(handled.value) + } + + func testClientTimeoutIsObservable() throws { + let server = UnixSocketServer(path: socketPath) { request in + try? await Task.sleep(for: .milliseconds(200)) + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + XCTAssertThrowsError( + try UnixSocketClient(path: socketPath, timeout: 0.02) + .send(ControlRequest(command: "slow")) + ) { error in + XCTAssertEqual(error as? IPCError, .timeout) + } + } + + func testClientRejectsMismatchedResponseRequestID() throws { + let server = UnixSocketServer(path: socketPath) { _ in + .success(requestID: "wrong-request", result: .null) + } + try server.start() + defer { server.stop() } + + XCTAssertThrowsError( + try UnixSocketClient(path: socketPath, timeout: 1) + .send(ControlRequest(requestID: "expected-request", command: "status")) + ) { error in + XCTAssertEqual(error as? IPCError, .responseRequestMismatch) + } + } + + func testClientRejectsUnsupportedResponseProtocolVersion() throws { + let server = UnixSocketServer(path: socketPath) { request in + ControlResponse(protocolVersion: 999, requestID: request.requestID, + ok: true, result: .null, error: nil) + } + try server.start() + defer { server.stop() } + + XCTAssertThrowsError( + try UnixSocketClient(path: socketPath, timeout: 1) + .send(ControlRequest(requestID: "version-check", command: "status")) + ) { error in + XCTAssertEqual(error as? IPCError, .responseProtocolMismatch) + } + } + + func testSilentAndPartialFrameClientsAreDisconnectedAfterServerTimeout() throws { + let server = UnixSocketServer(path: socketPath, connectionTimeout: 0.05) { request in + .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + for payload in [Data(), Data("{\"protocolVersion\":1".utf8)] { + let client = try connectRawClient(to: socketPath) + if !payload.isEmpty { + XCTAssertEqual(payload.withUnsafeBytes { + Darwin.write(client, $0.baseAddress, payload.count) + }, payload.count) + } + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(client, &byte, 1), 0) + Darwin.close(client) + } + } + + func testServerRejectsConnectionsAboveConfiguredBound() throws { + let server = UnixSocketServer( + path: socketPath, connectionTimeout: 1, maximumConnections: 1 + ) { request in + .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let heldClient = try connectRawClient(to: socketPath) + defer { Darwin.close(heldClient) } + let excessClient = try connectRawClient(to: socketPath) + defer { Darwin.close(excessClient) } + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(excessClient, &byte, 1), 0) + } + + func testTrickleClientCannotExtendAbsoluteReceiveDeadline() throws { + let server = UnixSocketServer(path: socketPath, connectionTimeout: 0.08) { request in + .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let client = try connectRawClient(to: socketPath) + defer { Darwin.close(client) } + let started = ContinuousClock.now + var closed = false + for _ in 0..<20 { + usleep(20_000) + var byte: UInt8 = 0x20 + if Darwin.write(client, &byte, 1) <= 0 { + closed = true + break + } + } + + XCTAssertTrue(closed) + XCTAssertLessThan(started.duration(to: .now), .milliseconds(250)) + } + + func testHandlerDeadlineReturnsStructuredTimeout() throws { + let server = UnixSocketServer(path: socketPath, connectionTimeout: 0.05) { request in + try? await Task.sleep(for: .seconds(5)) + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient(path: socketPath, timeout: 1) + .send(ControlRequest(requestID: "handler-timeout", command: "slow")) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.requestID, "handler-timeout") + XCTAssertEqual(response.error?.code, .timeout) + } + + func testBatchGetsLongerHandlerBudgetWithoutWeakeningConnectionDeadline() throws { + let batch = ControlRequest(requestID: "batch-budget", command: "brightness.set-all", + arguments: ["percent": .number(50)]) + let ordinary = ControlRequest(requestID: "ordinary", command: "brightness.get-all") + let settlingWrite = ControlRequest(requestID: "boost", command: "extra-brightness.set", + arguments: ["selector": .string("uuid-a"), "enabled": .bool(true)]) + let displayConnection = ControlRequest( + requestID: "disconnect", + command: "displays.disconnect", + arguments: ["uuid": .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")] + ) + + XCTAssertEqual(ControlTimeoutPolicy.clientTimeout( + for: batch, standard: 3, settlingWrite: 6, batch: 12 + ), 12) + XCTAssertEqual(ControlTimeoutPolicy.handlerTimeout( + for: batch, standard: 2, settlingWrite: 5, batch: 12 + ), 12) + XCTAssertEqual(ControlTimeoutPolicy.clientTimeout( + for: settlingWrite, standard: 3, settlingWrite: 6, batch: 12 + ), 6) + XCTAssertEqual(ControlTimeoutPolicy.handlerTimeout( + for: settlingWrite, standard: 2, settlingWrite: 5, batch: 12 + ), 5) + XCTAssertEqual(ControlTimeoutPolicy.clientTimeout( + for: displayConnection, + standard: 3, + settlingWrite: 6, + batch: 12, + displayConnection: 15 + ), 15) + XCTAssertEqual(ControlTimeoutPolicy.handlerTimeout( + for: displayConnection, + standard: 2, + settlingWrite: 5, + batch: 12, + displayConnection: 13 + ), 13) + XCTAssertEqual(ControlTimeoutPolicy.clientTimeout( + for: ordinary, standard: 3, settlingWrite: 6, batch: 12 + ), 3) + XCTAssertEqual(ControlTimeoutPolicy.handlerTimeout( + for: ordinary, standard: 2, settlingWrite: 5, batch: 12 + ), 2) + XCTAssertEqual(ControlTimeoutPolicy.receiveTimeout(standard: 2), 2) + } + + func testDefaultBatchClientBudgetLeavesTimeToDeliverHandlerResponse() { + XCTAssertGreaterThan( + UnixSocketClient().batchTimeout, + ControlTimeoutPolicy.defaultBatchHandlerTimeout + ) + } + + func testDefaultDisplayConnectionBudgetsCoverMutationSettlementAndResponseMargin() { + XCTAssertGreaterThanOrEqual( + ControlTimeoutPolicy.defaultDisplayConnectionHandlerTimeout, + ControlTimeoutPolicy.displayConfigurationTimeout + + ControlTimeoutPolicy.displayConnectionSettlementTimeout + ) + XCTAssertGreaterThan( + UnixSocketClient().displayConnectionTimeout, + ControlTimeoutPolicy.defaultDisplayConnectionHandlerTimeout + ) + } + + func testBrightnessWriteTimeoutReportsIndeterminateOutcomeBeforeLateCallback() throws { + let lateMutation = LockedFlag() + let server = UnixSocketServer(path: socketPath, connectionTimeout: 0.05) { request in + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.15) { + lateMutation.set() + continuation.resume() + } + } + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient(path: socketPath, timeout: 1).send( + ControlRequest( + requestID: "late-write", + command: "brightness.set", + arguments: ["selector": .string("builtin"), "percent": .number(55)] + ) + ) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(response.error?.details?["selector"], .string("builtin")) + XCTAssertEqual(response.error?.details?["targetPercent"], .number(55)) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(lateMutation.value) + } + + func testEveryServerSideMutationTimeoutIsIndeterminate() throws { + let server = UnixSocketServer( + path: socketPath, + connectionTimeout: 0.02, + settlingWriteHandlerTimeout: 0.02, + batchHandlerTimeout: 0.02, + displayConnectionHandlerTimeout: 0.02 + ) { request in + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.08) { + continuation.resume() + } + } + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + let client = UnixSocketClient( + path: socketPath, + timeout: 1, + settlingWriteTimeout: 1, + batchTimeout: 1, + displayConnectionTimeout: 1 + ) + let cases: [(String, [String: JSONValue])] = [ + ("brightness.set", ["selector": .string("uuid-a"), "percent": .number(150)]), + ("extra-brightness.set", ["selector": .string("uuid-a"), "enabled": .bool(true)]), + ("hdr.set", ["selector": .string("uuid-b"), "enabled": .bool(false)]), + ("brightness.set-all", ["percent": .number(50)]), + ("displays.disconnect", [ + "uuid": .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + ]), + ("displays.reconnect", [ + "uuid": .string("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + ]) + ] + + for (command, arguments) in cases { + let response = try client.send(ControlRequest( + requestID: command, command: command, arguments: arguments + )) + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate, command) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false), command) + XCTAssertEqual(response.error?.details?["command"], .string(command), command) + if command == "displays.disconnect" || command == "displays.reconnect" { + XCTAssertEqual(response.error?.details?["displayUUID"], arguments["uuid"], command) + XCTAssertEqual( + response.error?.details?["requestedConnectionState"], + .string(command == "displays.disconnect" ? "disconnected" : "connected"), + command + ) + XCTAssertEqual(response.error?.code.exitCode, 5, command) + } + } + } + + func testExpiredHandlerSuccessCannotWinWhenTimeoutTaskIsDelayed() async { + let clock = ManualUptimeClock() + let cases: [(String, [String: JSONValue])] = [ + ("brightness.set", ["selector": .string("uuid-a"), "percent": .number(150)]), + ("extra-brightness.set", ["selector": .string("uuid-a"), "enabled": .bool(true)]), + ("hdr.set", ["selector": .string("uuid-b"), "enabled": .bool(false)]), + ("brightness.set-all", ["percent": .number(50)]), + ("displays.disconnect", [ + "uuid": .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + ]), + ("displays.reconnect", [ + "uuid": .string("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + ]) + ] + + for (command, arguments) in cases { + clock.set(0) + let request = ControlRequest( + requestID: command, + command: command, + arguments: arguments + ) + let response = await responseBeforeDeadline( + request: request, + timeout: 1, + monotonicNow: { clock.now } + ) { request in + clock.set(2_000_000_000) + return .success(requestID: request.requestID, result: .null) + } + + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate, command) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false), command) + XCTAssertEqual(response.error?.details?["command"], .string(command), command) + if command == "displays.disconnect" || command == "displays.reconnect" { + XCTAssertEqual( + response.error?.details?["displayUUID"], + arguments["uuid"], + command + ) + XCTAssertEqual( + response.error?.details?["requestedConnectionState"], + .string(command == "displays.disconnect" ? "disconnected" : "connected"), + command + ) + XCTAssertEqual(response.error?.code.exitCode, 5, command) + } + } + } + + func testDisplayConnectionDeadlineRejectsRequestsWithoutExactUUIDBeforeHandler() async { + let handled = LockedFlag() + let invalidRequests = [ + ControlRequest( + requestID: "legacy-selector", + command: "displays.disconnect", + arguments: ["selector": .string("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")] + ), + ControlRequest( + requestID: "name", + command: "displays.disconnect", + arguments: ["uuid": .string("Fixture Display")] + ), + ControlRequest( + requestID: "missing", + command: "displays.reconnect" + ) + ] + + for request in invalidRequests { + let response = await responseBeforeDeadline(request: request, timeout: 0.01) { request in + handled.set() + return .success(requestID: request.requestID, result: .null) + } + + XCTAssertEqual(response.error?.code, .invalidArguments, request.requestID) + XCTAssertEqual(response.error?.details?["phase"], .string("preflight"), request.requestID) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(true), request.requestID) + XCTAssertEqual(response.error?.details?["mutationDispatched"], .bool(false), request.requestID) + } + XCTAssertFalse(handled.value) + } + + func testHDRSettlementBudgetCanReturnVerifiedResponseBeforeServerDeadline() throws { + let server = UnixSocketServer( + path: socketPath, + connectionTimeout: 0.01, + settlingWriteHandlerTimeout: 0.1 + ) { request in + try? await Task.sleep(for: .milliseconds(30)) + return .success(requestID: request.requestID, result: .object(["verification": .string("verified")])) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient( + path: socketPath, timeout: 0.05, settlingWriteTimeout: 0.2 + ).send(ControlRequest( + requestID: "hdr-settle", command: "hdr.set", + arguments: ["selector": .string("uuid-a"), "enabled": .bool(true)] + )) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.result?["verification"], .string("verified")) + } + + func testTimedOutHandlerReleasesConnectionCapacity() throws { + let server = UnixSocketServer( + path: socketPath, connectionTimeout: 0.02, maximumConnections: 1 + ) { request in + if request.requestID == "slow" { try? await Task.sleep(for: .milliseconds(100)) } + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + let client = UnixSocketClient(path: socketPath, timeout: 1) + + let first = try client.send(ControlRequest(requestID: "slow", command: "brightness.get-all")) + let second = try client.send(ControlRequest(requestID: "next", command: "status")) + + XCTAssertEqual(first.error?.code, .timeout) + XCTAssertTrue(second.ok) + } + + func testProductionDefaultTimeoutsDeliverStructuredIndeterminateBrightnessResult() throws { + let server = UnixSocketServer(path: socketPath) { request in + try? await Task.sleep(for: .seconds(5)) + return .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + let response = try UnixSocketClient(path: socketPath).send( + ControlRequest( + requestID: "production-default-timeout", + command: "brightness.set", + arguments: ["selector": .string("builtin"), "percent": .number(55)] + ) + ) + + XCTAssertEqual(response.error?.code, .writeOutcomeIndeterminate) + XCTAssertEqual(response.error?.details?["retrySafe"], .bool(false)) + } + + func testOversizedFrameIsRejectedEvenWhenTerminatedInSameChunk() throws { + let server = UnixSocketServer(path: socketPath) { request in + .success(requestID: request.requestID, result: .null) + } + try server.start() + defer { server.stop() } + + var data = Data(repeating: 0x20, count: 1_048_577) + data.append(0x0A) + XCTAssertThrowsError(try UnixSocketClient(path: socketPath, timeout: 1).sendRaw(data)) + } + + func testStaleSocketIsRecoveredButRegularFileIsNeverDeleted() throws { + try makeStaleSocket(at: socketPath) + let server = UnixSocketServer(path: socketPath) { request in + .success(requestID: request.requestID, result: .null) + } + XCTAssertNoThrow(try server.start()) + server.stop() + + try Data("keep".utf8).write(to: URL(fileURLWithPath: socketPath)) + XCTAssertThrowsError(try server.start()) { error in + XCTAssertEqual(error as? IPCError, .unsafeSocketPath) + } + XCTAssertEqual(try String(contentsOfFile: socketPath, encoding: .utf8), "keep") + } + + private func makeStaleSocket(at path: String) throws { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { bytes in + path.utf8CString.withUnsafeBytes { source in bytes.copyBytes(from: source) } + } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + XCTAssertEqual(result, 0) + } + + private func connectRawClient(to path: String) throws -> Int32 { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw IPCError.ioFailure } + var noSigPipe: Int32 = 1 + _ = setsockopt( + descriptor, SOL_SOCKET, SO_NOSIGPIPE, + &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe)) + ) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { bytes in + path.utf8CString.withUnsafeBytes { source in bytes.copyBytes(from: source) } + } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + Darwin.close(descriptor) + throw IPCError.unavailable + } + return descriptor + } +} + +private final class LockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var stored = false + var value: Bool { lock.withLock { stored } } + func set() { lock.withLock { stored = true } } +} + +private final class ManualUptimeClock: @unchecked Sendable { + private let lock = NSLock() + private var stored: UInt64 = 0 + + var now: UInt64 { lock.withLock { stored } } + + func set(_ value: UInt64) { + lock.withLock { stored = value } + } +} diff --git a/Tests/DistributionTests/fixtures/crisp-legacy.rb b/Tests/DistributionTests/fixtures/crisp-legacy.rb new file mode 100644 index 0000000..a952075 --- /dev/null +++ b/Tests/DistributionTests/fixtures/crisp-legacy.rb @@ -0,0 +1,16 @@ +cask "crisp" do + version "1.5.1" + sha256 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + url "https://github.com/didriksg/Crisp/releases/download/v#{version}/Crisp.dmg" + name "Crisp" + desc "External monitor control for macOS" + homepage "https://crispmac.app/" + + auto_updates true + + # Fixture sentinel: this unrelated comment must survive byte-for-byte. + app "Crisp.app" + + zap trash: "~/Library/Preferences/com.crisp.app.plist" +end diff --git a/Tests/DistributionTests/fixtures/crisp-with-binary.rb b/Tests/DistributionTests/fixtures/crisp-with-binary.rb new file mode 100644 index 0000000..1a009f3 --- /dev/null +++ b/Tests/DistributionTests/fixtures/crisp-with-binary.rb @@ -0,0 +1,20 @@ +cask "crisp" do + version "1.5.1" + sha256 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + url "https://github.com/didriksg/Crisp/releases/download/v#{version}/Crisp.dmg" + name "Crisp" + desc "External monitor control for macOS" + homepage "https://crispmac.app/" + + auto_updates true + + app "Crisp.app" + binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl" + + # Fixture sentinel: preserve this block and its spacing. + zap trash: [ + "~/Library/Caches/com.crisp.app", + "~/Library/Preferences/com.crisp.app.plist", + ] +end diff --git a/Tests/DistributionTests/test_p0_app_wiring.py b/Tests/DistributionTests/test_p0_app_wiring.py new file mode 100644 index 0000000..70f20b4 --- /dev/null +++ b/Tests/DistributionTests/test_p0_app_wiring.py @@ -0,0 +1,989 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +HOST = ROOT / "Crisp" / "Automation" / "CrispControlHost.swift" +BRIGHTNESS = ROOT / "Crisp" / "Services" / "BrightnessService.swift" +BOOST = ROOT / "Crisp" / "Services" / "BrightnessBoostService.swift" +FIXTURE = ROOT / "Sources" / "CrispControlTestHost" / "main.swift" +ROUNDTRIP = ROOT / "scripts" / "test-crispctl-roundtrip.sh" +PHYSICAL_TOGGLE = ROOT / "Crisp" / "Services" / "PhysicalDisplayToggleService.swift" +PHYSICAL_TOGGLE_VIEW = ROOT / "Crisp" / "Views" / "PhysicalDisplayToggleView.swift" +CG_HELPERS = ROOT / "Crisp" / "Services" / "CGHelpers.swift" +CONTROL_CORE = ROOT / "Sources" / "CrispControlCore" +CONTROL_CLI = ROOT / "Sources" / "CrispControlCLI" +PHYSICAL_CLASSIFIER = CONTROL_CORE / "HardwareBackedPhysicalDisplayClassifier.swift" +DISPLAY_RECOVERY = CONTROL_CORE / "DisplayConnectionRecovery.swift" +DISPLAY_COORDINATOR = CONTROL_CORE / "DisplayConnectionCoordinator.swift" +DISPLAY_PERSISTENCE = CONTROL_CORE / "DisplayConnectionPersistence.swift" +DISPLAY_READ_ONLY = CONTROL_CORE / "DisplayConnectionReadOnlyQueries.swift" +COMMAND_DISPATCHER = CONTROL_CORE / "CommandDispatcher.swift" + + +class P0AppWiringTests(unittest.TestCase): + def test_display_connection_control_reuses_physical_toggle_service_only(self): + host = HOST.read_text() + physical = PHYSICAL_TOGGLE.read_text() + helpers = CG_HELPERS.read_text() + + for required in ( + "PhysicalDisplayToggleService.shared", + "func disconnectedDisplays", + "func disconnectDisplay", + "func reconnectDisplay", + "disconnectForControl", + "reconnectForControl", + "connectionCapabilitiesForControl", + ): + self.assertIn(required, host) + self.assertIn("DisplayConnectionMutationCoordinator", physical) + self.assertIn("DisplayConnectionMutationAdapter", physical) + self.assertIn("dispatchConnectionChange", physical) + self.assertIn("runWithTimeoutOutcome", physical) + self.assertIn("case timedOut", helpers) + self.assertIn("case cancelled", helpers) + + control_plane = host + "".join( + path.read_text() for root in (CONTROL_CORE, CONTROL_CLI) for path in root.glob("*.swift") + ) + self.assertNotIn("SLSConfigureDisplayEnabled", control_plane) + self.assertNotIn("CGBeginDisplayConfiguration", control_plane) + self.assertEqual(physical.count("SLSConfigureDisplayEnabled(cfg"), 1) + + def test_display_list_batches_connection_capabilities_before_mapping(self): + host = HOST.read_text() + displays = host[ + host.index("func displays() async throws"): + host.index("func disconnectedDisplays() async throws") + ] + + self.assertEqual(displays.count("connectionCapabilitiesForControl"), 1) + self.assertLess( + displays.index("connectionCapabilitiesForControl"), + displays.index(".map"), + ) + self.assertNotIn("connectionCapabilityForControl(", displays) + self.assertIn("connectionCapabilities[display.displayUUID]", displays) + + def test_explicit_cli_reconnect_delegates_unique_uuid_to_shared_coordinator(self): + dispatcher = COMMAND_DISPATCHER.read_text() + host = HOST.read_text() + reconnect = dispatcher[ + dispatcher.index("private func reconnectDisplay("): + dispatcher.index("private func requireDisconnectAllowed") + ] + app_service = host[ + host.index("func reconnectDisplay(displayUUID:"): + host.index("func readBrightness(displayUUID:") + ] + + self.assertIn("service.disconnectedDisplays()", reconnect) + self.assertIn("matches.count == 1", reconnect) + self.assertIn("service.reconnectDisplay(displayUUID: uuid)", reconnect) + self.assertNotIn("requireReconnectAllowed", reconnect) + self.assertLess( + reconnect.index("matches.count == 1"), + reconnect.index("service.reconnectDisplay(displayUUID: uuid)"), + ) + self.assertIn("reconnectForControl(uuid: displayUUID)", app_service) + + def test_rejected_reconnect_rollback_uses_one_envelope_write_and_no_display_call(self): + physical = PHYSICAL_TOGGLE.read_text() + coordinator = DISPLAY_COORDINATOR.read_text() + persistence = DISPLAY_PERSISTENCE.read_text() + rollback = physical[ + physical.index("func rollbackRejectedReconnectBeforeDispatch"): + physical.index("func reconcileOrphanedReconnectAttempt") + ] + completion = coordinator[ + coordinator.index("private func requireReconnectDispatchCompletion"): + coordinator.index("private func cleanAlreadyOnlineRecoveryState") + ] + + self.assertIn("RejectedReconnectRollback.proposedState", rollback) + self.assertIn( + "let snapshot = try connectionStateSnapshot(synchronizePublished: true)", + rollback, + ) + self.assertLess( + rollback.index("defer { liveReconnectReservationUUIDs.remove(uuid) }"), + rollback.index( + "let snapshot = try connectionStateSnapshot(synchronizePublished: true)" + ), + ) + self.assertIn("persistConnectionState(", rollback) + self.assertNotIn("setEnabled", rollback) + self.assertNotIn("dispatchConnectionChange", rollback) + self.assertEqual(persistence.count("try writeEnvelope(proposedState)"), 1) + self.assertEqual(persistence.count("try writeEnvelope(quarantineState)"), 1) + self.assertIn("normal success path remains one write", persistence) + self.assertNotIn("setEnabled", persistence) + self.assertIn("rollbackRejectedReconnectBeforeDispatch", completion) + rejected = completion[ + completion.index("case let .rejectedBeforeDispatch"): + completion.index("case let .failedAfterDispatch") + ] + self.assertNotIn("releaseReconnectReservation", rejected) + self.assertNotIn("markReconnectIndeterminate", rejected) + + def test_display_connection_fallback_is_persisted_capability_scoped_and_enable_only(self): + physical = PHYSICAL_TOGGLE.read_text() + core = "".join(path.read_text() for path in CONTROL_CORE.glob("*.swift")) + control = physical[ + physical.index("func dispatchConnectionChange"): + physical.index("private func controlAllDisplayIDs") + ] + for required in ( + "DisplayConnectionRecoveryCapability", + "bootSessionID", + "loginSessionID", + "wakeSessionID", + "topologyFingerprint", + "case available", + "case invalidatedByWake", + "case consumed", + "case indeterminate", + ): + self.assertIn(required, core) + self.assertIn("consumeRecoveryCapability", physical) + self.assertIn("case .oneShotRecovery", control) + self.assertIn("requestedState == .connected", control) + self.assertIn("request.displayID", control) + self.assertLess(control.index("case .oneShotRecovery"), control.index("setEnabledOutcome")) + self.assertNotIn("record.displayID", control) + self.assertNotIn("?? record.displayID", control) + self.assertNotIn("SLSGetDisplayForUUID", physical) + self.assertNotIn("CGDisplayGetDisplayIDFromUUID", physical) + + def test_display_connection_requires_positive_hardware_backing_proof(self): + physical = PHYSICAL_TOGGLE.read_text() + host = HOST.read_text() + classifier = PHYSICAL_CLASSIFIER.read_text() + + for required in ( + '@_silgen_name("CGDisplayIOServicePort")', + "IOObjectConformsTo", + '"IODisplayConnect"', + 'IOServiceMatching("IOMobileFramebuffer")', + '"EDID UUID"', + '"DisplayAttributes"', + '"ProductAttributes"', + '"LegacyManufacturerID"', + '"ProductID"', + '"SerialNumber"', + "CGDisplayVendorNumber", + "CGDisplayModelNumber", + "CGDisplaySerialNumber", + "HardwareBackedPhysicalDisplayEvidence", + "HardwareDisplayIdentity", + "HardwareFramebufferIdentityEvidence", + "func isHardwareBackedPhysicalDisplay", + ): + self.assertIn(required, physical) + self.assertNotIn("ddc", classifier.lower()) + self.assertIn("HardwareFramebufferIdentityMatcher.hasUniqueExactMatch", classifier) + self.assertIn("framebufferSnapshot.filter(\\.hasEDIDUUID)", classifier) + self.assertIn("candidates.allSatisfy", classifier) + self.assertIn("coreGraphicsIdentity:", physical) + self.assertIn("framebufferSnapshot:", physical) + self.assertNotIn("hasUniqueFramebufferIdentityMatch: true", physical) + self.assertNotIn("hasEDIDUUID: true", physical) + + snapshot = physical[ + physical.index("private func framebufferSnapshotForPhysicalProof"): + physical.index("private func uint32PhysicalProofValue") + ] + self.assertIn("while service != 0", snapshot) + self.assertIn( + "snapshot.append(framebufferIdentityEvidenceForPhysicalProof(service))", + snapshot, + ) + self.assertIn('"EDID UUID" as CFString', snapshot) + self.assertIn("hasEDIDUUID: edidUUID?.isEmpty == false", snapshot) + self.assertIn("identity: framebufferIdentityForPhysicalProof(service)", snapshot) + self.assertNotIn("guard let identity else", snapshot) + enumeration_loop = snapshot[ + snapshot.index("while service != 0"): + snapshot.index("return snapshot") + ] + self.assertNotIn("return nil", enumeration_loop) + + capability = physical[ + physical.index("func connectionCapabilitiesForControl"): + physical.index("func disconnectedDisplaysForControl") + ] + self.assertIn("unsupportedConnectionCapability", capability) + self.assertIn("hardwareBackingEvidence", capability) + self.assertNotIn("guard !isVirtual", capability) + + observation = physical[ + physical.index("func connectionObservation()"): + physical.index("func retainDisconnectedRecord") + ] + self.assertIn("hardwareBackedPhysicalUUIDs", observation) + self.assertIn("unsafePhysicalMutationUUIDs", observation) + self.assertIn("virtualUUIDs: unsafePhysicalMutationUUIDs", observation) + self.assertIn("connectionCandidate", observation) + self.assertIn("candidate.isHardwareBackedPhysical", observation) + + active_count = physical[ + physical.index("private func physicalActiveDisplayCount"): + physical.index("private func uuid(for") + ] + self.assertIn("isHardwareBackedPhysicalDisplay", active_count) + self.assertNotIn("!virtual.isVirtualDisplay", active_count) + + control_disconnect = host[ + host.index("func disconnectDisplay(displayUUID:"): + host.index("func reconnectDisplay(displayUUID:") + ] + self.assertIn("disconnectForControl(display)", control_disconnect) + self.assertNotIn("VirtualDisplayService", control_disconnect) + + def test_existing_reconnect_recovery_drops_record_only_after_uuid_online_truth(self): + physical = PHYSICAL_TOGGLE.read_text() + reconnect = physical[ + physical.index("func reconnect(uuid:"): + physical.index("/// Soft-reconnects a display") + ] + self.assertIn("reconnectForControl(uuid: uuid)", reconnect) + self.assertIn("DisplayConnectionMutationError", reconnect) + self.assertNotIn("setEnabled", reconnect) + self.assertNotIn("resolveUniqueCurrentID", reconnect) + self.assertNotIn("disconnected.removeAll", reconnect) + + def test_gui_disconnect_requires_fresh_unique_exact_uuid_before_state_or_dispatch(self): + physical = PHYSICAL_TOGGLE.read_text() + disconnect = physical[ + physical.index("func disconnect(_ display:"): + physical.index("func reconnect(uuid:") + ] + control = physical[ + physical.index("func disconnectForControl"): + physical.index("func reconnectForControl") + ] + + self.assertIn("disconnectForControl(display)", disconnect) + self.assertIn("DisplayConnectionMutationError", disconnect) + self.assertNotIn("setEnabled", disconnect) + self.assertIn("displayID: display.displayID", control) + self.assertIn("DisplayConnectionMutationCoordinator", control) + + def test_gui_reconnect_rejects_non_exact_and_uses_stable_only_resolver(self): + physical = PHYSICAL_TOGGLE.read_text() + view = PHYSICAL_TOGGLE_VIEW.read_text() + reconnect = physical[ + physical.index("func reconnect(uuid:"): + physical.index("/// Soft-reconnects a display") + ] + + self.assertIn("reconnectForControl(uuid: uuid)", reconnect) + self.assertNotIn("setEnabled", reconnect) + self.assertIn("if case .failure(let error) = result", view) + self.assertIn("errorMessage", view) + self.assertNotIn("_ = await service.reconnect", view) + + def test_active_physical_enumeration_failure_cannot_authorize_recovery(self): + physical = PHYSICAL_TOGGLE.read_text() + active_count = physical[ + physical.index("private func physicalActiveDisplayCount"): + physical.index("private func hardwareBackingEvidence") + ] + would_leave = physical[ + physical.index("func wouldLeaveNoActiveDisplay"): + physical.index("/// All display IDs known") + ] + restore = physical[ + physical.index("func restoreIfNoActiveDisplay()"): + physical.index("func reapplyOnWake()") + ] + + self.assertIn("private func physicalActiveDisplayCount() -> Int?", active_count) + self.assertGreaterEqual(active_count.count("return nil"), 2) + self.assertIn("return 0", active_count) + self.assertIn("PhysicalDisplaySafetyPolicy.shouldRefuseDisconnect", would_leave) + self.assertNotIn("?? 0", would_leave) + self.assertNotIn("setEnabled", restore) + self.assertNotIn("physicalActiveDisplayCount() == 0", restore) + + def test_gui_reconnect_reproves_hardware_and_wake_invalidates_recovery(self): + physical = PHYSICAL_TOGGLE.read_text() + dispatch = physical[ + physical.index("func dispatchConnectionChange"): + physical.index("private func controlAllDisplayIDs") + ] + wake = physical[ + physical.index("func reapplyOnWake"): + physical.index("// MARK: - Persistence") + ] + + self.assertIn("DisplayConnectionRecoveryResolver", dispatch) + self.assertIn("authorizesExactDisconnect", dispatch) + self.assertIn("authorizesConsumedRecoveryDispatch", dispatch) + self.assertLess(dispatch.index("DisplayConnectionRecoveryResolver"), + dispatch.index("setEnabledOutcome")) + + self.assertIn( + "let snapshot = try? connectionStateSnapshot(synchronizePublished: true)", + wake, + ) + self.assertIn("changingState(to: .invalidatedByWake)", wake) + self.assertIn("quarantiningUUIDs: affectedUUIDs", wake) + self.assertNotIn("markRecoveryCapabilityIndeterminate", wake) + self.assertNotIn("pendingControlDisconnectUUIDs", wake) + self.assertNotIn("setEnabledOutcome", wake) + self.assertNotIn("SLSConfigureDisplayEnabled", wake) + + def test_wake_continuity_uses_public_mach_sleep_offset_clock(self): + physical = PHYSICAL_TOGGLE.read_text() + wake_token = physical[ + physical.index("private func currentWakeSessionID"): + physical.index("private func sysctlString") + ] + + self.assertEqual(wake_token.count("mach_continuous_time()"), 2) + self.assertIn("mach_absolute_time()", wake_token) + self.assertIn("mach_timebase_info", wake_token) + self.assertIn("DisplayConnectionMachSleepOffsetToken.make", wake_token) + self.assertNotIn("kern.waketime", physical) + self.assertNotIn("IOPMGetLastWakeTime", physical) + + def test_read_only_refresh_never_auto_enables_a_recovery_record(self): + physical = PHYSICAL_TOGGLE.read_text() + restore = physical[ + physical.index("func restoreIfNoActiveDisplay()"): + physical.index("func reapplyOnWake") + ] + self.assertNotIn("setEnabled", restore) + self.assertNotIn("record.displayID", restore) + self.assertNotIn("recoverAnyViewableDisplayEmergencyOnly", physical) + self.assertNotIn("?? record.displayID", physical) + + def test_orphan_reconnect_reconciliation_is_explicit_readback_only_and_process_owned(self): + physical = PHYSICAL_TOGGLE.read_text() + coordinator = DISPLAY_COORDINATOR.read_text() + view = PHYSICAL_TOGGLE_VIEW.read_text() + + adapter = physical[ + physical.index("func reserveReconnect"): + physical.index("func dispatchConnectionChange") + ] + orphan = adapter[ + adapter.index("func reconcileOrphanedReconnectAttempt"): + adapter.index("private func changeRecoveryCapabilityState") + ] + explicit_reconnect = coordinator[ + coordinator.index("public func reconnect(uuid:"): + coordinator.index("private func reconnectDispatchPlan") + ] + for required in ( + "liveReconnectReservationUUIDs", + "DisplayConnectionRecoveryResolver.orphanedReconnectResolution", + "connectionObservation()", + "nextReservations.remove(uuid)", + "persistConnectionState", + ): + self.assertIn(required, physical) + self.assertIn("reconcileOrphanedReconnectAttempt", explicit_reconnect) + self.assertIn("fresh explicit user decision", explicit_reconnect) + self.assertNotIn("dispatchConnectionChange", orphan) + self.assertNotIn("setEnabled", orphan) + self.assertLess(orphan.index("persistConnectionState"), orphan.index("return .reconciled")) + + read_only_sections = ( + physical[ + physical.index("func disconnectedDisplaysForControl"): + physical.index("func disconnectForControl") + ], + physical[ + physical.index("func reconcile()"): + physical.index("func recoverStrandedSoftReconnect") + ], + physical[physical.index("private func loadDesired"):], + ) + for section in read_only_sections: + self.assertNotIn("reconcileOrphanedReconnectAttempt", section) + self.assertNotIn("dispatchConnectionChange", section) + + gui_reconnect = view[ + view.index("private func reconnect("): + view.index("private struct DisconnectedDisplayRow") + ] + self.assertNotIn("Task.sleep", gui_reconnect) + self.assertNotIn("errorMessages[record.uuid] = nil", gui_reconnect.split("Task {", 1)[1]) + + def test_quarantine_reconciliation_is_explicit_readback_only_and_precedes_dispatch(self): + physical = PHYSICAL_TOGGLE.read_text() + coordinator = DISPLAY_COORDINATOR.read_text() + persistence = DISPLAY_PERSISTENCE.read_text() + reconnect = coordinator[ + coordinator.index("public func reconnect(uuid:"): + coordinator.index("private func reconcileQuarantineIfNeeded") + ] + quarantine_flow = coordinator[ + coordinator.index("private func reconcileQuarantineIfNeeded"): + coordinator.index("private func reconnectDispatchPlan") + ] + quarantine = physical[ + physical.index("func reconcileQuarantinedReconnectAttempt"): + physical.index("private func changeRecoveryCapabilityState") + ] + + self.assertLess( + reconnect.index("reconcileQuarantineIfNeeded"), + reconnect.index("DisplayConnectionRecoveryResolver.reconnectResolution"), + ) + self.assertLess( + reconnect.index("reconcileQuarantineIfNeeded"), + reconnect.index("reserveReconnect"), + ) + self.assertLess( + reconnect.index("reconcileQuarantineIfNeeded"), + reconnect.index("dispatchConnectionChange"), + ) + self.assertIn("reconnectPersistenceUncertainUUIDs.contains(uuid)", quarantine_flow) + self.assertIn("try await adapter.reconcileQuarantinedReconnectAttempt", quarantine_flow) + self.assertIn("finishQuarantinedReconnectAttempt", quarantine_flow) + self.assertIn("mutationDispatched: false", quarantine_flow) + self.assertIn("explicit user decision is required", quarantine_flow) + already_online = quarantine_flow[ + quarantine_flow.index("case .alreadyOnline:"): + quarantine_flow.index("case .liveAttempt:") + ] + self.assertIn("adapter.connectionObservation()", already_online) + self.assertIn( + "DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate", + already_online, + ) + self.assertIn("recoveryStateIsAbsent", already_online) + self.assertGreaterEqual(already_online.count("try Task.checkCancellation()"), 2) + self.assertLess( + already_online.index("try Task.checkCancellation()"), + already_online.index("adapter.connectionObservation()"), + ) + self.assertLess( + already_online.index("recoveryStateIsAbsent"), + already_online.rindex("try Task.checkCancellation()"), + ) + self.assertLess( + already_online.rindex("try Task.checkCancellation()"), + already_online.index("return success"), + ) + self.assertLess( + already_online.index("adapter.connectionObservation()"), + already_online.index("return success"), + ) + self.assertLess( + already_online.index( + "DisplayConnectionRecoveryResolver.uniqueOnlineHardwareCandidate" + ), + already_online.index("return success"), + ) + + self.assertEqual(quarantine.count("connectionStateSnapshot("), 1) + self.assertEqual(quarantine.count("connectionObservation("), 1) + self.assertIn("liveQuarantineReconciliationUUIDs", quarantine) + self.assertIn("snapshot.authority == .durable", quarantine) + self.assertIn("connectionPersistence.reconcileQuarantinedReconnect", quarantine) + self.assertIn("result.writeResult.disposition == .committedProposed", quarantine) + self.assertNotIn("dispatchConnectionChange", quarantine) + self.assertNotIn("setEnabled", quarantine) + self.assertNotIn("SLSConfigureDisplayEnabled", quarantine) + + authority = persistence[ + persistence.index("public var authorizesConnectionMutation"): + persistence.index("public enum ConnectionPersistenceDisposition") + ] + self.assertIn( + "authority == .durable && envelope.reconnectPersistenceUncertainSet.isEmpty", + authority, + ) + + for section in ( + physical[ + physical.index("func connectionCapabilitiesForControl"): + physical.index("func disconnectForControl") + ], + physical[ + physical.index("func reconcile()"): + physical.index("func recoverStrandedSoftReconnect") + ], + physical[ + physical.index("func reapplyOnWake"): + physical.index("// MARK: - Persistence") + ], + ): + self.assertNotIn("reconcileQuarantinedReconnectAttempt", section) + self.assertNotIn("dispatchConnectionChange", section) + + def test_shared_disconnect_retains_recovery_state_for_indeterminate_windowserver_call(self): + physical = PHYSICAL_TOGGLE.read_text() + adapter = physical[ + physical.index("func retainDisconnectedRecord"): + physical.index("private func controlAllDisplayIDs") + ] + gui_disconnect = physical[ + physical.index("func disconnect(_ display:"): + physical.index("func reconnect(uuid:") + ] + self.assertIn("persistConnectionState", adapter) + self.assertIn("markRecoveryCapabilityIndeterminate", adapter) + self.assertIn("disconnectForControl(display)", gui_disconnect) + self.assertLess( + adapter.index("persistConnectionState"), + adapter.index("func dispatchConnectionChange"), + ) + + def test_indeterminate_disconnect_keeps_uuid_scoped_recovery_record(self): + physical = PHYSICAL_TOGGLE.read_text() + self.assertIn("controlPendingDisconnectUUIDs", physical) + self.assertIn("currentState.pendingSet", physical) + self.assertIn("func confirmDisconnectedRecord", physical) + + inventory = physical[ + physical.index("func disconnectedDisplaysForControl"): + physical.index("func disconnectForControl") + ] + self.assertIn("func disconnectedDisplaysForControl() throws", inventory) + self.assertIn("DisplayConnectionReadOnlyQueries.disconnectedDisplays", inventory) + self.assertNotIn("confirmDisconnectedRecord", inventory) + self.assertNotIn("removeDisconnectedRecord", inventory) + self.assertNotIn("persistConnectionState", inventory) + + reconcile = physical[ + physical.index("func reconcile()"): + physical.index("func recoverStrandedSoftReconnect") + ] + self.assertIn("connectionObservation", reconcile) + self.assertIn("reconcileTopologyMetadata", reconcile) + self.assertNotIn("setEnabled", reconcile) + + wake = physical[ + physical.index("func reapplyOnWake"): + physical.index("// MARK: - Persistence") + ] + self.assertIn("changingState(to: .invalidatedByWake)", wake) + self.assertNotIn("markRecoveryCapabilityIndeterminate", wake) + self.assertNotIn("pendingControlDisconnectUUIDs", wake) + self.assertNotIn("setEnabledOutcome", wake) + + mark_indeterminate = physical[ + physical.index("func markRecoveryCapabilityIndeterminate"): + physical.index("func removeDisconnectedRecord") + ] + self.assertIn("currentState.pendingSet", mark_indeterminate) + self.assertIn("persistConnectionState", mark_indeterminate) + + capability = physical[ + physical.index("func connectionCapabilitiesForControl"): + physical.index("func disconnectedDisplaysForControl") + ] + self.assertIn("DisplayConnectionReadOnlyQueries.connectedCapabilities", capability) + self.assertNotIn("removeDisconnectedRecord", capability) + self.assertNotIn("persistConnectionState", capability) + self.assertIn("disconnect outcome is indeterminate", DISPLAY_READ_ONLY.read_text()) + + def test_old_records_have_no_fallback_authority_but_online_truth_can_clean_them(self): + physical = PHYSICAL_TOGGLE.read_text() + recovery = DISPLAY_RECOVERY.read_text() + record = recovery[ + recovery.index("struct DisplayConnectionPersistedRecord"): + recovery.index("struct DisplayConnectionPersistenceEnvelope") + ] + reconcile = physical[ + physical.index("func reconcile()"): + physical.index("func recoverStrandedSoftReconnect") + ] + + self.assertIn("recoveryCapability", record) + self.assertIn("DisplayConnectionRecoveryCapability?", record) + self.assertIn( + "typealias DisconnectedDisplay = DisplayConnectionPersistedRecord", + physical, + ) + self.assertIn("connectionObservation", reconcile) + self.assertIn("reconcileTopologyMetadata", reconcile) + self.assertNotIn("setEnabled", reconcile) + + def test_recovery_record_and_pending_marker_use_one_authoritative_envelope(self): + physical = PHYSICAL_TOGGLE.read_text() + recovery = DISPLAY_RECOVERY.read_text() + self.assertIn("struct DisplayConnectionPersistenceEnvelope", recovery) + self.assertIn( + "typealias PersistedConnectionState = DisplayConnectionPersistenceEnvelope", + physical, + ) + self.assertIn("connectionRecoveryStateKey", physical) + persistence = physical[ + physical.index("func persistConnectionState"): + physical.index("func pendingSoftReconnectUUIDs") + ] + self.assertIn("connectionPersistence.replace", persistence) + self.assertIn("oldState: oldSnapshot.envelope", persistence) + self.assertIn("result.disposition == .committedProposed", persistence) + boundary = DISPLAY_PERSISTENCE.read_text() + self.assertIn("func snapshot()", boundary) + self.assertIn("func replace(", boundary) + self.assertIn("decodeOneRead()", boundary) + + remove = physical[ + physical.index("func removeDisconnectedRecord"): + physical.index("func dispatchConnectionChange") + ] + self.assertIn("persistConnectionState", remove) + self.assertNotIn("persistDisconnectedForControl", remove) + self.assertNotIn("persistPendingControlDisconnectUUIDs", remove) + + def test_automation_host_wires_p0_to_existing_app_services(self): + text = HOST.read_text() + for required in ( + "func readBrightnessState", + "func setExtraBrightness", + "func setHDR", + "BrightnessBoostService.shared.isEligible", + "BrightnessBoostService.shared.isEnabled", + "boost.setEnabled", + "BrightnessBoostService.shared.isEligibleForHDRToggle", + "BrightnessBoostService.shared.isHDREnabled", + "BrightnessBoostService.shared.setHDRPreference", + "boost.controlHeadroomSnapshot", + "VirtualDisplayService.shared.isVirtualDisplay", + ): + self.assertIn(required, text) + + def test_extra_brightness_control_does_not_overload_boolean_disable_outcome(self): + host = HOST.read_text() + boost = BOOST.read_text() + setter = host[host.index("func setExtraBrightness"):host.index("func setHDR")] + self.assertIn("boost.setEnabledForControl", setter) + self.assertIn("mutationOutcome.resolvedControlResult", setter) + self.assertNotIn("guard try await boost.setEnabled", setter) + self.assertIn("func setEnabledForControl", boost) + self.assertIn("ExtraBrightnessControlMutationOutcome.classify", boost) + + def test_control_brightness_above_100_uses_gui_path_and_preserves_logical_state(self): + text = BRIGHTNESS.read_text() + control_write = text[text.index("func writeBrightnessForControl"):] + self.assertIn("await setBrightness(percent, for: display)", control_write) + self.assertNotIn("min(100, percent)", control_write) + control_read = text[text.index("func readBrightnessStateForControl"):] + self.assertIn("display.brightness > 100", control_read) + self.assertIn("getInternalBrightness()", control_read) + self.assertIn("controlBackend(for: display) == .ddc", control_read) + self.assertIn("readExternalDDCBrightnessForControl", control_read) + self.assertIn("hardwareReadbackPercent: hardware", control_read) + + def test_headroom_visibility_is_narrow_and_read_only(self): + text = BOOST.read_text() + self.assertIn("func controlHeadroomSnapshot", text) + self.assertIn("EDRHeadroomSnapshot", text) + self.assertIn("appliedFactorCommits", text) + self.assertIn("appliedFactor:", text) + + def test_external_applied_factor_is_published_only_after_queue_commit(self): + boost = BOOST.read_text() + brightness = BRIGHTNESS.read_text() + queued = brightness[brightness.index("func setBoostFactor("):] + self.assertIn("completion: @escaping @Sendable (Bool) -> Void", queued) + self.assertLess( + queued.index("setSoftwareBrightness"), + queued.index("completion(true)"), + ) + self.assertIn("completion(false)", queued) + self.assertIn("private func queueExternalFactor", boost) + self.assertIn("appliedFactorCommits.begin", boost) + self.assertIn("appliedFactorCommits.complete", boost) + collapse = boost[boost.index("private func collapseAndDisable"):boost.index("private func finishDisable")] + sync = boost[boost.index("func syncOverlay"):boost.index("// MARK: - Lifecycle")] + reapply = boost[boost.index("func reapplyAll"):boost.index("/// Quit:")] + self.assertIn("queueExternalFactor", collapse) + self.assertIn("queueExternalFactor", sync) + self.assertIn("queueExternalFactor", reapply) + + def test_external_boost_queue_rechecks_uuid_before_transfer_table_write(self): + boost = BOOST.read_text() + brightness = BRIGHTNESS.read_text() + self.assertIn("private func queueExternalFactor", boost) + self.assertGreaterEqual(boost.count("queueExternalFactor("), 4) + helper = brightness[brightness.index("func setBoostFactor("):] + self.assertIn("expectedDisplayUUID: String", helper) + self.assertIn( + "Self.displayUUIDString(for: displayID) == expectedDisplayUUID", + helper, + ) + terminal = brightness[brightness.index("func setBoostFactorForControl"):] + self.assertIn("expectedDisplayUUID: String", terminal) + self.assertIn( + "Self.displayUUIDString(for: displayID) == expectedDisplayUUID", + terminal, + ) + + def test_external_hdr_live_state_remains_readable_when_toggle_is_not_writable(self): + boost = BOOST.read_text() + host = HOST.read_text() + self.assertIn("func controlHDRState", boost) + self.assertIn("func readState", boost) + self.assertIn("monitorPanel.readState", boost) + self.assertIn("guard let liveState = boost.controlHDRState(for: display)", host) + self.assertIn("state: .readable", host) + self.assertIn("enabled: liveState", host) + + def test_extra_brightness_wires_generation_guard_and_awaitable_terminal_disable(self): + text = BOOST.read_text() + for required in ( + "BoostTransitionCoordinator", + "boostTransitions.begin", + "await collapseAndDisable", + "boostTransitions.completeDisable", + "currentDisplayMatches", + "headroomMaySync", + "private func restoreIdentityFactor", + "await BrightnessService.shared.setBoostFactorForControl(", + ): + self.assertIn(required, text) + self.assertGreaterEqual(text.count("await restoreIdentityFactor(for: display)"), 2) + brightness = BRIGHTNESS.read_text() + self.assertIn("func setBoostFactorForControl", brightness) + terminal = brightness[brightness.index("func setBoostFactorForControl"):] + self.assertIn("withCheckedContinuation", terminal) + self.assertLess( + terminal.index("setSoftwareBrightness"), + terminal.index("continuation.resume(returning: true)"), + ) + reconfigure = text[text.index("@objc private func screenParametersChanged"):] + self.assertIn("boostTransitions = BoostTransitionCoordinator()", reconfigure) + self.assertIn("hdrMutations = HDRMutationCoordinator()", reconfigure) + self.assertIn("maxAnimators.values.forEach", reconfigure) + self.assertIn("headroomPollTask?.cancel()", reconfigure) + self.assertIn("headroomPollTask = nil", reconfigure) + self.assertIn("headroomLossSince.removeAll()", reconfigure) + self.assertIn("activeBoostDisplays.removeAll()", reconfigure) + self.assertIn("fastPollUntil = nil", reconfigure) + + def test_external_boost_waits_for_edr_headroom_after_hdr_readback(self): + text = BOOST.read_text() + enable = text[text.index("func setEnabled"):text.index("private func collapseAndDisable")] + self.assertIn("EDRHeadroomSettlement.wait", enable) + self.assertIn("transitionAccepts(token, display: display)", enable) + self.assertIn("controlHDRState(for: display) == true", enable) + self.assertIn("case let .ready(potentialHeadroom)", enable) + self.assertIn("case .timedOut, .capabilityLost", enable) + self.assertIn("case .invalidated", enable) + self.assertIn("setHDRMode(false, for: display, requiring: token)", enable) + self.assertIn("requiring boostToken: BoostTransitionToken? = nil", text) + self.assertIn("guard transitionAccepts(boostToken, display: display)", text) + self.assertLess(enable.index("await setHDRMode(true"), enable.index("EDRHeadroomSettlement.wait")) + self.assertLess(enable.index("EDRHeadroomSettlement.wait"), enable.index("sliderMax")) + + def test_command_owned_settlement_does_not_swallow_cancellation(self): + host = HOST.read_text() + extra = host[host.index("func setExtraBrightness"):host.index("func setHDR")] + hdr = host[host.index("func setHDR"):host.index("private func connectedDisplay")] + for settle in (extra, hdr): + self.assertNotIn("try? await Task.sleep", settle) + self.assertIn("try await Task.sleep", settle) + + boost = BOOST.read_text() + enable = boost[boost.index("func setEnabled"):boost.index("private func collapseAndDisable")] + self.assertIn("try await EDRHeadroomSettlement.wait", enable) + self.assertIn("try await Task.sleep", enable) + self.assertNotIn("try? await Task.sleep", enable) + self.assertNotIn("!Task.isCancelled", enable) + + def test_extra_brightness_off_can_run_cleanup_after_eligibility_loss(self): + host = HOST.read_text() + setter = host[host.index("func setExtraBrightness"):host.index("func setHDR")] + self.assertIn("needsDisableCleanup(for: display)", setter) + self.assertIn("!enabled", setter) + self.assertIn("isEligible(display)", setter) + + boost = BOOST.read_text() + cleanup = boost[ + boost.index("func needsDisableCleanup"):boost.index("// MARK: - Toggle") + ] + for required in ( + "isEnabled(for: display)", + "display.maxBrightness > 100", + "display.brightness > 100", + "activeBoostDisplays.contains", + "collapsingDisplays.contains", + ): + self.assertIn(required, cleanup) + + def test_monitor_panel_access_is_runtime_checked_and_routes_only_after_verified_readback(self): + text = BOOST.read_text() + self.assertNotIn('value(forKey: "preferHDRModes")', text) + self.assertNotIn('value(forKey: "hasHDRModes")', text) + self.assertNotIn('value(forKey: "displayID")', text) + self.assertNotIn("methodSignature(for:", text) + for required in ( + "responds(to: selector)", + "HDRMutationCoordinator", + "verifiedRoutingState", + "let dimmed = controlHDRState(for: display) == true", + "monitorPanelIdentity", + "expectedIdentity:", + "class_getInstanceMethod", + "method_getNumberOfArguments", + "method_copyReturnType", + "method_copyArgumentType", + "method_getImplementation", + "MonitorPanelABISignatureValidator.isCompatible", + ".displaysGetter", + ".displayIDGetter", + ".boolGetter", + ".boolSetter", + ): + self.assertIn(required, text) + + def test_hdr_off_requires_terminal_boost_identity_before_monitor_panel_setter(self): + text = BOOST.read_text() + hdr = text[text.index("func setHDRPreference"):text.index("private var hdrSupportCache")] + self.assertIn("let hadBoostState =", hdr) + self.assertIn("guard try await setEnabled(false, for: display) else { return false }", hdr) + for required in ( + "currentDisplayMatches(display)", + "!collapsingDisplays.contains(displayID)", + "!isEnabled(for: display)", + "display.brightness <= 100.001", + "display.maxBrightness <= 100.001", + "appliedFactorCommits.isCommitted(", + "factor: 1", + ): + self.assertIn(required, hdr) + self.assertLess(hdr.index("appliedFactorCommits.isCommitted("), hdr.rindex("setHDRMode(false")) + + def test_independent_process_fixture_covers_all_p0_commands_without_hardware(self): + fixture = FIXTURE.read_text() + script = ROUNDTRIP.read_text() + for command in ( + "displays disconnected", + "displays reconnect", + "extra-brightness get", + "extra-brightness set", + "hdr get", + "hdr set", + "brightness get-all", + "brightness set-all", + ): + self.assertIn(command, script) + for wiring in ( + "func setExtraBrightness", + "func setHDR", + "func disconnectedDisplays", + "func disconnectDisplay", + "func reconnectDisplay", + "BrightnessReadSnapshot", + "Fixture External", + "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + ): + self.assertIn(wiring, fixture) + self.assertNotIn("DisplayServicesSetBrightness", fixture) + self.assertNotIn("DDCService", fixture) + self.assertNotIn("SLSConfigureDisplayEnabled", fixture) + self.assertNotIn("displays disconnect fixture-external", script) + self.assertIn('EXTERNAL_UUID="$(jq -er', script) + self.assertIn( + "select(.isBuiltin == false and .isVirtual == false)", + script, + ) + self.assertIn('displays disconnect "$EXTERNAL_UUID"', script) + self.assertLess(script.index('LIST="$('), script.index('EXTERNAL_UUID="$(jq -er')) + self.assertLess( + script.index('EXTERNAL_UUID="$(jq -er'), + script.index('displays disconnect "$EXTERNAL_UUID"'), + ) + self.assertIn('DISCONNECTED_UUID="$(jq -r', script) + self.assertIn('[ "$DISCONNECTED_UUID" = "$EXTERNAL_UUID" ]', script) + self.assertIn(".result.displays == []", script) + self.assertIn("requestedConnectionState == \"disconnected\"", script) + self.assertIn("requestedConnectionState == \"connected\"", script) + + def test_headless_roundtrip_uses_swiftpm_reported_bin_path(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + tools = root / "tools" + bin_path = root / "scratch" / "build" / "arm64-apple-macosx" / "debug" + tools.mkdir() + swift_log = root / "swift.log" + cli_log = root / "cli.log" + + def write_executable(name, source): + path = tools / name + path.write_text(source) + path.chmod(0o755) + + write_executable( + "swift", + "#!/bin/bash\n" + "set -eu\n" + 'printf \'%s\\n\' "$*" >> "$SWIFT_LOG"\n' + 'if [[ " $* " == *" --show-bin-path "* ]]; then\n' + ' printf \'%s\\n\' "$FAKE_BIN_PATH"\n' + " exit 0\n" + "fi\n" + 'mkdir -p "$FAKE_BIN_PATH"\n' + 'cat > "$FAKE_BIN_PATH/crisp-control-test-host" <<\'HOST\'\n' + "#!/bin/bash\n" + "exec python3 - \"$1\" <<'PY'\n" + "import socket\n" + "import signal\n" + "import sys\n" + "import time\n" + "signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n" + "sock = socket.socket(socket.AF_UNIX)\n" + "sock.bind(sys.argv[1])\n" + "sock.listen()\n" + "time.sleep(30)\n" + "PY\n" + "HOST\n" + 'cat > "$FAKE_BIN_PATH/crispctl" <<\'CLI\'\n' + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$CLI_LOG"\n' + "printf '{}\\n'\n" + 'case " $* " in\n' + ' *" brightness set-all "*" --allow-unrestorable "*) ;;\n' + ' *" brightness set-all "*) exit 4 ;;\n' + "esac\n" + "CLI\n" + 'chmod 755 "$FAKE_BIN_PATH/crisp-control-test-host" "$FAKE_BIN_PATH/crispctl"\n', + ) + write_executable("jq", "#!/bin/sh\nexit 0\n") + write_executable("stat", "#!/bin/sh\nprintf '600\\n'\n") + environment = os.environ.copy() + environment.update( + { + "PATH": f"{tools}:{environment['PATH']}", + "FAKE_BIN_PATH": str(bin_path), + "SWIFT_LOG": str(swift_log), + "CLI_LOG": str(cli_log), + } + ) + + result = subprocess.run( + ["/bin/bash", str(ROUNDTRIP), str(root / "scratch")], + cwd=ROOT, + env=environment, + text=True, + capture_output=True, + timeout=45, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("CRISPCTL_HEADLESS_ROUNDTRIP_OK", result.stdout) + self.assertEqual( + swift_log.read_text().splitlines(), + [ + f"build --disable-sandbox --scratch-path {root / 'scratch'}", + f"build --disable-sandbox --scratch-path {root / 'scratch'} --show-bin-path", + ], + ) + self.assertGreaterEqual(len(cli_log.read_text().splitlines()), 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/DistributionTests/test_release_distribution.py b/Tests/DistributionTests/test_release_distribution.py new file mode 100644 index 0000000..a627181 --- /dev/null +++ b/Tests/DistributionTests/test_release_distribution.py @@ -0,0 +1,700 @@ +import base64 +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +RELEASE = ROOT / "scripts" / "release.sh" +CRISPCTL_HELPER = ROOT / "scripts" / "build-embed-sign-crispctl.sh" +VERIFIER = ROOT / "scripts" / "verify-release-app.sh" + + +class ReleaseCrispctlHelperTests(unittest.TestCase): + def make_fixture(self, archs="arm64 x86_64"): + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + repo = root / "repo" + app = root / "Crisp.app" + scratch = root / "scratch" + swift_bin = root / "swift-bin" + tools = root / "bin" + for directory in (repo, swift_bin, tools): + directory.mkdir() + + xcrun = tools / "xcrun" + xcrun.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$XCRUN_LOG"\n' + 'case " $* " in\n' + ' *" --show-bin-path "*) printf \'%s\\n\' "$SWIFT_BIN" ;;\n' + ' *) [ "${FAKE_FAIL_STAGE:-}" = build ] && exit 41; ' + '[ "${FAKE_FAIL_STAGE:-}" = copy ] || { ' + 'printf \'swiftpm-product\' > "$SWIFT_BIN/crispctl"; ' + 'chmod 755 "$SWIFT_BIN/crispctl"; } ;;\n' + "esac\n" + ) + xcrun.chmod(0o755) + + lipo = tools / "lipo" + lipo.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$LIPO_LOG"\n' + '[ "${FAKE_FAIL_STAGE:-}" = verifier ] && { ' + 'printf \'%s\\n\' "$FAKE_ARCHS"; exit 43; }\n' + 'printf \'%s\\n\' "$FAKE_ARCHS"\n' + ) + lipo.chmod(0o755) + + codesign = tools / "codesign" + codesign.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$CODESIGN_LOG"\n' + '[ "${FAKE_FAIL_STAGE:-}" = sign ] && exit 44\n' + "exit 0\n" + ) + codesign.chmod(0o755) + + environment = os.environ.copy() + environment.update( + { + "PATH": f"{tools}:{environment['PATH']}", + "SWIFT_BIN": str(swift_bin), + "FAKE_ARCHS": archs, + "XCRUN_LOG": str(root / "xcrun.log"), + "LIPO_LOG": str(root / "lipo.log"), + "CODESIGN_LOG": str(root / "codesign.log"), + } + ) + return temporary, root, repo, app, scratch, environment + + def run_helper(self, repo, app, scratch, signing_id, environment): + return subprocess.run( + [ + "/bin/bash", + str(CRISPCTL_HELPER), + str(repo), + str(app), + str(scratch), + signing_id, + ], + env=environment, + text=True, + capture_output=True, + ) + + def test_builds_selects_embeds_verifies_and_ad_hoc_signs_swiftpm_product(self): + temporary, root, repo, app, scratch, environment = self.make_fixture() + with temporary: + result = self.run_helper(repo, app, scratch, "", environment) + destination = app / "Contents" / "MacOS" / "crispctl" + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(destination.read_bytes(), b"swiftpm-product") + self.assertTrue(os.access(destination, os.X_OK)) + expected_build = ( + "swift build --disable-sandbox -c release --product crispctl " + f"--package-path {repo} --scratch-path {scratch} " + "--arch arm64 --arch x86_64" + ) + xcrun_calls = (root / "xcrun.log").read_text().splitlines() + self.assertEqual( + xcrun_calls, [expected_build, expected_build + " --show-bin-path"] + ) + self.assertEqual( + (root / "lipo.log").read_text().strip(), f"-archs {destination}" + ) + self.assertEqual( + (root / "codesign.log").read_text().strip(), + f"--force --sign - {destination}", + ) + + def test_developer_id_uses_hardened_runtime_timestamp_and_identity(self): + temporary, root, repo, app, scratch, environment = self.make_fixture() + with temporary: + result = self.run_helper( + repo, app, scratch, "Developer ID Application: Example", environment + ) + destination = app / "Contents" / "MacOS" / "crispctl" + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + (root / "codesign.log").read_text().strip(), + "--force --options runtime --timestamp --sign " + f"Developer ID Application: Example {destination}", + ) + + def test_rejects_any_architecture_set_other_than_exact_universal_pair(self): + for archs in ("arm64", "arm64 x86_64 i386", "arm64 arm64"): + with self.subTest(archs=archs): + temporary, _, repo, app, scratch, environment = self.make_fixture( + archs=archs + ) + with temporary: + result = self.run_helper(repo, app, scratch, "", environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly arm64 and x86_64", result.stderr) + + def test_build_copy_verifier_and_sign_failures_propagate_nonzero(self): + for stage in ("build", "copy", "verifier", "sign"): + with self.subTest(stage=stage): + temporary, _, repo, app, scratch, environment = self.make_fixture() + with temporary: + environment["FAKE_FAIL_STAGE"] = stage + result = self.run_helper(repo, app, scratch, "", environment) + self.assertNotEqual(result.returncode, 0) + + def test_cli_sign_completes_before_enclosing_app_sign_can_run(self): + temporary, root, repo, app, scratch, environment = self.make_fixture() + with temporary: + result = self.run_helper(repo, app, scratch, "", environment) + self.assertEqual(result.returncode, 0, result.stderr) + + enclosing = subprocess.run( + ["codesign", "--force", "--sign", "-", str(app)], + env=environment, + text=True, + capture_output=True, + ) + self.assertEqual(enclosing.returncode, 0, enclosing.stderr) + self.assertEqual( + (root / "codesign.log").read_text().splitlines(), + [ + f"--force --sign - {app / 'Contents' / 'MacOS' / 'crispctl'}", + f"--force --sign - {app}", + ], + ) + + +class ReleaseScriptIntegrationTests(unittest.TestCase): + VERSION = "2.3.4" + SHA256 = "0123456789abcdef" * 4 + PREFLIGHT_MARKER = ( + "# Preflight the Homebrew cask before any publish-side mutation." + ) + HELPER_CALL = ( + '"$ROOT/scripts/build-embed-sign-crispctl.sh" "$ROOT" "$APP" ' + '"$CRISPCTL_SCRATCH" "${CRISP_SIGN_ID:-}"' + ) + + def write_executable(self, path, source): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + path.chmod(0o755) + + def make_fixture(self, release_source=None, cask_content=None): + temporary = tempfile.TemporaryDirectory() + repo = Path(temporary.name) / "repo" + scripts = repo / "scripts" + tools = repo / "fake-bin" + scripts.mkdir(parents=True) + tools.mkdir() + + release = scripts / "release.sh" + shutil.copy2(RELEASE, release) + if release_source is not None: + release.write_text(release_source) + release.chmod(0o755) + shutil.copy2(ROOT / "scripts" / "update-homebrew-cask.py", scripts) + + marker = repo / "helper.marker" + event_log = repo / "events.log" + uploaded_content = repo / "uploaded-cask.base64" + uploaded_sha = repo / "uploaded-cask.sha" + + self.write_executable( + scripts / "fetch-sparkle.sh", "#!/bin/sh\nexit 0\n" + ) + self.write_executable( + scripts / "build-embed-sign-crispctl.sh", + "#!/bin/sh\n" + 'if [ "${FAKE_HELPER_FAIL:-}" = 1 ]; then exit 67; fi\n' + 'app="$2"\n' + 'mkdir -p "$app/Contents/MacOS"\n' + 'printf crispctl > "$app/Contents/MacOS/crispctl"\n' + 'chmod 755 "$app/Contents/MacOS/crispctl"\n' + ': > "$HELPER_MARKER"\n' + 'printf \'helper\\n\' >> "$EVENT_LOG"\n', + ) + self.write_executable( + scripts / "verify-release-app.sh", + "#!/bin/sh\n" + 'cli="$1/Contents/MacOS/crispctl"\n' + 'if [ ! -f "$HELPER_MARKER" ] || [ ! -x "$cli" ]; then\n' + ' echo "ERROR: crispctl helper output missing" >&2\n' + " exit 68\n" + "fi\n" + 'printf \'verifier\\n\' >> "$EVENT_LOG"\n', + ) + self.write_executable( + scripts / "xcstrings-compile.py", + "#!/usr/bin/env python3\nprint('en')\n", + ) + self.write_executable( + scripts / "check-translations.py", + "#!/usr/bin/env python3\n" + "import os\n" + "from pathlib import Path\n" + "with Path(os.environ['EVENT_LOG']).open('a') as log:\n" + " log.write('translations\\n')\n", + ) + + self.write_executable( + tools / "swiftc", + "#!/bin/sh\n" + "output=\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = -o ]; then output="$2"; shift 2; else shift; fi\n' + "done\n" + 'mkdir -p "$(dirname "$output")"\n' + 'printf app-binary > "$output"\n' + 'chmod 755 "$output"\n', + ) + self.write_executable( + tools / "lipo", + "#!/bin/sh\n" + 'if [ "$1" = -create ]; then\n' + " output=\n" + " while [ $# -gt 0 ]; do\n" + ' if [ "$1" = -output ]; then output="$2"; shift 2; else shift; fi\n' + " done\n" + ' printf universal > "$output"; chmod 755 "$output"\n' + "else\n" + " printf 'arm64 x86_64\\n'\n" + "fi\n", + ) + self.write_executable( + tools / "iconutil", + "#!/bin/sh\n" + "output=\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = -o ]; then output="$2"; shift 2; else shift; fi\n' + "done\n" + ': > "$output"\n', + ) + self.write_executable(tools / "xattr", "#!/bin/sh\nexit 0\n") + self.write_executable(tools / "codesign", "#!/bin/sh\nexit 0\n") + self.write_executable( + tools / "hdiutil", + "#!/bin/sh\n" + "for argument in \"$@\"; do output=\"$argument\"; done\n" + 'printf dmg-fixture > "$output"\n', + ) + self.write_executable( + tools / "shasum", + f"#!/bin/sh\nprintf '%s %s\\n' '{self.SHA256}' \"$3\"\n", + ) + self.write_executable( + tools / "sed", + "#!/bin/sh\n" + 'if [ "${REQUIRE_CASK_PREFLIGHT:-}" = 1 ]; then\n' + ' prepared="$REPO_UNDER_TEST/build/crisp.rb"\n' + ' grep -F \'version "2.3.4"\' "$prepared" >/dev/null\n' + ' grep -F \'sha256 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"\' "$prepared" >/dev/null\n' + "fi\n" + 'printf \'project-mutate\\n\' >> "$EVENT_LOG"\n' + 'exec /usr/bin/sed "$@"\n', + ) + self.write_executable( + tools / "gh", + "#!/bin/bash\n" + "set -eu\n" + 'if [ "$1" = release ]; then\n' + ' printf \'release-create\\n\' >> "$EVENT_LOG"\n' + ' if [ "${REQUIRE_CASK_PREFLIGHT:-}" = 1 ]; then\n' + ' prepared="$REPO_UNDER_TEST/build/crisp.rb"\n' + ' grep -F \'version "2.3.4"\' "$prepared" >/dev/null\n' + ' grep -F \'sha256 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"\' "$prepared" >/dev/null\n' + ' grep -F \'binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl"\' "$prepared" >/dev/null\n' + " fi\n" + " exit 0\n" + "fi\n" + 'if [ "$1" != api ]; then exit 70; fi\n' + "is_put=false\n" + "for argument in \"$@\"; do\n" + ' [ "$argument" = PUT ] && is_put=true\n' + "done\n" + 'if [ "$is_put" = true ]; then\n' + ' printf \'tap-put\\n\' >> "$EVENT_LOG"\n' + " for argument in \"$@\"; do\n" + ' case "$argument" in\n' + ' content=*) printf \'%s\' "${argument#content=}" > "$UPLOADED_CONTENT" ;;\n' + ' sha=*) printf \'%s\' "${argument#sha=}" > "$UPLOADED_SHA" ;;\n' + " esac\n" + " done\n" + " printf 'commit-sha\\n'\n" + " exit 0\n" + "fi\n" + 'printf \'cask-get\\n\' >> "$EVENT_LOG"\n' + 'if [ "${FAKE_CASK_GET_FAIL:-}" = 1 ]; then exit 72; fi\n' + 'case "$*" in\n' + " *\"--jq .sha\"*) printf '%s\\n' retained-cask-sha ;;\n" + " *\"--jq .content\"*) printf '%s\\n' \"$FAKE_CASK_BASE64\" ;;\n" + " *) printf '%s\\n' \"$FAKE_CASK_JSON\" ;;\n" + "esac\n", + ) + + assets = repo / "Crisp" / "Assets.xcassets" / "AppIcon.appiconset" + assets.mkdir(parents=True) + for size in (16, 32, 64, 128, 256, 512, 1024): + (assets / f"icon_{size}.png").write_bytes(b"icon") + resources = repo / "Crisp" / "Resources" + resources.mkdir() + (resources / "Localizable.xcstrings").write_text("{}") + (repo / "Crisp" / "Crisp-Bridging-Header.h").write_text("") + (repo / "Crisp" / "Crisp.entitlements").write_text("") + (repo / "Crisp" / "Fixture.swift").write_text("") + core = repo / "Sources" / "CrispControlCore" + core.mkdir(parents=True) + (core / "Fixture.swift").write_text("") + + framework = repo / "vendor" / "Sparkle" / "Sparkle.framework" + (framework / "Versions" / "B" / "Updater.app").mkdir(parents=True) + (framework / "Versions" / "B" / "Autoupdate").write_text("") + sparkle_bin = repo / "vendor" / "Sparkle" / "bin" + self.write_executable( + sparkle_bin / "generate_appcast", + "#!/bin/sh\n" + 'printf \'appcast\\n\' >> "$EVENT_LOG"\n' + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = -o ]; then printf appcast > "$2"; exit 0; fi\n' + " shift\n" + "done\n" + "exit 71\n", + ) + + (repo / "docs").mkdir() + (repo / "project.yml").write_text('MARKETING_VERSION: "1.0.0"\n') + (repo / "notes.md").write_text("release notes\n") + if cask_content is None: + cask_content = ( + ROOT + / "Tests" + / "DistributionTests" + / "fixtures" + / "crisp-legacy.rb" + ).read_bytes() + encoded_cask = base64.b64encode(cask_content).decode() + + environment = { + "PATH": f"{tools}:{os.environ.get('PATH', os.defpath)}", + "LANG": os.environ.get("LANG", "C"), + "TMPDIR": tempfile.gettempdir(), + "EVENT_LOG": str(event_log), + "HELPER_MARKER": str(marker), + "REPO_UNDER_TEST": str(repo), + "UPLOADED_CONTENT": str(uploaded_content), + "UPLOADED_SHA": str(uploaded_sha), + "FAKE_CASK_BASE64": encoded_cask, + "FAKE_CASK_JSON": json.dumps( + {"sha": "retained-cask-sha", "content": encoded_cask} + ), + } + return temporary, repo, environment + + def run_release(self, repo, environment, publish=False): + command = ["/bin/bash", str(repo / "scripts" / "release.sh"), "v2.3.4"] + if publish: + command.extend([str(repo / "notes.md"), "--publish"]) + return subprocess.run( + command, + cwd=repo, + env=environment, + text=True, + capture_output=True, + ) + + def test_actual_release_dry_run_executes_helper_and_reaches_no_publish_exit(self): + temporary, repo, environment = self.make_fixture() + with temporary: + result = self.run_release(repo, environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Dry run. Pass --publish", result.stdout) + self.assertTrue((repo / "helper.marker").is_file()) + events = (repo / "events.log").read_text().splitlines() + self.assertNotIn("cask-get", events) + self.assertNotIn("release-create", events) + self.assertNotIn("appcast", events) + self.assertNotIn("tap-put", events) + self.assertTrue( + os.access( + repo / "build" / "Crisp.app" / "Contents" / "MacOS" / "crispctl", + os.X_OK, + ) + ) + + def test_helper_invocation_mutant_fails_through_actual_release_verifier(self): + source = RELEASE.read_text() + self.assertEqual(source.count(self.HELPER_CALL), 1) + mutant = source.replace(self.HELPER_CALL, f"# MUTANT {self.HELPER_CALL}") + temporary, repo, environment = self.make_fixture(release_source=mutant) + with temporary: + result = self.run_release(repo, environment) + self.assertEqual(result.returncode, 68) + self.assertIn("crispctl helper output missing", result.stderr) + self.assertNotIn("Dry run. Pass --publish", result.stdout) + + def test_helper_failure_propagates_through_actual_release(self): + temporary, repo, environment = self.make_fixture() + with temporary: + environment["FAKE_HELPER_FAIL"] = "1" + result = self.run_release(repo, environment) + self.assertEqual(result.returncode, 67) + self.assertFalse((repo / "helper.marker").exists()) + + def test_publish_rejects_unsupported_cask_before_any_publish_mutation(self): + fixture = ( + ROOT / "Tests" / "DistributionTests" / "fixtures" / "crisp-legacy.rb" + ).read_bytes() + unsupported = fixture.replace( + b' version "1.5.1"\n', + b' on_arm { version "nested" }\n version "1.5.1"\n', + ) + temporary, repo, environment = self.make_fixture( + cask_content=unsupported + ) + with temporary: + original_project = (repo / "project.yml").read_bytes() + result = self.run_release(repo, environment, publish=True) + events = (repo / "events.log").read_text().splitlines() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported Ruby brace/block syntax", result.stderr) + self.assertNotIn("project-mutate", events) + self.assertNotIn("release-create", events) + self.assertNotIn("appcast", events) + self.assertNotIn("tap-put", events) + self.assertEqual((repo / "project.yml").read_bytes(), original_project) + + def test_publish_fetch_and_decode_failures_precede_all_mutations(self): + cases = ( + ("fetch", {"FAKE_CASK_GET_FAIL": "1"}, 72, ""), + ( + "decode", + { + "FAKE_CASK_JSON": json.dumps( + {"sha": "retained-cask-sha", "content": "not base64!"} + ) + }, + None, + "invalid Homebrew cask response", + ), + ) + for label, overrides, returncode, error in cases: + with self.subTest(label=label): + temporary, repo, environment = self.make_fixture() + with temporary: + environment.update(overrides) + original_project = (repo / "project.yml").read_bytes() + result = self.run_release(repo, environment, publish=True) + events = (repo / "events.log").read_text().splitlines() + + self.assertNotEqual(result.returncode, 0) + if returncode is not None: + self.assertEqual(result.returncode, returncode) + if error: + self.assertIn(error, result.stderr) + self.assertNotIn("project-mutate", events) + self.assertNotIn("release-create", events) + self.assertNotIn("appcast", events) + self.assertNotIn("tap-put", events) + self.assertEqual( + (repo / "project.yml").read_bytes(), original_project + ) + + def test_publish_prepares_cask_then_uses_retained_sha_and_exact_bytes(self): + temporary, repo, environment = self.make_fixture() + with temporary: + environment["REQUIRE_CASK_PREFLIGHT"] = "1" + result = self.run_release(repo, environment, publish=True) + self.assertEqual(result.returncode, 0, result.stderr) + + events = (repo / "events.log").read_text().splitlines() + ordered = [ + "cask-get", + "project-mutate", + "release-create", + "appcast", + "tap-put", + ] + self.assertEqual( + [event for event in events if event in ordered], ordered + ) + prepared = (repo / "build" / "crisp.rb").read_bytes() + uploaded = base64.b64decode( + (repo / "uploaded-cask.base64").read_bytes() + ) + self.assertEqual(uploaded, prepared) + self.assertEqual( + (repo / "uploaded-cask.sha").read_text(), "retained-cask-sha" + ) + self.assertIn(b'version "2.3.4"', prepared) + self.assertIn(f'sha256 "{self.SHA256}"'.encode(), prepared) + self.assertIn( + b'binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl"', + prepared, + ) + self.assertEqual( + (repo / "project.yml").read_text(), + 'MARKETING_VERSION: "2.3.4"\n', + ) + + def test_moving_preflight_after_release_is_caught_on_production_path(self): + source = RELEASE.read_text() + self.assertIn(self.PREFLIGHT_MARKER, source) + start = source.index(self.PREFLIGHT_MARKER) + end = source.index("# Keep project.yml", start) + preflight = source[start:end] + without_preflight = source[:start] + source[end:] + release_call = 'gh release create "$TAG" --title "Crisp ${TAG}" --notes-file "$NOTES" "$DMG"' + insertion = without_preflight.index(release_call) + len(release_call) + mutant = ( + without_preflight[:insertion] + + "\n\n" + + preflight.rstrip() + + without_preflight[insertion:] + ) + + fixture = ( + ROOT / "Tests" / "DistributionTests" / "fixtures" / "crisp-legacy.rb" + ).read_bytes() + unsupported = fixture.replace( + b' version "1.5.1"\n', + b' on_arm { version "nested" }\n version "1.5.1"\n', + ) + temporary, repo, environment = self.make_fixture( + release_source=mutant, cask_content=unsupported + ) + with temporary: + result = self.run_release(repo, environment, publish=True) + events = (repo / "events.log").read_text().splitlines() + self.assertNotEqual(result.returncode, 0) + self.assertIn("release-create", events) + self.assertIn("unsupported Ruby brace/block syntax", result.stderr) + + +class ReleaseWiringTests(unittest.TestCase): + def test_release_invokes_behaviorally_tested_helper_before_enclosing_signatures(self): + source = RELEASE.read_text() + helper_call = source.index( + '"$ROOT/scripts/build-embed-sign-crispctl.sh" "$ROOT" "$APP" ' + '"$CRISPCTL_SCRATCH" "${CRISP_SIGN_ID:-}"' + ) + sparkle_sign = source.index('for item in "${SPARKLE_NESTED[@]}"') + developer_app_sign = source.index( + '--entitlements Crisp/Crisp.entitlements --sign "$CRISP_SIGN_ID" "$APP"' + ) + ad_hoc_app_sign = source.index( + 'codesign --force --sign - --entitlements Crisp/Crisp.entitlements "$APP"' + ) + verifier = source.index('"$ROOT/scripts/verify-release-app.sh" "$APP"') + + self.assertLess(helper_call, sparkle_sign) + self.assertLess(helper_call, developer_app_sign) + self.assertLess(helper_call, ad_hoc_app_sign) + self.assertGreater(verifier, developer_app_sign) + self.assertGreater(verifier, ad_hoc_app_sign) + self.assertNotIn( + "xcrun swift build --disable-sandbox -c release --product crispctl", + source, + ) + self.assertNotIn('codesign --force --sign - "$CRISPCTL_APP_BINARY"', source) + + def test_publish_uses_tested_cask_transformer(self): + source = RELEASE.read_text() + helper = source.index('scripts/update-homebrew-cask.py') + upload = source.index('gh api -X PUT "repos/$TAP_REPO/contents/$TAP_CASK"') + self.assertLess(helper, upload) + self.assertNotIn('| sed -e "s/version', source) + + +class ReleaseArtifactVerifierTests(unittest.TestCase): + def make_fixture(self, archs="arm64 x86_64", executable=True): + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + app = root / "Crisp.app" + cli = app / "Contents" / "MacOS" / "crispctl" + cli.parent.mkdir(parents=True) + cli.write_text("fixture") + cli.chmod(0o755 if executable else 0o644) + + tools = root / "bin" + tools.mkdir() + lipo = tools / "lipo" + lipo.write_text(f"#!/bin/sh\nprintf '%s\\n' '{archs}'\n") + lipo.chmod(0o755) + codesign = tools / "codesign" + codesign.write_text( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODESIGN_LOG\"\n" + ) + codesign.chmod(0o755) + log = root / "codesign.log" + environment = os.environ.copy() + environment["PATH"] = f"{tools}:{environment['PATH']}" + environment["CODESIGN_LOG"] = str(log) + return temporary, app, log, environment + + def run_verifier(self, app, environment): + return subprocess.run( + [str(VERIFIER), str(app)], + env=environment, + text=True, + capture_output=True, + ) + + def test_accepts_executable_two_arch_cli_and_verifies_app_signature(self): + temporary, app, log, environment = self.make_fixture() + with temporary: + result = self.run_verifier(app, environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + log.read_text().strip(), f"--verify --deep --strict {app}" + ) + + def test_rejects_missing_architecture(self): + temporary, app, _, environment = self.make_fixture(archs="arm64") + with temporary: + result = self.run_verifier(app, environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("arm64 and x86_64", result.stderr) + + def test_rejects_non_executable_cli(self): + temporary, app, _, environment = self.make_fixture(executable=False) + with temporary: + result = self.run_verifier(app, environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not executable", result.stderr) + + def test_rejects_missing_cli(self): + temporary, app, _, environment = self.make_fixture() + with temporary: + (app / "Contents" / "MacOS" / "crispctl").unlink() + result = self.run_verifier(app, environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing or not executable", result.stderr) + + def test_strict_codesign_failure_propagates_nonzero(self): + temporary, app, _, environment = self.make_fixture() + with temporary: + codesign = Path(environment["PATH"].split(":", 1)[0]) / "codesign" + codesign.write_text("#!/bin/sh\nexit 45\n") + codesign.chmod(0o755) + result = self.run_verifier(app, environment) + self.assertEqual(result.returncode, 45) + + def test_architecture_tool_failure_propagates_even_with_valid_looking_output(self): + temporary, app, _, environment = self.make_fixture() + with temporary: + lipo = Path(environment["PATH"].split(":", 1)[0]) / "lipo" + lipo.write_text("#!/bin/sh\nprintf '%s\\n' 'arm64 x86_64'\nexit 46\n") + lipo.chmod(0o755) + result = self.run_verifier(app, environment) + self.assertEqual(result.returncode, 46) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/DistributionTests/test_skill_package.py b/Tests/DistributionTests/test_skill_package.py new file mode 100644 index 0000000..f4a7775 --- /dev/null +++ b/Tests/DistributionTests/test_skill_package.py @@ -0,0 +1,341 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SKILL = ROOT / "skills" / "crispctl" / "SKILL.md" +OLD_SKILL = ROOT / "docs" / "skills" / "crispctl" / "SKILL.md" +README = ROOT / "README.md" +CRISPCTL_DOCS = ROOT / "docs" / "crispctl.md" +COMPARE_EN = ROOT / "docs" / "crisp-vs-betterdisplay.html" +COMPARE_ZH = ROOT / "docs" / "crisp-vs-betterdisplay-zh.html" +RELEASING_DOCS = ROOT / "docs" / "RELEASING.md" +SKILL_INSTALL_COMMAND = ( + "npx skills add didriksg/Crisp --skill crispctl -g --agent '*' -y" +) + + +class CrispctlSkillPackageTests(unittest.TestCase): + def test_skill_has_one_canonical_location(self): + self.assertTrue(SKILL.is_file(), "canonical Skill is missing") + self.assertFalse(OLD_SKILL.exists(), "legacy Skill duplicate still exists") + + def test_frontmatter_is_complete_and_discoverable(self): + raw = SKILL.read_bytes() + self.assertTrue(raw.startswith(b"---\n"), "frontmatter must start at byte 0") + text = raw.decode() + _, frontmatter, _ = text.split("---", 2) + + self.assertRegex(frontmatter, r"(?m)^name: crispctl$") + description = re.search(r"(?m)^description: (.+)$", frontmatter) + self.assertIsNotNone(description) + description_text = description.group(1).strip('"') + self.assertLessEqual(len(description_text), 60) + self.assertTrue(description_text.startswith("Use when ")) + self.assertIn("Crisp", description_text) + self.assertRegex(description_text.lower(), r"\bdisplays?\b") + self.assertTrue(description_text.endswith(".")) + self.assertRegex(frontmatter, r"(?m)^version: 0\.1\.0$") + self.assertRegex(frontmatter, r"(?m)^author: .+$") + self.assertLess(frontmatter.index("Juns (Juns-g)"), frontmatter.index("Hermes Agent")) + self.assertRegex(frontmatter, r"(?m)^license: MIT$") + self.assertRegex(frontmatter, r"(?ms)^platforms:\n - macos$") + self.assertRegex(frontmatter, r"(?ms)^metadata:\n hermes:\n tags:\n(?: - .+\n)+ related_skills: \[\]$") + + def test_body_preserves_execution_and_safety_contract(self): + text = SKILL.read_text() + for heading in ( + "## When to Use", + "## Prerequisites", + "## How to Run", + "### Preflight", + "## Pitfalls", + "## Verification", + ): + self.assertIn(heading, text) + + for required in ( + "--json", + "--allow-unrestorable", + "UUID", + "capabilities", + "ambiguous", + "write_outcome_indeterminate", + "do not retry", + "fresh user decision", + ): + self.assertIn(required, text) + + self.assertIn("command -v crispctl", text) + self.assertIn("/Applications/Crisp.app/Contents/MacOS/crispctl", text) + self.assertIn('"${CRISPCTL}" displays list --json', text) + self.assertNotRegex(text, r"(?m)^crispctl (?:version|status|displays|brightness)") + self.assertIn("github.com/didriksg/Crisp", text) + self.assertIn("skills/crispctl", text) + self.assertRegex(text.lower(), r"fresh agent session") + + def test_p0_skill_is_fail_closed_for_every_expanded_mutation(self): + text = SKILL.read_text() + lower = text.lower() + for command in ( + "brightness set ", "extra-brightness set ", + "hdr set ", "brightness set-all", + ): + self.assertIn(command, text) + for phrase in ( + "external displays only", + "built-in hdr", + "extra brightness", + "logicalpercent", + "hardwarereadbackpercent", + "non-atomic", + "partial failure", + "do not retry the whole batch", + "retrysafe:false", + "fresh user decision", + "stale display uuid", + "read-back mismatch", + "capability collapse", + "item results", + "warnings", + "manualrestorationuuids", + ): + self.assertIn(phrase, lower) + self.assertRegex(lower, r"only `?retrysafe:true`? members") + self.assertRegex(lower, r"missing|unsupported") + self.assertIn("P1/P2", text) + + def test_display_connection_skill_contract_is_fail_closed_and_per_write_authorized(self): + documents = (README, CRISPCTL_DOCS, SKILL) + for path in documents: + with self.subTest(path=path): + text = path.read_text() + for command in ( + "displays disconnected", + "displays disconnect ", + "displays reconnect ", + ): + self.assertIn(command, text) + self.assertNotIn("displays disconnect ", text) + + normalized_skill = " ".join(SKILL.read_text().split()).lower() + for required in ( + "explicit user authorization for each display connection write", + "fresh `displays disconnected` inventory", + "exact uuid", + "disconnect accepts only an exact uuid", + "names, `main`, and `builtin` are not accepted for disconnect", + "apple silicon", + "macos 13", + "physical displays only", + "positive hardware-backed physical proof", + "last active physical viewable display", + "write_outcome_indeterminate", + "every connection timeout includes the exact `displayuuid`", + "read-reconcile-fresh-decision", + "no automatic retry", + ): + self.assertIn(required, normalized_skill) + + docs = CRISPCTL_DOCS.read_text() + for field in ( + "disconnectAllowed", + "reconnectAllowed", + "platformSupported", + "requestedConnectionState", + "observedConnectionState", + "same_uuid_enumeration", + ): + self.assertIn(field, docs) + + en = COMPARE_EN.read_text() + zh = COMPARE_ZH.read_text() + self.assertIn("displays disconnected/disconnect/reconnect", en) + self.assertIn("P1 source target", en) + self.assertIn("exact-UUID writes and positive hardware-backed physical proof", en) + self.assertNotIn( + "Resolution, presets, arrangement, disconnect, virtual displays, and other settings", + en, + ) + self.assertIn("displays disconnected/disconnect/reconnect", zh) + self.assertIn("P1 源码目标", zh) + self.assertIn("精确 UUID 写入与正向硬件物理显示器证明", zh) + self.assertNotIn("分辨率、预设、排列、断开、虚拟显示器及其他设置", zh) + + def test_skill_uses_conventional_cross_agent_installer(self): + text = SKILL.read_text() + + self.assertIn(SKILL_INSTALL_COMMAND, text) + self.assertNotIn("$skill-installer", text) + self.assertNotIn("CODEX_HOME", text) + self.assertNotIn(".codex/skills", text) + + +class DistributionDocumentationTests(unittest.TestCase): + def test_user_docs_explain_cli_and_skill_distribution_without_overclaiming(self): + readme = README.read_text() + crispctl_docs = CRISPCTL_DOCS.read_text() + + self.assertIn("[skills/crispctl/SKILL.md](skills/crispctl/SKILL.md)", readme) + self.assertIn("/Applications/Crisp.app/Contents/MacOS/crispctl", readme) + self.assertRegex(readme, r"(?is)homebrew.+?`PATH`") + + self.assertIn( + "[../skills/crispctl/SKILL.md](../skills/crispctl/SKILL.md)", + crispctl_docs, + ) + self.assertIn( + "/Applications/Crisp.app/Contents/MacOS/crispctl", crispctl_docs + ) + self.assertRegex(crispctl_docs, r"(?is)homebrew.+?`PATH`") + self.assertRegex(crispctl_docs, r"(?i)fresh agent session") + self.assertIn(SKILL_INSTALL_COMMAND, crispctl_docs) + + def test_public_availability_wording_is_durable(self): + documents = (README, CRISPCTL_DOCS, SKILL) + transient_wording = ( + r"(?i)PR\s*#\d+", + r"(?i)open pull request", + r"(?i)pull request is still open", + r"(?i)while the pull request", + r"(?i)(?:once|after) (?:it|the change) is merged", + r"(?i)not (?:yet )?published", + ) + + for path in documents: + with self.subTest(path=path): + text = path.read_text() + for pattern in transient_wording: + self.assertNotRegex(text, pattern) + self.assertIn("Crisp 1.5.0", text) + self.assertRegex( + text, + r"(?is)first Crisp release.+?contains?\s+(?:this|the) distribution change", + ) + self.assertRegex(text, r"(?is)source\s+checkout.+?build") + + def test_release_docs_record_idempotent_homebrew_path_stanza(self): + releasing = RELEASING_DOCS.read_text() + self.assertIn( + 'binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl"', releasing + ) + self.assertIn("scripts/update-homebrew-cask.py", releasing) + self.assertRegex(releasing, r"(?i)exactly once|idempotent") + self.assertRegex(releasing, r"(?i)homebrew.+`PATH`") + self.assertIn("/Applications/Crisp.app/Contents/MacOS/crispctl", releasing) + + def test_p0_automation_docs_are_complete_scoped_and_bilingual(self): + readme = README.read_text() + docs = CRISPCTL_DOCS.read_text() + skill = SKILL.read_text() + en = COMPARE_EN.read_text() + zh = COMPARE_ZH.read_text() + + for command in ( + "extra-brightness get", "extra-brightness set", + "hdr get", "hdr set", "brightness get-all", "brightness set-all", + ): + self.assertIn(command, readme) + self.assertIn(command, docs) + self.assertIn(command, skill) + + for schema_term in ( + "logicalPercent", "hardwareReadbackPercent", "hardwareRange", "logicalRange", + "app_state_verified", "settling", "batch_partial_failure", + "same_logical_percent_per_display", "appliedFactor", "factorVerification", + "written_verified", "written_unverified", "manualRestorationUUIDs", + ): + self.assertIn(schema_term, docs) + + batch_docs = docs[docs.index("`brightness get-all`"):docs.index("## Response contract")] + for member_field in ( + "`attempted`", "`outcome`", "`status`", "`verification`", "`code`", "`retrySafe`", + ): + self.assertIn(member_field, batch_docs) + + for headroom_term in ("appliedFactor", "factorVerification", "app_state"): + self.assertIn(headroom_term, skill) + + for safety_term in ( + "UUID", "explicit user authorization", "capabilities", "no automatic retry", + "write_outcome_indeterminate", "batch_partial_failure", "fresh user decision", + ): + self.assertIn(safety_term, skill) + self.assertNotRegex(skill, r"(?i)HDR[^\n]{0,40}(?:not supported|unsupported)") + + for page, section, p1p2 in ( + (en, "GUI to crispctl automation", "P1/P2"), + (zh, "GUI 到 crispctl 自动化", "P1/P2"), + ): + self.assertIn(section, page) + self.assertIn("extra-brightness", page) + self.assertIn("brightness set-all", page) + self.assertIn(p1p2, page) + + def test_set_all_override_docs_preserve_strict_default_and_manual_restore_truth(self): + for path in (README, CRISPCTL_DOCS, SKILL): + with self.subTest(path=path): + text = " ".join(path.read_text().split()).lower() + self.assertIn("--allow-unrestorable", text) + self.assertIn("strict", text) + self.assertRegex(text, r"no (?:display is changed|changes|writes)") + self.assertIn("manual restoration", text) + self.assertIn("non-atomic", text) + + def test_extra_brightness_cleanup_only_off_exception_is_fail_closed(self): + for path in (SKILL, CRISPCTL_DOCS): + with self.subTest(path=path): + text = path.read_text() + normalized = " ".join(text.split()).lower() + for required in ( + "fresh discovery", + "exact same UUID", + "`state: writable`", + "`extra-brightness set off`", + "`state: unsupported`", + "`persistedEnabled: true`", + "`enabled: true`", + "`maxBrightness > 100`", + "never permits `on`", + "never permits unsupported HDR or brightness writes", + "unsupported `off` without a cleanup indicator", + "explicit user authorization", + "stop and re-discover", + "write_outcome_indeterminate", + "`retrySafe: false`", + "no automatic retry", + "fresh user decision", + ): + self.assertIn(required.lower(), normalized) + + def test_extra_brightness_disable_documents_settling_and_indeterminate_outcomes(self): + for path in (SKILL, CRISPCTL_DOCS): + with self.subTest(path=path): + text = " ".join(path.read_text().split()).lower() + for required in ( + "accepted and persisted off", + "verification: settling", + "terminal cleanup", + "read back", + "write_outcome_indeterminate", + "no automatic retry", + ): + self.assertIn(required, text) + + def test_comparison_pages_distinguish_source_p0_from_public_availability(self): + en = COMPARE_EN.read_text() + zh = COMPARE_ZH.read_text() + + for page in (en, zh): + self.assertIn("Crisp 1.5.0", page) + self.assertIn("Homebrew cask", page) + self.assertIn("P0", page) + self.assertRegex(en, r"(?is)source-build.+?future release") + self.assertRegex(zh, r"(?is)源码构建.+?未来版本") + self.assertNotIn("Crisp now has crispctl", en) + self.assertNotIn("Crisp 现在已有 crispctl", zh) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/DistributionTests/test_update_homebrew_cask.py b/Tests/DistributionTests/test_update_homebrew_cask.py new file mode 100644 index 0000000..aa70bca --- /dev/null +++ b/Tests/DistributionTests/test_update_homebrew_cask.py @@ -0,0 +1,285 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = Path(__file__).with_name("fixtures") +TRANSFORMER = ROOT / "scripts" / "update-homebrew-cask.py" +VERSION = "2.3.4" +SHA256 = "0123456789abcdef" * 4 +BINARY_STANZA = ' binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl"' + + +class HomebrewCaskTransformerTests(unittest.TestCase): + def run_transform( + self, original, version=VERSION, sha256=SHA256, mode=0o640 + ): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "crisp.rb" + target.write_bytes(original) + target.chmod(mode) + command = [ + "python3", + str(TRANSFORMER), + str(target), + "--version", + version, + "--sha256", + sha256, + ] + result = subprocess.run(command, text=True, capture_output=True) + return result, target.read_bytes(), target.stat().st_mode & 0o7777 + + def expected_bytes(self, original): + newline = b"\r\n" if b"\r\n" in original else b"\n" + expected = original.replace( + b'version "1.5.1"', f'version "{VERSION}"'.encode() + ) + old_sha = b"b" * 64 if b"b" * 64 in original else b"a" * 64 + expected = expected.replace( + b'sha256 "' + old_sha + b'"', f'sha256 "{SHA256}"'.encode() + ) + if BINARY_STANZA.encode() not in expected: + expected = expected.replace( + b' app "Crisp.app"' + newline, + b' app "Crisp.app"' + + newline + + BINARY_STANZA.encode() + + newline, + ) + return expected + + def assert_success_bytes_and_idempotence(self, original, expected): + first_result, first, first_mode = self.run_transform(original) + self.assertEqual(first_result.returncode, 0, first_result.stderr) + self.assertEqual(first, expected) + self.assertEqual(first_mode, 0o640) + + second_result, second, second_mode = self.run_transform(first) + self.assertEqual(second_result.returncode, 0, second_result.stderr) + self.assertEqual(second, expected, "transform must be byte-idempotent") + self.assertEqual(second_mode, 0o640) + + def assert_rejected_unchanged(self, original, error, **arguments): + result, actual, actual_mode = self.run_transform( + original, mode=0o751, **arguments + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(error, result.stderr) + self.assertEqual(actual, original) + self.assertEqual(actual_mode, 0o751) + + def test_inserts_missing_binary_stanza_once(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes() + self.assert_success_bytes_and_idempotence( + original, self.expected_bytes(original) + ) + + def test_existing_binary_stanza_remains_exactly_once(self): + original = (FIXTURES / "crisp-with-binary.rb").read_bytes() + self.assert_success_bytes_and_idempotence( + original, self.expected_bytes(original) + ) + + def test_preserves_crlf_with_complete_expected_bytes(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes().replace(b"\n", b"\r\n") + self.assert_success_bytes_and_idempotence( + original, self.expected_bytes(original) + ) + + def test_rejects_app_stanza_without_terminating_newline_unchanged(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + marker = b' app "Crisp.app"' + original = fixture[: fixture.index(marker) + len(marker)] + + self.assert_rejected_unchanged( + original, "app stanza must end with a newline" + ) + + def test_rejects_mixed_newlines_unchanged(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes().replace( + b"\n", b"\r\n", 1 + ) + + self.assert_rejected_unchanged( + original, "mixed or ambiguous newline style" + ) + + def test_rejects_wrong_cask_wrapper_unchanged(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes().replace( + b'cask "crisp" do', b'cask "other" do' + ) + + self.assert_rejected_unchanged( + original, 'expected top-level cask "crisp" do wrapper' + ) + + def test_rejects_malformed_cask_wrapper_unchanged(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes().removesuffix(b"end\n") + self.assert_rejected_unchanged( + original, 'expected top-level cask "crisp" do wrapper' + ) + + def test_rejects_matching_stanzas_nested_in_unexpected_block_unchanged(self): + original = ( + b'cask "crisp" do\n' + b' on_arm do\n' + b' version "1.5.1"\n' + + b' sha256 "' + + b"a" * 64 + + b'"\n' + b' app "Crisp.app"\n' + b' end\n' + b'end\n' + ) + + self.assert_rejected_unchanged( + original, "release stanzas must be in the top-level cask block" + ) + + def test_rejects_multiline_ruby_brace_block_unchanged(self): + original = ( + b'cask "crisp" do\n' + b' on_arm {\n' + b' version "1.5.1"\n' + + b' sha256 "' + + b"a" * 64 + + b'"\n' + b' app "Crisp.app"\n' + b' }\n' + b'end\n' + ) + + self.assert_rejected_unchanged( + original, "unsupported Ruby brace/block syntax" + ) + + def test_rejects_same_line_ruby_brace_block_unchanged(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + original = fixture.replace( + b' version "1.5.1"\n', + b' on_arm { version "nested" }\n version "1.5.1"\n', + ) + + self.assert_rejected_unchanged( + original, "unsupported Ruby brace/block syntax" + ) + + def test_escaped_backslash_cannot_hide_unquoted_brace(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + original = fixture.replace( + b' version "1.5.1"\n', + b' desc "escaped backslash \\\\" {\n version "1.5.1"\n', + ) + + self.assert_rejected_unchanged( + original, "unsupported Ruby brace/block syntax" + ) + + def test_allows_braces_in_quoted_strings_comments_and_binary_interpolation(self): + fixture = (FIXTURES / "crisp-with-binary.rb").read_bytes() + original = fixture.replace( + b' version "1.5.1"\n', + b' # comment braces are harmless: { }\n' + b' desc "escaped quote: \\\"{still quoted}\\\""\n' + b" homepage 'https://example.test/{literal}'\n" + b' version "1.5.1"\n', + ) + + self.assert_success_bytes_and_idempotence( + original, self.expected_bytes(original) + ) + + def test_rejects_unclosed_quoted_string_unchanged(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + original = fixture.replace( + b' version "1.5.1"\n', + b' desc "unterminated {\n version "1.5.1"\n', + ) + + self.assert_rejected_unchanged( + original, "unterminated Ruby quoted string" + ) + + def test_rejects_missing_or_duplicate_required_stanzas_unchanged(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + version = b' version "1.5.1"\n' + sha = b' sha256 "' + b"a" * 64 + b'"\n' + app = b' app "Crisp.app"\n' + cases = { + "missing version": (fixture.replace(version, b""), "version", 0), + "duplicate version": (fixture.replace(version, version * 2), "version", 2), + "missing sha": (fixture.replace(sha, b""), "sha256", 0), + "duplicate sha": (fixture.replace(sha, sha * 2), "sha256", 2), + "missing app": (fixture.replace(app, b""), 'app "Crisp.app"', 0), + "duplicate app": (fixture.replace(app, app * 2), 'app "Crisp.app"', 2), + } + for label, (original, stanza, count) in cases.items(): + with self.subTest(label=label): + self.assert_rejected_unchanged( + original, f"expected exactly one {stanza} stanza, found {count}" + ) + + def test_rejects_unrecognized_crispctl_stanza_unchanged(self): + fixture = (FIXTURES / "crisp-legacy.rb").read_bytes() + original = fixture.replace( + b' app "Crisp.app"\n', + b' app "Crisp.app"\n' + b' binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl", target: "wrong"\n', + ) + self.assert_rejected_unchanged( + original, "found an unrecognized crispctl cask stanza" + ) + + def test_rejects_unsafe_version_and_sha_unchanged(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes() + cases = ( + ({"version": '2.3.4"; system("bad")'}, "unsafe version value"), + ({"sha256": "g" * 64}, "sha256 must contain exactly 64 hex characters"), + ({"sha256": "abc"}, "sha256 must contain exactly 64 hex characters"), + ) + for arguments, error in cases: + with self.subTest(arguments=arguments): + self.assert_rejected_unchanged(original, error, **arguments) + + def test_preserves_file_mode(self): + original = (FIXTURES / "crisp-legacy.rb").read_bytes() + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "crisp.rb" + target.write_bytes(original) + target.chmod(0o751) + result = subprocess.run( + [ + "python3", + str(TRANSFORMER), + str(target), + "--version", + VERSION, + "--sha256", + SHA256, + ], + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(target.stat().st_mode & 0o7777, 0o751) + + def test_transformed_output_is_valid_ruby_when_available(self): + ruby = shutil.which("ruby") + if ruby is None: + self.skipTest("Ruby is unavailable") + original = (FIXTURES / "crisp-legacy.rb").read_bytes() + result, transformed, _ = self.run_transform(original) + self.assertEqual(result.returncode, 0, result.stderr) + + syntax = subprocess.run( + [ruby, "-c", "-"], input=transformed, capture_output=True + ) + self.assertEqual(syntax.returncode, 0, syntax.stderr.decode()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/Fixtures/old-v1-display.json b/Tests/Fixtures/old-v1-display.json new file mode 100644 index 0000000..5b5942e --- /dev/null +++ b/Tests/Fixtures/old-v1-display.json @@ -0,0 +1,13 @@ +{ + "uuid": "old-uuid", + "name": "Old Display", + "isMain": true, + "isBuiltin": true, + "brightness": { + "state": "writable", + "backend": "DisplayServices", + "range": {"min": 0, "max": 100, "precision": 0.1}, + "readback": "authoritative" + }, + "brightnessPercent": 42 +} diff --git a/dev.sh b/dev.sh index 9ea0f5c..abb79af 100755 --- a/dev.sh +++ b/dev.sh @@ -33,7 +33,8 @@ swiftc -O -swift-version 5 -strict-concurrency=minimal -parse-as-library \ -Xlinker -rpath -Xlinker @executable_path/../Frameworks \ -Xlinker -undefined -Xlinker dynamic_lookup \ Crisp/App/*.swift Crisp/Models/*.swift Crisp/Services/*.swift \ - Crisp/Views/*.swift Crisp/Utilities/*.swift \ + Crisp/Views/*.swift Crisp/Utilities/*.swift Crisp/Automation/*.swift \ + Sources/CrispControlCore/*.swift \ -o Crisp-bin echo "==> Swapping into ${APP}..." diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 11d345b..6694f48 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -42,10 +42,33 @@ the release instead of shipping. ## Homebrew tap -`release.sh` bumps `version` and `sha256` in `didriksg/homebrew-tap` -automatically. `auto_updates true` is already in the cask as of 1.5.0, so -`brew upgrade` reconciles against the on-disk version instead of -reinstalling over an app that updated itself. Nothing to do per release. +`release.sh` fetches the existing cask from `didriksg/homebrew-tap`, then +`scripts/update-homebrew-cask.py` updates `version` and `sha256` and maintains +this artifact stanza exactly once: + +```ruby +binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl" +``` + +This is Homebrew's documented syntax for linking a binary contained in an app +bundle. The transformer is idempotent and preserves unrelated cask content; it +refuses an unrecognized Crisp CLI stanza instead of guessing. The source-tree +tests use local fixtures and never contact GitHub. + +On `--publish`, the script fetches and transforms the cask immediately after +the final DMG hash and release-notes validation, before changing `project.yml` +or creating the release. The later tap PUT reuses those exact prepared bytes +and the SHA retained from that single read-only fetch; a cask preflight failure +therefore leaves all publish-side state untouched. + +After a release containing this change is published, Homebrew cask installs expose `crispctl` on `PATH`. +Manual DMG installs can instead execute +`/Applications/Crisp.app/Contents/MacOS/crispctl` directly or create a symlink +in a user-owned `PATH` directory; `sudo` is not required as the only route. + +`auto_updates true` is already in the cask as of 1.5.0, so `brew upgrade` +reconciles against the on-disk version instead of reinstalling over an app that +updated itself. No additional cask edit is needed per release. ## The signing key diff --git a/docs/crisp-vs-betterdisplay-zh.html b/docs/crisp-vs-betterdisplay-zh.html index 65b15ae..01d9f2d 100644 --- a/docs/crisp-vs-betterdisplay-zh.html +++ b/docs/crisp-vs-betterdisplay-zh.html @@ -187,7 +187,7 @@

功能对比

快捷指令 / 命令行 - + ~ crispctl 源码目标;未来版本 命令行 ~ @@ -198,11 +198,27 @@

功能对比

免费   Pro 仅付费版   不支持   ~ 部分 / 受限

价格和免费 / Pro 的划分参考各自的官方文档整理(BetterDisplay、Lunar,截至 2026 年年中)。功能会在版本间调整,具体以官网为准。

+

GUI 到 crispctl 自动化

+

Crisp 的源码构建 P0 自动化目标复用正在运行的 App 显示服务;CLI 不会直接访问显示器硬件。源码目标已实现下面这组明确能力,并不宣称已经与 GUI 完全等价。这是源码可用性,不是公开版本可用性声明:Crisp 1.5.0 和当前 Homebrew cask 尚未捆绑 crispctl;公开可用性将从包含此分发变更的未来版本开始。

+
+ + + + + + + + + + +
GUI 能力crispctl自动化状态
显示器发现与实时能力displays list/get/capabilities P0 源码目标
亮度(含 Extra Brightness 实时范围)brightness get/set
extra-brightness get/set
P0 源码目标
外接屏 HDR 开关hdr get/set P0 源码目标;仅 GUI 本身提供开关时可写,内建屏不伪造 HDR 开关
全部物理显示器brightness get-all
brightness set-all
P0 源码目标;每台使用相同逻辑百分比
物理显示器断开 / 重连displays disconnected/disconnect/reconnect P1 源码目标;精确 UUID 写入与正向硬件物理显示器证明,本次未做真实硬件验证
分辨率、预设、排列、虚拟显示器及其他设置仍仅限 GUI;其余 P1/P2 尚未实现
+
+

为什么选 Crisp

如果你要的只是 1440p 或 4K 显示器上清晰的 HiDPI,再加上亮度(Mac 的亮度键也能直接调外接屏,和内建屏一样)、预设和排列,Crisp 全都免费给你。现在还包括 XDR/HDR 亮度增强,把 XDR MacBook 和 HDR 显示器推过 100%,这项功能 BetterDisplay 和 Lunar 都锁在 Pro 里;另外还能通过 DDC 调显示器自带音箱的音量。不用重置试用期,不用花钱买授权码,更不用去找破解版,因为它根本没有付费版。开源(MIT),代码随便看,也能自己编译。

BetterDisplay Pro 仍然领先的地方

-

先把话说清楚:Crisp 目前不支持 EDID 覆写、画中画,也没有快捷指令和命令行自动化。如果你就是需要这些,BetterDisplay Pro 更合适,它成熟、维护得好,21.99 美元也算公道。但如果你要的只是日常的显示器控制,清晰又免费,Crisp 完全够用。

+

先把话说清楚:Crisp 的源码目标为未来版本实现了 crispctl 命令行自动化,但公开的 Crisp 1.5.0 尚未捆绑它,P1/P2 范围也仍不支持 Apple 快捷指令、EDID 覆写、画中画 / 屏幕推流。如果你就是需要这些尚不支持的功能,BetterDisplay Pro 更合适,它成熟、维护得好,21.99 美元也算公道。但如果你要的只是日常的显示器控制,清晰又免费,Crisp 完全够用。

Crisp 对比 BetterDisplay、Lunar、MonitorControl

Crisp 对比 BetterDisplay:日常功能高度重合,但 BetterDisplay 把灵活的 HiDPI 缩放和 XDR/HDR 亮度增强锁进了 Pro(21.99 美元),还多了 EDID 覆写、画中画这些 Crisp 没有的功能。想免费用上清晰的 HiDPI、亮度、XDR/HDR 增强和预设,就选 Crisp;需要那些进阶功能,就买 BetterDisplay Pro。

diff --git a/docs/crisp-vs-betterdisplay.html b/docs/crisp-vs-betterdisplay.html index d51e450..798fbca 100644 --- a/docs/crisp-vs-betterdisplay.html +++ b/docs/crisp-vs-betterdisplay.html @@ -187,7 +187,7 @@

Feature comparison

Apple Shortcuts / CLI automation - + ~ crispctl source target; future release CLI ~ @@ -198,11 +198,27 @@

Feature comparison

free   Pro paid tier only   not available   ~ partial / limited

Prices and free/Pro splits from each app's own documentation (BetterDisplay and Lunar, mid-2026). Features move between tiers over time, so check their sites for the current state.

+

GUI to crispctl automation

+

Crisp's source-build P0 automation target reuses the running app's display services; the CLI does not talk to monitor hardware directly. It implements the focused subset below rather than claiming full GUI parity. This is source availability, not a public-release claim: Crisp 1.5.0 and the current Homebrew cask do not bundle crispctl. Public availability begins with a future release containing this distribution change.

+
+ + + + + + + + + + +
GUI capabilitycrispctlAutomation status
Display discovery and live capabilitiesdisplays list/get/capabilities P0 source target
Brightness, including live Extra Brightness rangebrightness get/set
extra-brightness get/set
P0 source target
External HDR togglehdr get/set P0 source target where the GUI offers the toggle; no fake built-in toggle
All physical displaysbrightness get-all
brightness set-all
P0 source target, same logical percent per display
Physical display disconnect / reconnectdisplays disconnected/disconnect/reconnect P1 source target; exact-UUID writes and positive hardware-backed physical proof, not real-hardware validated here
Resolution, presets, arrangement, virtual displays, and other settingsGUI-only; remaining P1/P2 not implemented
+
+

Where Crisp wins

If what you wanted from BetterDisplay was sharp HiDPI on a 1440p or 4K monitor, plus brightness (your Mac's own brightness keys drive the external monitors too, just like the built-in), presets and arrangement, Crisp gives you all of it for free. That now includes XDR/HDR extra brightness, pushing an XDR MacBook or HDR monitor past 100%, which BetterDisplay and Lunar both keep behind Pro, and DDC volume for the monitor's built-in speakers. There is no trial to reset and no license to buy, because there is no paid tier at all. It is open source, so you can read exactly what it does or build it yourself.

Where BetterDisplay Pro is still ahead

-

Being honest keeps this useful. Crisp does not do EDID overrides, picture-in-picture, or Apple Shortcuts and CLI automation. If you specifically need any of those, BetterDisplay Pro is the better buy, it is a mature, well-supported app and $21.99 is fair for what it does. Crisp is the right pick when you want the everyday display controls, sharp and free, without a subscription mindset.

+

Being honest keeps this useful. Crisp's source target implements crispctl command-line automation for a future release, while public Crisp 1.5.0 does not bundle it and the P1/P2 surfaces still do not include Apple Shortcuts, EDID overrides, or picture-in-picture / streaming. If you specifically need any of those unsupported features, BetterDisplay Pro is the better buy, it is a mature, well-supported app and $21.99 is fair for what it does. Crisp is the right pick when you want the everyday display controls, sharp and free, without a subscription mindset.

Crisp vs BetterDisplay, Lunar, and MonitorControl

Crisp vs BetterDisplay: the everyday features overlap, but BetterDisplay keeps flexible HiDPI and XDR/HDR brightness behind Pro ($21.99), and adds EDID overrides and picture-in-picture that Crisp doesn't have. Choose Crisp for sharp HiDPI, brightness, XDR/HDR boost and presets at no cost; choose BetterDisplay Pro if you need those advanced extras.

diff --git a/docs/crispctl.md b/docs/crispctl.md new file mode 100644 index 0000000..5d38fe3 --- /dev/null +++ b/docs/crispctl.md @@ -0,0 +1,303 @@ +# crispctl automation interface + +`crispctl` is Crisp's versioned, JSON-first automation interface. The CLI never +talks to display APIs itself: it sends one request over a local Unix-domain +socket to the running Crisp app, which reuses `DisplayManager` and +the existing app services. Physical connection writes specifically reuse +`PhysicalDisplayToggleService`; neither the CLI nor the protocol layer calls +SkyLight/CoreGraphics display-write APIs. + +## Installation and agent discovery + +Public installs gain `crispctl` only with the first Crisp release that contains +this distribution change. Crisp 1.5.0 does not contain the bundled CLI. Source +checkout users can build it. In a release that includes it, the Homebrew cask +exposes `crispctl` on `PATH`: + +```sh +brew install --cask didriksg/tap/crisp +crispctl version --json +``` + +A manual DMG install can invoke the embedded executable without changing +`PATH`: + +```sh +/Applications/Crisp.app/Contents/MacOS/crispctl version --json +``` + +Users who prefer a short command may create a symlink in a user-owned directory +such as `~/.local/bin`; `sudo` is not required and the app-internal path remains +the deterministic fallback. + +The standard AI-agent Skill is +[../skills/crispctl/SKILL.md](../skills/crispctl/SKILL.md). Install it globally +for all agent types supported by the conventional installer: + +```sh +npx skills add didriksg/Crisp --skill crispctl -g --agent '*' -y +``` + +The quotes around `'*'` prevent shell expansion. Skill discovery is evaluated +at startup in many agents, so a fresh agent session may be required; +installation does not promise hot-loading into the current session. + +## Commands + +```sh +crispctl version --json +crispctl status --json [--no-start] +crispctl displays list --json [--no-start] +crispctl displays get --json [--no-start] +crispctl displays capabilities --json [--no-start] +crispctl displays disconnected --json [--no-start] +crispctl displays disconnect --json [--no-start] +crispctl displays reconnect --json [--no-start] +crispctl brightness get --json [--no-start] +crispctl brightness set --json [--no-start] +crispctl brightness get-all --json [--no-start] +crispctl brightness set-all --json [--allow-unrestorable] [--no-start] +crispctl extra-brightness get --json [--no-start] +crispctl extra-brightness set on|off --json [--no-start] +crispctl hdr get --json [--no-start] +crispctl hdr set on|off --json [--no-start] +``` + +Display UUID is the stable canonical selector. Read commands and unrelated P0 +writes retain the existing `main`, `builtin`, and case-insensitive name +conveniences; an ambiguous name fails with candidate UUIDs. Physical connection +writes accept only the exact UUID form documented below. + +## Physical display connection state + +This additive P1 source slice is not a claim that Crisp 1.5.0 contains +`crispctl`, and its headless tests do not perform a real display write. On a +release/source build that contains the slice, each online display includes a +`connection` capability with `state`, current `connected` truth, +`disconnectAllowed`, `reconnectAllowed`, `platformSupported`, and optional +`reason`/`remediation`. `displays capabilities` includes the same connection +object. + +`displays disconnected` returns only stable automation identities that Crisp +intentionally disconnected, deterministically sorted by UUID. Each item has +`uuid`, `name`, `width`, `height`, and its connection capability. A last-known +`CGDirectDisplayID` is retained privately for the existing recovery machinery +but is never exposed or accepted as an automation identity. + +The safe per-write sequence is: + +1. Obtain explicit user authorization for the exact connection write. +2. For disconnect, read fresh `displays list` and `displays capabilities`, copy + the exact `uuid` without normalization, then run `displays disconnect `. + Names, `main`, `builtin`, legacy `selector` arguments, and non-UUID values + fail before inventory work or mutation. The app re-resolves that exact UUID + immediately before dispatch and never switches targets. +3. For reconnect, read a fresh `displays disconnected` inventory and copy the + exact `uuid` without normalization into `displays reconnect `. Names, + `main`, `builtin`, missing/collapsed records, stale IDs, and UUIDs absent from + that fresh response fail before mutation. + +Connection writes fail closed unless the existing platform gate proves Apple +Silicon and macOS 13 or later and the target has positive hardware-backed +physical proof: a built-in panel; an external display whose nonzero CoreGraphics +service conforms to IOKit `IODisplayConnect`; or an external display whose +nonzero CoreGraphics vendor, product, and serial identity has exactly one equal +`IOMobileFramebuffer` `DisplayAttributes.ProductAttributes` identity among the +complete registry snapshot's entries that publish a non-empty `EDID UUID`. +EDID UUID presence only scopes the external framebuffer candidates; its value or +contents are never parsed or compared as identity proof. Any EDID-bearing +candidate with a partial, zero, or missing identity fails the entire proof, as +do zero or duplicate exact matches. Non-EDID built-in and inactive framebuffer +entries are not external identity candidates. Crisp +virtual displays, third-party virtual displays, placeholders, and unknown or +unprovable targets are excluded and cannot count as the other viewable display. +Disconnect must leave another positively proven active physical viewable +display. DDC support, display name, dimensions, IOKit `Location`, EDID prefix or +contents, traversal order, and vendor/product without serial are not proof. A +transaction return alone is not proof. Success +requires bounded same-UUID enumeration: +disconnect proves the UUID offline while its intentional record remains; +reconnect proves it online and then proves the intentional record absent. +Success reports `displayUUID`, `requestedConnectionState`, +`observedConnectionState`, `verification: same_uuid_enumeration`, and +`warnings`. + +After dispatch, a service/transport timeout, cancellation, configuration +failure, identity loss, or late/non-settling enumeration returns +`write_outcome_indeterminate`, exit code 5, and `retrySafe: false`. The +response includes the exact `displayUUID`, requested connection state, and +command. WindowServer may still finish. Never automatically retry. Read a +fresh `displays list` and `displays disconnected`, let same-UUID reconciliation +finish, explain the observed state, and obtain a fresh user decision before any +later write. + +If Crisp is unavailable, the CLI normally resolves and launches the registered +`com.crisp.app` bundle, verifies that bundle identity, and polls socket readiness +for a bounded interval. `--no-start` returns `app_not_running` immediately. +Tests inject a launcher and never open the GUI. + +Brightness values are logical percentages within the selected display's current +`brightness.logicalRange` (also retained as the compatible `brightness.range`). +`brightness.hardwareRange` is always the native/DDC range, normally 0...100. +When Extra Brightness is enabled and usable, the logical maximum is the live +`DisplayInfo.maxBrightness`; it is never hardcoded to 200 and does not represent +absolute nits. `headroom.potential` and `headroom.current` are relative NSScreen +EDR component values. `headroom.appliedFactor` is the most recent factor Crisp +committed through its overlay or transfer-table path. Its +`factorVerification` is `app_state`; a missing value is `null`. Neither field is +an independent measurement of EDR output or emitted light. + +A read returns compatible `percent` plus unambiguous `logicalPercent` and +`hardwareReadbackPercent`. A logical value above 100 can therefore coexist with +a hardware read-back of 100. A set response includes requested, original, +applied, and read-back values, backend, verification quality, and warnings. At +or below 100, authoritative/approximate/unavailable retain their existing +meaning. Above 100, `app_state_verified` means Crisp committed the logical state +and synchronized its existing EDR/boost path; it is not hardware-authoritative +EDR verification. An unavailable independent EDR read-back is stated in +`warnings`. + +`extra-brightness get/set` reports `state`, live `enabled`, +`persistedEnabled`, `maxBrightness`, relative `headroom`, reasons, and +remediation. A verified disable has reached its generation-guarded terminal +state: logical brightness is at most 100, the ceiling and boost factor are at +identity, and the overlay is removed. If disable was accepted and persisted off +but fresh same-UUID state still positively shows terminal cleanup in progress, +the command is non-failing with `verification: settling` and a warning. That is +transitional app state, not terminal verification; read back before another +write and do not retry automatically. Identity loss or another unknown +post-mutation result remains `write_outcome_indeterminate`, with no automatic +retry. An accepted enable can also return `settling` while its animated ceiling +grows. `hdr get/set` is distinct and writable only for an external display +where Crisp's GUI exposes the explicit HDR toggle. A built-in display returns +`unsupported_capability` with remediation to use Extra Brightness when +eligible. HDR set is verified only after bounded live read-back. + +Normally every write requires a fresh discovery response with `state: writable` +and the exact same UUID. The sole exception is +`extra-brightness set off`: a freshly returned same-UUID Extra +Brightness state may have `state: unsupported` while positively proving cleanup +is needed through `persistedEnabled: true`, `enabled: true`, or +`maxBrightness > 100`. This exception never permits `on`, never permits +unsupported HDR or brightness writes, and never permits unsupported `off` +without a cleanup indicator. Obtain explicit user authorization for that exact +cleanup command. For every other capability collapse, unsupported state, or +stale UUID, stop and re-discover before a fresh user decision. The cleanup path +retains `write_outcome_indeterminate`, `retrySafe: false`, separate read-back, +and no automatic retry rules. + +`brightness get-all` and `brightness set-all` target every connected +non-virtual physical display in UUID order. Their semantics are +`same_logical_percent_per_display`: 125 means logical 125 on each display, not a +normalized fraction. Strict mode is the default and requires both a writable +live range and a readable pre-write restore snapshot from every target. If one +target is outside its dynamic range or has no readable snapshot, preflight +returns `batch_preflight_failed`, reports the rejecting member as `failed`, +reports the other members as `not_attempted`, and makes no writes. + +`--allow-unrestorable` is the only override. It maps to the additive protocol +argument `allowUnrestorable: true` and must be deliberately supplied for that +exact command; it is never inferred. It accepts a target whose snapshot read +explicitly returns unavailable, but it does not bypass capability/range checks, +snapshot errors, deadlines, or cancellation. This mode is non-atomic and is not +a restorable transaction: hardware writes remain sequential, `atomic` is false, +`rollbackAttempted` is false, and Crisp cannot supply an original value for a +missing-snapshot display. The result reports `restoreMode`, +`restoreSnapshotsComplete`, `missingRestoreSnapshotUUIDs`, +`manualRestorationRequired`, and `manualRestorationUUIDs`; warnings identify +each attempted display for which manual restoration is required. + +A partial execution returns `batch_partial_failure`, ordered per-display +outcomes, `appliedUUIDs`, `failedUUIDs`, `indeterminateUUIDs`, and +`notAttemptedUUIDs`. Each outcome retains the compatible `attempted`, `outcome`, +`verification`, `code`, and `retrySafe` fields and adds a deterministic `status`: +`written_verified`, `written_unverified`, `failed`, or `not_attempted`. +`write_indeterminate` remains a separate stronger safety state. Outcomes also +report `restoreSnapshotAvailable`, `manualRestorationRequired`, and `warnings`. +A successful member uses `code: null`; an unverified write uses +`verification: unavailable` and an explicit warning. The aggregate is +`retrySafe: false`. The batch's internal deadline returns accumulated results +before the server deadline, so only the in-flight member is indeterminate and +later members are not attempted. Do not retry successful or indeterminate +members. Only a `retrySafe: true` member may be reconsidered after reconciliation +and a fresh authorized decision; there is no automatic retry. Empty physical +inventory returns `empty_physical_inventory`. + +## Response contract + +Every stdout response in JSON mode is one JSON value followed by a newline: + +```json +{ + "protocolVersion": 1, + "requestID": "...", + "ok": false, + "error": { + "code": "ambiguous_selector", + "message": "display selector is ambiguous", + "details": {"candidates": []} + } +} +``` + +Success uses `result` instead of `error`. Exit codes are stable by category: +1 internal, 2 arguments, 3 app lifecycle, 4 unsupported capability, 5 write +verification or indeterminate write outcome, 6 selector, 7 protocol, and 8 +transport/timeout. Batch preflight/empty inventory use capability category 4; +`batch_partial_failure` uses write category 5. `write_outcome_indeterminate` +includes `retrySafe: false` +and the requested selector/target: an in-flight macOS or DDC callback may still +apply after the CLI times out, so callers must read current state and must not +automatically repeat the write. + +These fields, commands, the optional `allowUnrestorable` request argument, and +the batch status/restore fields are additive protocol-v1 extensions. Omission or +`false` means strict mode. An older v1 app that does not know the option remains +strict, which is safe; callers that requested the override must require returned +`restoreMode: allow_unrestorable` before treating it as honored. Older v1 display +payloads decode with defaults for the new capability fields; existing field +names, types, and exit categories remain unchanged. + +All mutating commands (`brightness set`, `brightness set-all`, +`extra-brightness set`, `hdr set`, `displays disconnect`, and +`displays reconnect`) use this indeterminate contract. Batch +timeouts identify `all_physical_displays`; selector writes include the selector +and target. Read current state before seeking a new user-authorized decision. + +## Security and lifecycle + +- The socket lives below the current user's temporary directory. Its parent is + mode `0700` and the socket is mode `0600`. +- The server rejects peers whose `getpeereid` UID differs from Crisp's UID. +- Startup removes only a stale, same-owner socket. It never replaces a regular + file, a foreign-owned socket, or a reachable active server. +- The socket is removed on orderly app termination. Abrupt termination is + recovered on the next start. +- The trust boundary is the local user account: another process running as the + same UID can issue commands. There is intentionally no network listener. +- Request and response size is strictly bounded to 1 MiB. Client waits, accepted + socket I/O, and command handlers use bounded timeouts; the server also caps + concurrent accepted connections at 16. A timed-out hardware write reports an + unknown outcome, not a rollback guarantee, because an in-flight macOS/DDC call + may not support cancellation. + +## Development verification + +The dependency-free control plane can be checked without launching Crisp: + +```sh +swift test --disable-sandbox +swift build --disable-sandbox --product crispctl +./scripts/test-crispctl-roundtrip.sh +``` + +The round-trip script starts a separate fixture host process, exercises list, +status, all P0 get/set commands, >100 logical state, batch behavior, plus +disconnected list -> fixture disconnect -> retained record -> exact-UUID +reconnect -> empty list through the real socket, then +removes the process and socket. It contains the only hardcoded display fixture; +production discovery always comes from Crisp's live `DisplayManager`. + +External DDC hardware, real app launch/readiness, real brightness writes, and +real physical display disconnect/reconnect require separately authorized host +validation and are not exercised by the headless suite. diff --git a/project.yml b/project.yml index 7c6dc7d..5aa6c58 100644 --- a/project.yml +++ b/project.yml @@ -10,6 +10,42 @@ options: - Base targets: + CrispControlCore: + type: library.static + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/CrispControlCore + settings: + base: + SWIFT_VERSION: "6.0" + + CrispControlCLI: + type: library.static + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/CrispControlCLI + dependencies: + - target: CrispControlCore + settings: + base: + SWIFT_VERSION: "6.0" + + crispctl: + type: tool + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/crispctl + dependencies: + - target: CrispControlCore + - target: CrispControlCLI + settings: + base: + PRODUCT_NAME: crispctl + SWIFT_VERSION: "6.0" + Crisp: type: application platform: macOS @@ -19,6 +55,7 @@ targets: excludes: - "**/*.md" dependencies: + - target: CrispControlCore # Prebuilt Sparkle, fetched by scripts/fetch-sparkle.sh (make vendor). - framework: vendor/Sparkle/Sparkle.framework embed: true @@ -79,3 +116,7 @@ schemes: test: targets: - CrispTests + crispctl: + build: + targets: + crispctl: all diff --git a/scripts/build-embed-sign-crispctl.sh b/scripts/build-embed-sign-crispctl.sh new file mode 100755 index 0000000..104a902 --- /dev/null +++ b/scripts/build-embed-sign-crispctl.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +REPO="${1:?Usage: build-embed-sign-crispctl.sh REPO APP SCRATCH SIGNING_ID}" +APP="${2:?Usage: build-embed-sign-crispctl.sh REPO APP SCRATCH SIGNING_ID}" +SCRATCH="${3:?Usage: build-embed-sign-crispctl.sh REPO APP SCRATCH SIGNING_ID}" +SIGNING_ID="${4-}" +DESTINATION="$APP/Contents/MacOS/crispctl" + +BUILD_ARGS=( + swift build --disable-sandbox -c release --product crispctl + --package-path "$REPO" --scratch-path "$SCRATCH" + --arch arm64 --arch x86_64 +) + +xcrun "${BUILD_ARGS[@]}" +BIN_PATH="$(xcrun "${BUILD_ARGS[@]}" --show-bin-path)" +SOURCE="$BIN_PATH/crispctl" + +mkdir -p "$(dirname "$DESTINATION")" +cp "$SOURCE" "$DESTINATION" +if [ ! -x "$DESTINATION" ]; then + echo "ERROR: embedded crispctl is not executable: $DESTINATION" >&2 + exit 1 +fi + +ARCHITECTURE_OUTPUT="$(lipo -archs "$DESTINATION")" +read -r -a ARCHITECTURES <<< "$ARCHITECTURE_OUTPUT" +HAS_ARM64=false +HAS_X86_64=false +for architecture in "${ARCHITECTURES[@]}"; do + [ "$architecture" = "arm64" ] && HAS_ARM64=true + [ "$architecture" = "x86_64" ] && HAS_X86_64=true +done +if [ "${#ARCHITECTURES[@]}" -ne 2 ] || \ + [ "$HAS_ARM64" != true ] || [ "$HAS_X86_64" != true ]; then + echo "ERROR: embedded crispctl must contain exactly arm64 and x86_64; found: ${ARCHITECTURES[*]:-none}" >&2 + exit 1 +fi + +if [ -n "$SIGNING_ID" ]; then + codesign --force --options runtime --timestamp --sign "$SIGNING_ID" "$DESTINATION" +else + codesign --force --sign - "$DESTINATION" +fi diff --git a/scripts/release.sh b/scripts/release.sh index 1a99034..73bcad6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -24,6 +24,8 @@ cd "$ROOT" BUILD="$ROOT/build" APP="$BUILD/Crisp.app" DMG="$ROOT/Crisp.dmg" +CRISPCTL_SCRATCH="$BUILD/crispctl-swiftpm" +CRISPCTL_APP_BINARY="$APP/Contents/MacOS/crispctl" TAP_REPO="didriksg/homebrew-tap" TAP_CASK="Casks/crisp.rb" @@ -40,7 +42,7 @@ rm -rf "$BUILD"; mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$ROOT/scripts/fetch-sparkle.sh" echo "==> Compiling universal binary (arm64 + x86_64)…" -SRC=$(find Crisp -name '*.swift') +SRC=$(find Crisp Sources/CrispControlCore -name '*.swift') for a in arm64 x86_64; do swiftc -O -parse-as-library -target "$a-apple-macos14.0" \ -import-objc-header Crisp/Crisp-Bridging-Header.h \ @@ -128,19 +130,24 @@ SPARKLE_FW="$APP/Contents/Frameworks/Sparkle.framework" SPARKLE_NESTED=("$SPARKLE_FW/Versions/B/Autoupdate" "$SPARKLE_FW/Versions/B/Updater.app" "$SPARKLE_FW") if [ -n "${CRISP_SIGN_ID:-}" ]; then echo "==> Signing (Developer ID: $CRISP_SIGN_ID, hardened runtime)…" +else + echo "==> Signing (ad-hoc — set CRISP_SIGN_ID for a notarizable build)…" +fi +echo "==> Compiling and embedding universal crispctl (arm64 + x86_64)…" +"$ROOT/scripts/build-embed-sign-crispctl.sh" "$ROOT" "$APP" "$CRISPCTL_SCRATCH" "${CRISP_SIGN_ID:-}" +if [ -n "${CRISP_SIGN_ID:-}" ]; then for item in "${SPARKLE_NESTED[@]}"; do codesign --force --options runtime --timestamp --sign "$CRISP_SIGN_ID" "$item" done codesign --force --options runtime --timestamp \ --entitlements Crisp/Crisp.entitlements --sign "$CRISP_SIGN_ID" "$APP" else - echo "==> Signing (ad-hoc — set CRISP_SIGN_ID for a notarizable build)…" for item in "${SPARKLE_NESTED[@]}"; do codesign --force --sign - "$item" done codesign --force --sign - --entitlements Crisp/Crisp.entitlements "$APP" fi -codesign --verify --deep --strict "$APP" +"$ROOT/scripts/verify-release-app.sh" "$APP" # Notarize the app and staple the ticket BEFORE packaging, so the app validates # offline once dragged out of the DMG and the DMG's sha256 (computed below) is the @@ -190,6 +197,7 @@ fi SHA=$(shasum -a 256 "$DMG" | awk '{print $1}') echo "==> Built $DMG" echo " version $(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$APP/Contents/Info.plist"), archs $(lipo -archs "$APP/Contents/MacOS/Crisp"), sha256 $SHA" +echo " crispctl archs $(lipo -archs "$CRISPCTL_APP_BINARY")" if [ "$PUBLISH" != true ]; then echo "==> Dry run. Pass --publish to create the release and bump the tap." @@ -198,6 +206,39 @@ fi [ -n "$NOTES" ] && [ -f "$NOTES" ] || { echo "ERROR: --publish needs a notes file: ./scripts/release.sh $TAG notes.md --publish"; exit 1; } +# Preflight the Homebrew cask before any publish-side mutation. +echo "==> Preparing Homebrew tap update…" +TAP_CASK_RESPONSE="$BUILD/homebrew-cask-response.json" +PREPARED_TAP_CASK="$BUILD/crisp.rb" +gh api "repos/$TAP_REPO/contents/$TAP_CASK" > "$TAP_CASK_RESPONSE" +TAP_CASK_SHA="$( +python3 - "$TAP_CASK_RESPONSE" "$PREPARED_TAP_CASK" <<'PY' +import base64 +import binascii +import json +import sys +from pathlib import Path + +response = Path(sys.argv[1]) +prepared = Path(sys.argv[2]) +try: + payload = json.loads(response.read_text(encoding="utf-8")) + sha = payload["sha"] + encoded = payload["content"] + if not isinstance(sha, str) or not sha: + raise ValueError("missing cask sha") + if not isinstance(encoded, str): + raise ValueError("missing cask content") + compact = "".join(encoded.split()) + prepared.write_bytes(base64.b64decode(compact, validate=True)) +except (OSError, UnicodeError, json.JSONDecodeError, KeyError, ValueError, binascii.Error) as error: + raise SystemExit(f"ERROR: invalid Homebrew cask response: {error}") +print(sha) +PY +)" +python3 "$ROOT/scripts/update-homebrew-cask.py" "$PREPARED_TAP_CASK" \ + --version "$VERSION" --sha256 "$SHA" + # Keep project.yml (the Xcode build path) in sync with the version we shipped. sed -i '' "s/MARKETING_VERSION: \"[^\"]*\"/MARKETING_VERSION: \"${VERSION}\"/" project.yml @@ -219,14 +260,10 @@ cp "$DMG" "$APPCAST_STAGE/Crisp.dmg" -o "$ROOT/docs/appcast.xml" "$APPCAST_STAGE" echo "==> Bumping Homebrew tap…" -SHA_FILE=$(gh api "repos/$TAP_REPO/contents/$TAP_CASK" --jq '.sha') -gh api "repos/$TAP_REPO/contents/$TAP_CASK" --jq '.content' | base64 -d \ - | sed -e "s/version \"[^\"]*\"/version \"${VERSION}\"/" \ - -e "s/sha256 \"[^\"]*\"/sha256 \"${SHA}\"/" > "$BUILD/crisp.rb" gh api -X PUT "repos/$TAP_REPO/contents/$TAP_CASK" \ -f message="crisp ${VERSION}" \ - -f content="$(base64 -i "$BUILD/crisp.rb")" \ - -f sha="$SHA_FILE" --jq '.commit.sha' >/dev/null + -f content="$(base64 -i "$PREPARED_TAP_CASK")" \ + -f sha="$TAP_CASK_SHA" --jq '.commit.sha' >/dev/null echo "==> Released ${TAG} and updated the tap." echo "==> ACTION REQUIRED: commit and push docs/appcast.xml (+ project.yml bump)." diff --git a/scripts/test-crispctl-roundtrip.sh b/scripts/test-crispctl-roundtrip.sh new file mode 100755 index 0000000..2053d52 --- /dev/null +++ b/scripts/test-crispctl-roundtrip.sh @@ -0,0 +1,99 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRATCH="${1:-/tmp/crispctl-roundtrip-build}" +SOCKET_DIR="$(mktemp -d /tmp/crispctl-roundtrip.XXXXXX)" +SOCKET="$SOCKET_DIR/control.sock" +HOST_PID="" + +cleanup() { + if [ -n "$HOST_PID" ]; then + kill "$HOST_PID" 2>/dev/null || true + wait "$HOST_PID" 2>/dev/null || true + fi + rm -f "$SOCKET" + rmdir "$SOCKET_DIR" +} +trap cleanup EXIT + +cd "$ROOT" +BUILD_ARGS=(swift build --disable-sandbox --scratch-path "$SCRATCH") +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/crisp-spm-module-cache \ +CLANG_MODULE_CACHE_PATH=/tmp/crisp-clang-module-cache \ +"${BUILD_ARGS[@]}" +BIN_PATH="$( + SWIFTPM_MODULECACHE_OVERRIDE=/tmp/crisp-spm-module-cache \ + CLANG_MODULE_CACHE_PATH=/tmp/crisp-clang-module-cache \ + "${BUILD_ARGS[@]}" --show-bin-path +)" +HOST="$BIN_PATH/crisp-control-test-host" +CLI="$BIN_PATH/crispctl" + +"$HOST" "$SOCKET" & +HOST_PID=$! +for _ in {1..100}; do + [ -S "$SOCKET" ] && break + kill -0 "$HOST_PID" 2>/dev/null || { wait "$HOST_PID"; exit 1; } + sleep 0.01 +done +[ -S "$SOCKET" ] + +STATUS="$("$CLI" status --json --no-start --socket "$SOCKET")" +LIST="$("$CLI" displays list --json --no-start --socket "$SOCKET")" +EXTERNAL_UUID="$(jq -er ' + [.result.displays[] | select(.isBuiltin == false and .isVirtual == false)] + | if length == 1 then .[0].uuid + else error("expected exactly one non-builtin physical display") + end +' <<<"$LIST")" +SET="$("$CLI" brightness set builtin 55 --json --no-start --socket "$SOCKET")" +GET="$("$CLI" brightness get fixture-built-in --json --no-start --socket "$SOCKET")" +EXTRA_GET="$("$CLI" extra-brightness get fixture-built-in --json --no-start --socket "$SOCKET")" +EXTRA_SET="$("$CLI" extra-brightness set fixture-built-in on --json --no-start --socket "$SOCKET")" +BOOSTED_SET="$("$CLI" brightness set fixture-built-in 125 --json --no-start --socket "$SOCKET")" +HDR_GET="$("$CLI" hdr get AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA --json --no-start --socket "$SOCKET")" +HDR_SET="$("$CLI" hdr set AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA off --json --no-start --socket "$SOCKET")" +GET_ALL="$("$CLI" brightness get-all --json --no-start --socket "$SOCKET")" +if STRICT_SET_ALL="$("$CLI" brightness set-all 60 --json --no-start --socket "$SOCKET")"; then + echo "strict set-all unexpectedly accepted a display without a restore snapshot" >&2 + exit 1 +else + STRICT_SET_ALL_EXIT=$? +fi +STRICT_SET_ALL_BUILTIN="$("$CLI" brightness get fixture-built-in --json --no-start --socket "$SOCKET")" +SET_ALL="$("$CLI" brightness set-all 60 --allow-unrestorable --json --no-start --socket "$SOCKET")" +DISCONNECTED_BEFORE="$("$CLI" displays disconnected --json --no-start --socket "$SOCKET")" +DISCONNECT="$("$CLI" displays disconnect "$EXTERNAL_UUID" --json --no-start --socket "$SOCKET")" +DISCONNECTED_AFTER="$("$CLI" displays disconnected --json --no-start --socket "$SOCKET")" +DISCONNECTED_UUID="$(jq -r '.result.displays[0].uuid' <<<"$DISCONNECTED_AFTER")" +[ "$DISCONNECTED_UUID" = "$EXTERNAL_UUID" ] +RECONNECT="$("$CLI" displays reconnect "$DISCONNECTED_UUID" --json --no-start --socket "$SOCKET")" +DISCONNECTED_FINAL="$("$CLI" displays disconnected --json --no-start --socket "$SOCKET")" + +jq -e '.ok and .result.running' <<<"$STATUS" >/dev/null +jq -e '.ok and .result.displays[0].uuid == "fixture-built-in"' <<<"$LIST" >/dev/null +jq -e '.ok and .result.verification == "verified" and .result.readbackPercent == 55' <<<"$SET" >/dev/null +jq -e '.ok and .result.percent == 55' <<<"$GET" >/dev/null +jq -e '.ok and (.result.enabled | not) and .result.maxBrightness == 100' <<<"$EXTRA_GET" >/dev/null +jq -e '.ok and .result.enabled and .result.verification == "app_state_verified" and .result.maxBrightness == 150' <<<"$EXTRA_SET" >/dev/null +jq -e '.ok and .result.logicalPercent == 125 and .result.hardwareReadbackPercent == 100 and .result.verification == "app_state_verified"' <<<"$BOOSTED_SET" >/dev/null +jq -e '.ok and .result.enabled' <<<"$HDR_GET" >/dev/null +jq -e '.ok and (.result.enabled | not) and .result.verification == "verified"' <<<"$HDR_SET" >/dev/null +jq -e '.ok and .result.displays[0].displayUUID == "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" and .result.displays[1].displayUUID == "fixture-built-in"' <<<"$GET_ALL" >/dev/null +[ "$STRICT_SET_ALL_EXIT" -eq 4 ] +jq -e '(.ok | not) and .error.code == "batch_preflight_failed" and .error.details.restoreMode == "strict" and .error.details.missingRestoreSnapshotUUIDs == ["AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"]' <<<"$STRICT_SET_ALL" >/dev/null +jq -e '.ok and .result.logicalPercent == 125' <<<"$STRICT_SET_ALL_BUILTIN" >/dev/null +jq -e '.ok and .result.appliedUUIDs == ["AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "fixture-built-in"] and .result.restoreMode == "allow_unrestorable" and (.result.restoreSnapshotsComplete | not) and .result.manualRestorationUUIDs == ["AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"] and .result.outcomes[0].status == "written_unverified" and .result.outcomes[1].status == "written_verified"' <<<"$SET_ALL" >/dev/null +jq -e '.ok and .result.displays == []' <<<"$DISCONNECTED_BEFORE" >/dev/null +jq -e '.ok and .result.displayUUID == "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" and .result.requestedConnectionState == "disconnected" and .result.observedConnectionState == "disconnected" and .result.verification == "same_uuid_enumeration"' <<<"$DISCONNECT" >/dev/null +jq -e '.ok and (.result.displays | length) == 1 and .result.displays[0].uuid == "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" and (.result.displays[0].connection.connected | not) and .result.displays[0].connection.reconnectAllowed' <<<"$DISCONNECTED_AFTER" >/dev/null +jq -e '.ok and .result.displayUUID == "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" and .result.requestedConnectionState == "connected" and .result.observedConnectionState == "connected" and .result.verification == "same_uuid_enumeration"' <<<"$RECONNECT" >/dev/null +jq -e '.ok and .result.displays == []' <<<"$DISCONNECTED_FINAL" >/dev/null +[ "$(stat -f '%Lp' "$SOCKET")" = "600" ] + +kill "$HOST_PID" +wait "$HOST_PID" +HOST_PID="" + +echo "CRISPCTL_HEADLESS_ROUNDTRIP_OK" diff --git a/scripts/update-homebrew-cask.py b/scripts/update-homebrew-cask.py new file mode 100755 index 0000000..36a3db9 --- /dev/null +++ b/scripts/update-homebrew-cask.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Update Crisp's release cask without rewriting unrelated Ruby content.""" + +from __future__ import annotations + +import argparse +import os +import re +import stat +import tempfile +from pathlib import Path + + +BINARY_STANZA = 'binary "#{appdir}/Crisp.app/Contents/MacOS/crispctl"' +VERSION_LINE = re.compile(r'^(?P\s*)version\s+"[^"]*"\s*$') +SHA_LINE = re.compile(r'^(?P\s*)sha256\s+"[^"]*"\s*$') +APP_LINE = re.compile(r'^(?P\s*)app\s+"Crisp\.app"\s*$') +BINARY_LINE = re.compile( + r'^\s*binary\s+"#\{appdir\}/Crisp\.app/Contents/MacOS/crispctl"\s*$' +) +BLOCK_OPEN_LINE = re.compile(r"^(?!\s*#)\s*.*\bdo(?:\s+\|[^|]*\|)?\s*$") +SAFE_VERSION = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+-]*$") +SAFE_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") + + +class CaskTransformError(ValueError): + pass + + +def _line_body(line: str) -> str: + return line.rstrip("\r\n") + + +def _line_ending(line: str) -> str: + return line[len(_line_body(line)) :] + + +def _ruby_code_contains_brace(line: str) -> bool: + quote = None + escaped = False + for character in line: + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + continue + + if character == "#": + break + if character in ('"', "'"): + quote = character + elif character in "{}": + return True + + if quote is not None: + raise CaskTransformError("unterminated Ruby quoted string") + return False + + +def transform(content: str, version: str, sha256: str) -> str: + if not SAFE_VERSION.fullmatch(version): + raise CaskTransformError(f"unsafe version value: {version!r}") + if not SAFE_SHA256.fullmatch(sha256): + raise CaskTransformError("sha256 must contain exactly 64 hex characters") + + newline_styles = set(re.findall(r"\r\n|\r|\n", content)) + if newline_styles not in ({"\n"}, {"\r\n"}): + raise CaskTransformError("mixed or ambiguous newline style") + + lines = content.splitlines(keepends=True) + if not lines: + raise CaskTransformError("cask is empty") + preliminary_app_indexes = [ + index + for index, line in enumerate(lines) + if APP_LINE.fullmatch(_line_body(line)) + ] + if any(not _line_ending(lines[index]) for index in preliminary_app_indexes): + raise CaskTransformError("app stanza must end with a newline") + if _line_body(lines[0]) != 'cask "crisp" do' or _line_body(lines[-1]) != "end": + raise CaskTransformError('expected top-level cask "crisp" do wrapper') + + for line in lines[1:-1]: + if _ruby_code_contains_brace(_line_body(line)): + raise CaskTransformError("unsupported Ruby brace/block syntax") + + version_indexes = [] + sha_indexes = [] + app_indexes = [] + binary_indexes = [] + depth = 1 + for index, line in enumerate(lines[1:-1], start=1): + body = _line_body(line) + if re.fullmatch(r"\s*end\s*", body): + if depth == 1: + raise CaskTransformError("unexpected end inside top-level cask block") + depth -= 1 + continue + + version_match = VERSION_LINE.fullmatch(body) + sha_match = SHA_LINE.fullmatch(body) + app_match = APP_LINE.fullmatch(body) + binary_match = BINARY_LINE.fullmatch(body) + if any((version_match, sha_match, app_match, binary_match)) and depth != 1: + raise CaskTransformError( + "release stanzas must be in the top-level cask block" + ) + if version_match: + version_indexes.append(index) + if sha_match: + sha_indexes.append(index) + if app_match: + app_indexes.append(index) + if binary_match: + binary_indexes.append(index) + elif ( + not body.lstrip().startswith("#") + and "Contents/MacOS/crispctl" in body + ): + raise CaskTransformError( + "found an unrecognized crispctl cask stanza; refusing to guess" + ) + if BLOCK_OPEN_LINE.fullmatch(body): + depth += 1 + + if depth != 1: + raise CaskTransformError("unbalanced nested block in cask") + + for label, indexes in ( + ("version", version_indexes), + ("sha256", sha_indexes), + ('app "Crisp.app"', app_indexes), + ): + if len(indexes) != 1: + raise CaskTransformError( + f"expected exactly one {label} stanza, found {len(indexes)}" + ) + + app_index = app_indexes[0] + + version_index = version_indexes[0] + sha_index = sha_indexes[0] + version_match = VERSION_LINE.fullmatch(_line_body(lines[version_index])) + sha_match = SHA_LINE.fullmatch(_line_body(lines[sha_index])) + assert version_match is not None + assert sha_match is not None + lines[version_index] = ( + f'{version_match.group("indent")}version "{version}"' + f"{_line_ending(lines[version_index])}" + ) + lines[sha_index] = ( + f'{sha_match.group("indent")}sha256 "{sha256.lower()}"' + f"{_line_ending(lines[sha_index])}" + ) + + app_match = APP_LINE.fullmatch(_line_body(lines[app_index])) + assert app_match is not None + newline = _line_ending(lines[app_index]) + + without_binary = [ + line for index, line in enumerate(lines) if index not in set(binary_indexes) + ] + removed_before_app = sum(index < app_index for index in binary_indexes) + insertion_index = app_index - removed_before_app + 1 + stanza = f'{app_match.group("indent")}{BINARY_STANZA}{newline}' + without_binary.insert(insertion_index, stanza) + return "".join(without_binary) + + +def update_file(path: Path, version: str, sha256: str) -> None: + original = path.read_bytes().decode("utf-8", errors="strict") + updated = transform(original, version, sha256) + mode = stat.S_IMODE(path.stat().st_mode) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(updated.encode("utf-8", errors="strict")) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("cask", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--sha256", required=True) + arguments = parser.parse_args() + try: + update_file(arguments.cask, arguments.version, arguments.sha256) + except (CaskTransformError, OSError, UnicodeError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-release-app.sh b/scripts/verify-release-app.sh new file mode 100755 index 0000000..19cc0e0 --- /dev/null +++ b/scripts/verify-release-app.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +APP="${1:?Usage: ./scripts/verify-release-app.sh /path/to/Crisp.app}" +CRISPCTL="$APP/Contents/MacOS/crispctl" + +if [ ! -x "$CRISPCTL" ]; then + echo "ERROR: embedded crispctl is missing or not executable: $CRISPCTL" >&2 + exit 1 +fi + +ARCHITECTURE_OUTPUT="$(lipo -archs "$CRISPCTL")" +read -r -a ARCHITECTURES <<< "$ARCHITECTURE_OUTPUT" +HAS_ARM64=false +HAS_X86_64=false +for architecture in "${ARCHITECTURES[@]}"; do + [ "$architecture" = "arm64" ] && HAS_ARM64=true + [ "$architecture" = "x86_64" ] && HAS_X86_64=true +done +if [ "${#ARCHITECTURES[@]}" -ne 2 ] || \ + [ "$HAS_ARM64" != true ] || [ "$HAS_X86_64" != true ]; then + echo "ERROR: embedded crispctl must contain exactly arm64 and x86_64; found: ${ARCHITECTURES[*]:-none}" >&2 + exit 1 +fi + +codesign --verify --deep --strict "$APP" +echo "Verified Crisp.app: executable universal crispctl and strict app signature" diff --git a/skills/crispctl/SKILL.md b/skills/crispctl/SKILL.md new file mode 100644 index 0000000..7e5c918 --- /dev/null +++ b/skills/crispctl/SKILL.md @@ -0,0 +1,247 @@ +--- +name: crispctl +description: Use when controlling displays through Crisp's JSON CLI. +version: 0.1.0 +author: Juns (Juns-g), Hermes Agent +license: MIT +platforms: + - macos +metadata: + hermes: + tags: + - crisp + - display-control + - automation + related_skills: [] +--- + +# crispctl + +## When to Use + +Use this Skill to discover displays, inspect Crisp-supported capabilities, or +perform an explicitly requested P0 brightness, Extra Brightness, external HDR, +or P1 physical-display connection read/write through Crisp's +versioned JSON interface. Do not substitute macOS display APIs or another +display CLI: Crisp must remain the owner of display state. + +Discovery, P0 brightness/Extra Brightness/external-HDR, and the fail-closed P1 +physical disconnect/reconnect slice are supported. Do not emulate modes, +arrangement, presets, virtual displays, or any other P1/P2 operation. These +remain Tier 2/3 operations outside this Skill. +The HDR toggle is for eligible external displays only. Built-in HDR is not a +write surface; use Extra Brightness when its live capability is writable. + +## Prerequisites + +- Run on macOS 14 or later with a Crisp release that includes `crispctl`. +- Physical disconnect/reconnect uses the existing Apple Silicon and macOS 13+ + service gate. It is for physical displays only and requires positive + hardware-backed physical proof; third-party virtual, placeholder, and + unknown/unprovable displays must remain read-only/unsupported. +- Public installs gain `crispctl` only with the first Crisp release that + contains this distribution change. Crisp 1.5.0 does not contain the bundled + CLI. Source checkout users can build it. +- Prefer a Homebrew cask installation for a release that includes `crispctl`; + the cask exposes it on `PATH`. +- A manual DMG installation can use the executable embedded inside Crisp.app; + no system-wide symlink or `sudo` is required. + +### Install the Skill + +Install the Skill from the canonical +[repository source](https://github.com/didriksg/Crisp/tree/main/skills/crispctl) +for all agent types supported by the conventional installer: + +```sh +npx skills add didriksg/Crisp --skill crispctl -g --agent '*' -y +``` + +The quotes around `'*'` prevent shell expansion. Skill discovery is normally +evaluated when an agent session starts. Installing the Skill does not hot-load +it; a fresh agent session may be required. + +## How to Run + +### Preflight + +Use the agent's terminal tool. Resolve the executable and run the desired +command in the same shell call. Prefer `command -v crispctl`, then check the +standard system and current-user Applications locations. Stop if none is +executable; never guess or fall back to another display CLI. + +```sh +resolve_crispctl() { + candidate="$(command -v crispctl 2>/dev/null || true)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + for candidate in \ + /Applications/Crisp.app/Contents/MacOS/crispctl \ + "$HOME/Applications/Crisp.app/Contents/MacOS/crispctl" + do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +CRISPCTL="$(resolve_crispctl)" || { + echo "crispctl is not installed; install a Crisp release that ships it" >&2 + exit 127 +} + +"${CRISPCTL}" version --json +"${CRISPCTL}" displays list --json +``` + +Use only `--json` responses for automation. Follow this procedure: + +1. Run `"${CRISPCTL}" displays list --json` and select a display by UUID when + possible. `main` and `builtin` are explicit conveniences; names can be + ambiguous. +2. Before every write, run + `"${CRISPCTL}" displays capabilities --json` and verify that the + requested operation is normally `state: writable` and its value is within + the returned live range. Fresh discovery and the exact same UUID are + mandatory; UUID selectors are required when available. + The sole exception is `extra-brightness set off` when the fresh + same-UUID response has `state: unsupported` but proves cleanup is needed with + `persistedEnabled: true`, `enabled: true`, or `maxBrightness > 100`. + This exception never permits `on`, never permits unsupported HDR or + brightness writes, and never permits unsupported `off` without a cleanup + indicator. For every other missing capability, capability collapse, + stale display UUID, or read-back mismatch, stop and re-discover before + seeking a fresh user decision; never change targets or ranges. +3. Read with `"${CRISPCTL}" brightness get --json`. +4. Obtain explicit user authorization for the exact UUID(s), command, and + target before every write. For an explicitly requested supported write, run + `"${CRISPCTL}" brightness set --json`. No extra + confirmation is needed for that already-requested write. The cleanup-only + exception also requires explicit user authorization for the exact + `extra-brightness set off` command. +5. Extra Brightness uses `"${CRISPCTL}" extra-brightness get --json` + and `"${CRISPCTL}" extra-brightness set on|off --json`. External HDR + uses `"${CRISPCTL}" hdr get --json` and + `"${CRISPCTL}" hdr set on|off --json`; built-in displays direct the + user to Extra Brightness instead of exposing a fake HDR toggle. +6. Batch reads/writes use `"${CRISPCTL}" brightness get-all --json` and + `"${CRISPCTL}" brightness set-all --json`. The percent is the same + logical value per display, not a normalized fraction. + The set-all default is strict: every target must have a readable pre-write + restore snapshot or no display is changed. Never add `--allow-unrestorable` + implicitly. Use it only when the human explicitly authorizes that exact + override after accepting the listed missing-snapshot UUIDs, unverified writes, + and manual restoration. Batch writes are non-atomic: hardware members cannot + be rolled back safely, including in override mode. +7. Physical connection discovery uses + `"${CRISPCTL}" displays disconnected --json`. Disconnect uses + `"${CRISPCTL}" displays disconnect --json` only after a fresh + `displays list` plus `displays capabilities` response says the connection + capability is writable and `disconnectAllowed: true`. Disconnect accepts + only an exact UUID copied without normalization from that fresh response. + Names, `main`, and `builtin` are not accepted for disconnect. Crisp + re-resolves the same UUID immediately before the write and must never switch + targets. +8. Reconnect requires a fresh `displays disconnected` inventory. Copy its exact + UUID without normalization into + `"${CRISPCTL}" displays reconnect --json`. Never use a name, `main`, + `builtin`, a stale/collapsed record, or a last-known numeric display ID. +9. Obtain explicit user authorization for each display connection write. The + authorization must identify disconnect/reconnect, the exact UUID resolved + from fresh inventory, and the intended connection state. Never infer one + authorization from an earlier write or from a read request. +10. If the user forbids launching Crisp, append `--no-start` to the command. + Otherwise `crispctl` may use its bounded bundle-ID launch/readiness policy. + +## Pitfalls + +- Stop on an `ambiguous_selector` response and present candidate UUIDs. Never + guess a display. +- Treat `unsupported_capability`, `permission_required`, unavailable read-back, + warnings, and remediation text as real constraints rather than success. +- Connection capability must prove Apple Silicon/macOS 13+ support, a stable + UUID, and positive hardware-backed physical proof. A built-in panel is + positive proof; an external requires either a nonzero CoreGraphics service + that conforms to IOKit `IODisplayConnect`, or exactly one complete and equal + vendor/product/serial match in `IOMobileFramebuffer` + `DisplayAttributes.ProductAttributes` among entries publishing a non-empty + `EDID UUID`. EDID UUID presence only scopes external framebuffer candidates; + its value or contents are not identity proof. Any EDID-bearing incomplete or + zero identity, and zero or duplicate exact matches, fail closed. Non-EDID + built-in and inactive entries are ignored as candidates. DDC support, display + name, dimensions, IOKit `Location`, EDID prefix or contents, traversal order, + and vendor/product without serial are not proof. Crisp virtual, third-party + virtual, placeholder, and unknown/unprovable displays do not count. + Disconnect must also prove that it will not remove the last active physical + viewable display. Any failed gate stops before mutation; do not bypass it with + another display tool. +- A `write_outcome_indeterminate` response means an in-flight hardware callback + may still apply; do not retry it automatically. There is no automatic retry. + Read back the selected display, + explain that the timed-out write may still complete, and require a fresh user + decision before any later write. +- Apply that same `write_outcome_indeterminate`, `retrySafe:false`, read-back, + and fresh-user-decision rule to brightness, Extra Brightness, external HDR, + display connection, and batch mutations. For display connection, use the + explicit read-reconcile-fresh-decision rule: read both `displays list` and + `displays disconnected`, reconcile only same-UUID truth, then seek a fresh + authorized decision. Never automatically retry disconnect or reconnect. + Every connection timeout includes the exact `displayUUID`, command, requested + state, exit code 5, and `retrySafe:false`; a response missing that identity is + not safe to reconcile or retry. + The cleanup-only exception does not change + `write_outcome_indeterminate`, `retrySafe: false`, separate read-back, fresh + user decision, or no automatic retry rules. +- If Extra Brightness disable returns `ok: true` with + `verification: settling`, it was accepted and persisted off, but terminal + cleanup is still in progress. Treat the returned state and warning as + transitional, read back before another write, and make no automatic retry. +- A `batch_partial_failure` contains applied, failed, and possibly indeterminate + UUIDs. This is a partial failure: do not retry the whole batch or any + successful/indeterminate member. + Read every affected UUID. Only `retrySafe:true` members may be reconsidered, + and only after reconciliation plus a fresh user decision; never retry them + automatically. +- Treat `--allow-unrestorable` as a separate, human-deliberate authorization, + not a fallback after strict preflight failure. It does not bypass capability, + range, snapshot-error, timeout, or cancellation gates. Require returned + `restoreMode: allow_unrestorable`, inspect each member's `status` and + `warnings`, and report every `manualRestorationUUIDs` entry. A missing restore + snapshot means Crisp cannot supply the original value; the user must restore + that display manually. Never describe this non-atomic mode as a restorable + transaction. +- Do not infer success from exit status alone. Parse the single JSON value on + stdout and require `ok: true`; for batch responses also parse item results, + verification, warnings, and each member's retry safety. + +## Verification + +For discovery and reads, verify `ok: true`, the expected selector/UUID, and the +returned capability or brightness fields. In the Extra Brightness headroom +snapshot, `appliedFactor` with `factorVerification: app_state` records the last +factor Crisp committed; it is not independent EDR or hardware read-back, and +`null` means no committed factor is known. Report `requestID` when diagnosing +a failure. + +For a successful brightness write, inspect `verification`, `readbackPercent`, +`logicalPercent`, `hardwareReadbackPercent`, and `warnings` as well as +`ok: true`. `app_state_verified` and `settling` are app/overlay truth, not +hardware-authoritative proof of EDR output. If the outcome is indeterminate, the only +safe verification is a separate read followed by a fresh user decision; an +automatic retry is forbidden. + +For `brightness set-all`, also inspect `status`, `restoreSnapshotAvailable`, +`manualRestorationRequired`, aggregate `missingRestoreSnapshotUUIDs`, and +`manualRestorationUUIDs`. `written_unverified` is not verified success; surface +its warnings and manual-restoration requirement to the user. + +For successful display connection writes, require the exact `displayUUID`, the +authorized `requestedConnectionState`, matching `observedConnectionState`, +`verification: same_uuid_enumeration`, and reviewed `warnings`. Transaction +return alone is not proof. On `write_outcome_indeterminate`, exit code 5, or +`retrySafe:false`, report that WindowServer may still complete, perform the +read-reconcile-fresh-decision flow above, and make no automatic retry.