diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b5063ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,80 @@ +name: Bug report +description: Report a problem with OpenDisplay +title: "[bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Please redact identifying data (display serials, usernames). + If a display became unreachable, note which recovery action you used. + - type: input + id: app-version + attributes: + label: OpenDisplay version + placeholder: "e.g. 0.3.0 (build 123)" + validations: + required: true + - type: dropdown + id: build-flavor + attributes: + label: Build flavor + options: ["Full", "Public-API-only"] + validations: + required: true + - type: input + id: macos + attributes: + label: macOS version / build + placeholder: "e.g. 15.5 (24F74)" + validations: + required: true + - type: input + id: mac-model + attributes: + label: Mac model & chip + placeholder: "e.g. MacBook Pro 14\" M3 Pro (Apple Silicon)" + validations: + required: true + - type: input + id: displays + attributes: + label: Display model(s) + placeholder: "e.g. LG UltraFine 4K + built-in Retina" + validations: + required: true + - type: dropdown + id: route + attributes: + label: Connection route + multiple: true + options: ["Direct USB-C/DP", "HDMI", "Thunderbolt dock", "USB-C hub", "KVM", "Adapter", "Wireless (Sidecar/AirPlay)"] + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + validations: + required: true + - type: textarea + id: expected-actual + attributes: + label: Expected vs actual behavior + validations: + required: true + - type: dropdown + id: recovery + attributes: + label: Was a recovery action needed? + options: ["No", "Reconnect All", "Automatic rollback", "Rescue utility", "Safe mode", "Manual intervention"] + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / diagnostics bundle + description: Attach a redacted diagnostics bundle if available. diff --git a/.github/ISSUE_TEMPLATE/compatibility_report.yml b/.github/ISSUE_TEMPLATE/compatibility_report.yml new file mode 100644 index 0000000..9a54c73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility_report.yml @@ -0,0 +1,63 @@ +name: Compatibility report +description: Report hardware/OS compatibility results for a capability +title: "[compat]: " +labels: ["compatibility"] +body: + - type: markdown + attributes: + value: | + Helps build the certified compatibility matrix. **Redact serials and other + identifying data.** + - type: input + id: mac-model + attributes: + label: Mac model & chip + placeholder: "e.g. Mac mini M2 (Apple Silicon)" + validations: + required: true + - type: input + id: macos + attributes: + label: macOS build + placeholder: "e.g. 15.5 (24F74)" + validations: + required: true + - type: input + id: display + attributes: + label: Display model & firmware + placeholder: "e.g. Dell U2720Q (firmware M3B103)" + validations: + required: true + - type: dropdown + id: route + attributes: + label: Route + options: ["Direct", "Thunderbolt dock", "USB-C hub", "KVM", "Adapter", "Wireless"] + validations: + required: true + - type: dropdown + id: capability + attributes: + label: Capability tested + options: + - Logical disconnect / reconnect + - DDC brightness + - DDC volume / contrast / input + - Resolution / refresh / rotation modes + - HDR / XDR + - Mirroring / main display + - Virtual display + validations: + required: true + - type: dropdown + id: result + attributes: + label: Result + options: ["Works (verified)", "Works (unverified read-back)", "Degraded", "Fails"] + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..12c5891 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report (private) + url: https://github.com/aquitaine/opendisplay/security/policy + about: Please report vulnerabilities privately — do not open a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..bc288e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem / use-case + description: What are you trying to do? What's painful today? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + validations: + required: true + - type: dropdown + id: domain + attributes: + label: Related capability domain + options: + - Display lifecycle & topology + - Modes, scaling & geometry + - Brightness, audio, color & input + - Virtual displays, capture & presentation + - Automation & integrations + - Diagnostics, configuration & recovery + - User experience & accessibility + - Other + validations: + required: true + - type: dropdown + id: tier + attributes: + label: Core or Labs? + description: Labs = experimental / system-sensitive / undocumented behavior. + options: ["Core", "Labs", "Not sure"] + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c7cf340 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + +## Summary + + + +Closes # + +## Type of change + +- [ ] Bug fix +- [ ] Feature +- [ ] Refactor / internal +- [ ] Docs +- [ ] Lifecycle / recovery / safety (requires threat & recovery review) + +## Checklist + +- [ ] **Clean-room:** this contribution is my original work, or its source and license are + identified. No proprietary code, copied UI, copy, or assets. +- [ ] Tests added/updated (unit/state-machine for logic; hardware evidence for provider changes). +- [ ] `make test` passes locally (`swift test` green) and SwiftLint is clean. (No remote CI — local verification is the gate.) +- [ ] The **public-API-only** build still compiles with experimental providers absent (NFR-010). +- [ ] Docs updated where behavior changed. +- [ ] Commits are signed off (`git commit -s`, DCO). + +## Safety & recovery + +- [ ] This PR does **not** touch lifecycle, the transaction coordinator, checkpoints, the + rescue path, startup, IPC, capture, update, or network. +- [ ] If it does: I have described new failure modes and how recovery stays guaranteed, and + requested a threat & recovery review (RFC linked if applicable). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dce5424 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Swift / SwiftPM +.build/ +.swiftpm/ +Package.resolved.user +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ + +# Xcode — the project is generated by XcodeGen (`make xcode`); do not commit it. +/OpenDisplay.xcodeproj/ +xcuserdata/ +*.xcuserstate +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# macOS +.DS_Store + +# Release / signing artifacts (never commit secrets or signed binaries) +*.app +*.pkg +*.dmg +*.zip +ExportOptions.plist +*.p12 +*.cer +*.provisionprofile +notarization-*.json + +# Editor +.idea/ +*.swp + +# Local build output (XcodeGen `make build` writes here) and local agent/session config +build/ +.claude/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..fdf336f --- /dev/null +++ b/.swift-format @@ -0,0 +1,18 @@ +{ + "version": 1, + "lineLength": 110, + "indentation": { "spaces": 4 }, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false, + "indentConditionalCompilationBlocks": false, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLowerCamelCase": true, + "NeverForceUnwrap": false, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "ReturnVoidInsteadOfEmptyTuple": true + } +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..69924ff --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,46 @@ +# SwiftLint configuration for OpenDisplay. +# Keep rules pragmatic; the domain packages are the strictest because they encode safety logic. +included: + - Packages + - Providers + - Apps + - Tools +excluded: + - .build + - "**/.build" + - Tests/Fixtures + +opt_in_rules: + - empty_count + - explicit_init + - first_where + - sorted_imports + - unused_import + - redundant_nil_coalescing + - closure_spacing + - operator_usage_whitespace + +line_length: + warning: 120 + error: 160 + ignores_comments: true + +type_body_length: + warning: 400 + error: 600 + +file_length: + warning: 600 + error: 1000 + +function_body_length: + warning: 80 + error: 140 + +identifier_name: + min_length: 2 + excluded: [id, x, y, dx, dy, to, on] + +cyclomatic_complexity: + warning: 12 + error: 20 diff --git a/Apps/OpenDisplay/README.md b/Apps/OpenDisplay/README.md new file mode 100644 index 0000000..6d72285 --- /dev/null +++ b/Apps/OpenDisplay/README.md @@ -0,0 +1,16 @@ +# OpenDisplay (app) + +**macOS app target** (SwiftUI + AppKit). The menu-bar popover (primary surface) and the +settings window, built from `Packages/OpenDisplayDesignSystem`. Hosts the dependency +composition root: `DisplayRegistry`, `TopologyCoordinator`, providers, stores, and the +`RecoveryService` (Reconnect All + global hotkey). + +Surfaces (PRD §8.1): menu-bar root, topology workspace, display detail, scenes, automation, +health & recovery, Labs. The UI consumes immutable snapshots and submits commands through the +command gateway — it never mutates domain state directly. + +Milestone: **M1 (menu-bar + connect/disconnect) → M3 (Core 1.0)**. + +> Scaffolded: run `make xcode` to generate the project, then build/run the `OpenDisplay` +> scheme. The app currently runs against `SimulatedDisplaySystem`; real providers land in M0. +> Sources: `Apps/OpenDisplay/Sources` (`OpenDisplayApp`, `AppModel`, `MenuBarView`, `SettingsView`). diff --git a/Apps/OpenDisplay/Resources/Info.plist b/Apps/OpenDisplay/Resources/Info.plist new file mode 100644 index 0000000..170fb5e --- /dev/null +++ b/Apps/OpenDisplay/Resources/Info.plist @@ -0,0 +1,23 @@ + + + + + CFBundleName + OpenDisplay + CFBundleDisplayName + OpenDisplay + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundlePackageType + APPL + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + LSUIElement + + + diff --git a/Apps/OpenDisplay/Resources/OpenDisplay.entitlements b/Apps/OpenDisplay/Resources/OpenDisplay.entitlements new file mode 100644 index 0000000..1b41e94 --- /dev/null +++ b/Apps/OpenDisplay/Resources/OpenDisplay.entitlements @@ -0,0 +1,14 @@ + + + + + + com.apple.security.app-sandbox + + + diff --git a/Apps/OpenDisplay/Sources/AppModel.swift b/Apps/OpenDisplay/Sources/AppModel.swift new file mode 100644 index 0000000..e0cfcc1 --- /dev/null +++ b/Apps/OpenDisplay/Sources/AppModel.swift @@ -0,0 +1,1250 @@ +#if os(macOS) +import AppKit +import CoreGraphicsProvider +import DisplayDomain +import Foundation +import ProviderInterfaces +import SceneEngine +import TopologyCore +#if !PUBLIC_API_ONLY +import ExperimentalLifecycleProvider +#endif + +/// A hardware (DDC) control the menu can offer for an external display. Public-safe (no private-SPI +/// types), so the menu can reference it in every build; AppModel maps it to a DDC VCP code. +enum HardwareControl: CaseIterable, Hashable { + case contrast, volume + + var vcp: UInt8 { + switch self { + case .contrast: return 0x12 + case .volume: return 0x62 + } + } + var label: String { + switch self { + case .contrast: return "Contrast" + case .volume: return "Volume" + } + } + var icon: String { + switch self { + case .contrast: return "circle.lefthalf.filled" + case .volume: return "speaker.wave.2" + } + } +} + +/// Which route a display's unified brightness slider drives. Resolved per display when brightness is +/// read: built-in panels use the OS (`native`), externals that answer DDC use `hardware`, and anything +/// else falls back to `software` gamma dimming (works on any display). Surfaced as a small caption so +/// the single slider stays honest about *how* it's dimming. +enum BrightnessMethod: String, Hashable { + case native, hardware, software + + /// Caption shown beneath the slider. `native` needs none — it *is* the system brightness. + var caption: String? { + switch self { + case .native: return nil + case .hardware: return "Hardware · DDC" + case .software: return "Software · gamma" + } + } +} + +/// Build/runtime feature flags for the experimental control paths (PRD: risky behaviour is opt-in). +enum FeatureFlags { + /// ICC profile writing uses *public* ColorSync, so it's App-Store-safe and on by default. + static var iccProfileWrite: Bool { true } + #if !PUBLIC_API_ONLY + /// Rotation writing uses a *private* API — OFF unless explicitly enabled, and the whole path is + /// compiled out of the public-API-only / App Store build. + static var experimentalRotation: Bool { UserDefaults.standard.bool(forKey: "OpenDisplayExperimentalRotation") } + #else + static var experimentalRotation: Bool { false } + #endif +} + +/// The app's composition root. It wires the platform-independent `TopologyCoordinator` +/// (Packages/TopologyCore) to a display system and exposes an observable snapshot for the UI. +/// +/// M0: observation comes from the real `CoreGraphicsProvider` (live enumeration + a +/// reconfiguration event source). The lifecycle path prefers the experimental SkyLight provider +/// (true logical disconnect, full build only) and falls back to `CoreGraphicsProvider`'s public, +/// reversible mirroring approach — selected by `RoutedLifecycleProvider`. In the public-API-only +/// build the experimental module is absent and the public provider is used directly. +@MainActor +final class AppModel: ObservableObject { + @Published private(set) var displays: [DisplayObservation] = [] + @Published private(set) var statusText = "Scanning…" + @Published private(set) var busy = false + @Published private(set) var diagnostics: [DisplayDiagnostic] = [] + @Published private(set) var phase: DisplayLoadPhase = .scanning + @Published private(set) var recentActivity: [AuditEntry] = [] + /// Identity records for the current displays, keyed by the observation's record id. + @Published private(set) var records: [DisplayRecordID: DisplayRecord] = [:] + @Published private(set) var scenes: [Scene] = [] + /// Non-fatal note from the last scene apply (e.g. rotation skipped) — shown in the Scenes tab. + @Published var sceneWarning: String? + /// Displays the app has logically turned off. The OS drops them from the online list, so we track + /// them here to keep an "off" card in the menu (with a way back on) and to feed the safety net. + @Published private(set) var managedOffline: [OfflineDisplay] = [] + /// Cached brightness (0...1) for displays we can control — built-in via DisplayServices, externals + /// via DDC, or software gamma as a universal fallback. A missing key means "not yet read". + @Published private(set) var brightness: [DisplayRecordID: Float] = [:] + /// The route the unified brightness slider drives for each display (resolved on read). + @Published private(set) var brightnessMethod: [DisplayRecordID: BrightnessMethod] = [:] + /// The display selected in the Settings → Displays detail pane. The menu bar sets this when it + /// deep-links into Settings ("Display settings…"), so the right display is shown on arrival. + @Published var selectedDisplayID: DisplayRecordID? + /// Opt-in toggle for the experimental (private-API) rotation writer; persisted to UserDefaults. + /// Drives the rotation backend live and stays false in the public-API-only build. + @Published var experimentalRotationEnabled: Bool = FeatureFlags.experimentalRotation { + didSet { UserDefaults.standard.set(experimentalRotationEnabled, forKey: "OpenDisplayExperimentalRotation") } + } + /// Cached DDC hardware-control levels (0...1) keyed by display then VCP code (contrast/volume). + @Published private(set) var ddcControlLevel: [DisplayRecordID: [UInt8: Float]] = [:] + /// Per-display software (gamma) dim level, 1 = no dim. Applies on top of hardware brightness and + /// works on any display, including DDC-less externals and below the hardware minimum. + @Published private(set) var softwareDim: [DisplayRecordID: Float] = [:] + /// Current DDC input-source code (VCP 0x60) per external display. + @Published private(set) var inputSource: [DisplayRecordID: Int] = [:] + /// Current DDC colour-preset code (VCP 0x14) per external display, and the max code it reports. + @Published private(set) var colorPreset: [DisplayRecordID: Int] = [:] + @Published private(set) var colorPresetMax: [DisplayRecordID: Int] = [:] + /// Current ICC colour-profile name per display (ColorSync), for the Colour profile row. + @Published private(set) var colorProfileName: [DisplayRecordID: String] = [:] + /// Whether each display exposes a ColorSync device that profile writes can target — resolved + /// off-main and cached so the menu never probes ColorSync during a view body. + @Published private(set) var colorProfileControllable: [DisplayRecordID: Bool] = [:] + /// Installed ICC profiles the user can assign, enumerated off-main once and cached (the scan reads + /// and parses every installed profile from disk, so it must never run during a SwiftUI body). + @Published private(set) var availableColorProfilesCache: [ICCProfile] = [] + + /// Standard DDC colour-preset labels (VCP 0x14). Monitors vary; the menu offers 1...max and labels + /// the standard ones, falling back to "Preset N". + static let presetNames: [Int: String] = [ + 1: "sRGB", 2: "Display native", 3: "4000K", 4: "5000K", 5: "6500K", + 6: "7500K", 7: "8200K", 8: "9300K", 9: "10000K", 11: "User 1", + ] + func presetName(_ code: Int) -> String { Self.presetNames[code] ?? "Preset \(code)" } + + /// Common DDC/CI input-source codes (VCP 0x60). Monitors mostly follow these; the menu shows the + /// live code too, so a non-standard panel is still legible. + static let standardInputs: [(name: String, code: Int)] = [ + ("HDMI 1", 0x11), ("HDMI 2", 0x12), ("DisplayPort 1", 0x0F), ("DisplayPort 2", 0x10), + ("USB-C", 0x1B), ("DVI", 0x03), ("VGA", 0x01), + ] + + /// Human label for a DDC input code, or "Code N" if non-standard. + func inputName(_ code: Int) -> String { + Self.standardInputs.first { $0.code == code }?.name ?? "Code \(code)" + } + + /// A display OpenDisplay turned off — remembered (and persisted) so it stays visible and + /// re-enableable even across app restarts, and so the watchdog can always recover it. + struct OfflineDisplay: Identifiable, Equatable, Codable { + let recordID: DisplayRecordID + let cgID: CGDirectDisplayID + let name: String + let displayClass: DisplayClass + var id: DisplayRecordID { recordID } + } + + /// Count of currently-active displays — the UI disables the off-toggle on the last one. + var activeDisplayCount: Int { displays.filter(\.isActive).count } + + /// True when any provider isn't fully supported — drives the menu-bar caution banner. + var isDegraded: Bool { diagnostics.contains { $0.status != "supported" } } + + private let observer: CoreGraphicsProvider + private let coordinator: TopologyCoordinator + private let checkpoints: any CheckpointStoring + private let lifecycle: any LifecycleProvider + /// Read-only by default; the experimental SkyLight rotator is selected only when the opt-in toggle + /// is on (and is compiled out of the public-API-only build entirely). Computed so toggling the + /// setting takes effect immediately, without a relaunch. + private var rotationBackend: any RotationBackend { + #if !PUBLIC_API_ONLY + if experimentalRotationEnabled { return ExperimentalRotationBackend() } + #endif + return ReadOnlyRotationBackend() + } + #if !PUBLIC_API_ONLY + private let brightnessControl = DisplayServicesBrightnessProvider() + private var ddc: [DisplayRecordID: ExternalDisplayDDC] = [:] + /// In-flight DDC-controller constructions, so concurrent first-uses of one display await the same + /// build instead of each spinning up (and binding) a duplicate IOAVService. + private var ddcBuilders: [DisplayRecordID: Task] = [:] + private var brightnessMax: [DisplayRecordID: Int] = [:] + private var ddcTarget: [DisplayRecordID: Int] = [:] + private var ddcWriters: [DisplayRecordID: Task] = [:] + private struct DDCControlKey: Hashable { let id: DisplayRecordID; let vcp: UInt8 } + private var ddcControlMax: [DDCControlKey: Int] = [:] + private var ddcControlTarget: [DDCControlKey: Int] = [:] + private var ddcControlWriter: [DDCControlKey: Task] = [:] + private var inputSourceTarget: [DisplayRecordID: Int] = [:] + private var inputSourceWriter: [DisplayRecordID: Task] = [:] + private var colorPresetTarget: [DisplayRecordID: Int] = [:] + private var colorPresetWriter: [DisplayRecordID: Task] = [:] + #endif + private var hotKey: GlobalHotKey? + private var registry: DisplayRegistry? + private var sceneLibrary: SceneLibrary? + let settings: OpenDisplaySettings + + init() { + let observer = CoreGraphicsProvider() + self.observer = observer + let checkpoints = AppModel.makeCheckpointStore() + self.checkpoints = checkpoints + let lifecycle = AppModel.makeLifecycleProvider(public: observer) + self.lifecycle = lifecycle + self.coordinator = TopologyCoordinator( + observer: observer, + lifecycleProvider: lifecycle, + checkpoints: checkpoints, + // A disconnect from the menu/CLI is an explicit user action, so confirm the SafetyEngine's + // `.needsConfirmation` cases (e.g. turning off the current main). The engine's hard + // `.blocked` cases — chiefly "this would leave no active display" — are NOT bypassable here. + confirm: { _, _ in true } + ) + self.settings = AppModel.loadSettings() + // Always-available global Reconnect-All (recovery hierarchy step 3): reachable even when the + // menu bar isn't. Skipped if disabled in settings; falls back to the menu-bar item if the + // chord can't be registered. + if settings.reconnectAllHotkeyEnabled { + self.hotKey = GlobalHotKey.reconnectAll { [weak self] in + #if DEBUG + AppModel.debugMarkHotKeyFired() + #endif + Task { await self?.reconnectAll() } + } + } + #if DEBUG + let hotkeyState = settings.reconnectAllHotkeyEnabled + ? (hotKey != nil ? "registered" : "FAILED") + : "disabled in settings" + FileHandle.standardError.write(Data("Global Reconnect-All hotkey (Ctrl-Opt-Cmd-R) \(hotkeyState)\n".utf8)) + #endif + Task { + await setUpRegistry() + await setUpScenes() + await loadManagedOffline() + await refresh() + await enforceActiveSurfaceInvariant() // recover if we launched into a stranded (0-active) state + await writeBaselineCheckpoint() + if let marker = Self.readRotationMarker() { + // A prior experimental rotation didn't confirm — restore that display's safe angle and + // a safe layout, then clear the marker. + try? await rotationBackend.setRotation(marker.safeAngle, for: marker.cgID) + _ = await coordinator.reconnectAll() + Self.clearRotationMarker() + await refresh() + } + #if DEBUG + if let token = ProcessInfo.processInfo.environment["OPENDISPLAY_DISCONNECT"] { + await debugDisconnectCycle(token: token) + } + #endif + } + Task { await observeTopologyChanges() } + // A software gamma dim persists until logout; restore on quit so it never outlives the app. + NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { _ in + CoreGraphicsProvider.restoreGamma() + } + } + + /// Builds the lifecycle provider: experimental-primary + public-fallback in the full build, + /// the public provider alone in the public-API-only build. + private static func makeLifecycleProvider(public publicProvider: CoreGraphicsProvider) -> any LifecycleProvider { + #if PUBLIC_API_ONLY + return publicProvider + #else + return RoutedLifecycleProvider(primary: ExperimentalLifecycleProvider(), fallback: publicProvider) + #endif + } + + /// Loads persisted user settings, or defaults if the store can't be resolved/read. + private static func loadSettings() -> OpenDisplaySettings { + (try? SettingsStore.defaultDirectory()).map(SettingsStore.init(directory:))?.load() ?? .default + } + + /// Persistent, rescue-readable checkpoints in Application Support, falling back to in-memory + /// only if that directory can't be resolved. + private static func makeCheckpointStore() -> any CheckpointStoring { + if let directory = try? DiskCheckpointStore.defaultDirectory() { + return DiskCheckpointStore(directory: directory) + } + return InMemoryCheckpointStore() + } + + /// Records the current arrangement as a last-known-safe baseline so the rescue utility has + /// something to restore even before any disconnect runs (PRD §9.4). + private func writeBaselineCheckpoint() async { + let snapshot = await observer.currentSnapshot() + let checkpoint = Checkpoint( + transactionID: TransactionID(rawValue: "txn_baseline"), + generation: snapshot.generation, + observations: snapshot.observations, + mainDisplayID: snapshot.observations.first(where: { $0.isMain })?.recordID, + managedOffline: snapshot.managedOffline + ) + try? await checkpoints.writeAtomic(checkpoint) + } + + /// A human-readable name for a display: the OS-provided localized name when the display is live + /// (e.g. "Built-in Retina Display", "S34J55x"), otherwise a class + resolution fallback, and + /// finally the stable record ID. Identity-resolved aliases land later (PRD D-009). + func displayName(for observation: DisplayObservation) -> String { + if let alias = records[observation.recordID]?.alias, !alias.isEmpty { + return alias + } + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + if let cgID = observation.cgDisplayID, + let screen = NSScreen.screens.first(where: { + ($0.deviceDescription[screenNumberKey] as? NSNumber)?.uint32Value == cgID + }) { + return screen.localizedName + } + if observation.displayClass == .builtIn { return "Built-in Display" } + if let mode = observation.mode { + return "\(observation.displayClass.rawValue.capitalized) · \(mode.pixelWidth)×\(mode.pixelHeight)" + } + return observation.recordID.rawValue + } + + /// Where the rescue-readable checkpoints live, shown in Settings → Diagnostics & Recovery. + var checkpointLocation: String { + (try? DiskCheckpointStore.defaultDirectory().path) ?? "(unavailable)" + } + + /// The bound global recovery hotkey, shown in Settings. + let reconnectAllHotkey = "⌃⌥⌘R" + + /// Probes the observation + lifecycle providers and publishes their status for Settings. + func refreshDiagnostics() async { + #if arch(arm64) + let appleSilicon = true + #else + let appleSilicon = false + #endif + let environment = ProviderEnvironment( + osBuild: ProcessInfo.processInfo.operatingSystemVersionString, + isAppleSilicon: appleSilicon, transport: .unknown, displayClass: .unknown + ) + let observation = await observer.probe(environment) + let lifecycleProbe = await lifecycle.probe(environment) + // Probes are static for a given environment, so republish only when something actually changed + // (this is read on every topology event to drive the menu-bar "degraded" banner via isDegraded). + let updated = [ + DisplayDiagnostic(provider: "Core Graphics (observation)", status: observation.status.rawValue, + risk: observation.risk.rawValue, experimental: observer.isExperimental, + reasons: observation.reasons.map(\.rawValue)), + DisplayDiagnostic(provider: "Lifecycle (disconnect / reconnect)", status: lifecycleProbe.status.rawValue, + risk: lifecycleProbe.risk.rawValue, experimental: lifecycle.isExperimental, + reasons: lifecycleProbe.reasons.map(\.rawValue)) + ] + if diagnostics != updated { diagnostics = updated } + } + + /// Loads the most recent audit-log entries for Settings → Recent Activity. + func refreshActivity() async { + guard let directory = try? DiskAuditLog.defaultDirectory() else { return } + recentActivity = await DiskAuditLog(directory: directory).recent(limit: 8).reversed() + } + + private func setUpRegistry() async { + let store: any RegistryStoring = + (try? DiskRegistryStore.defaultDirectory()).map { DiskRegistryStore(directory: $0) } + ?? InMemoryRegistryStore() + registry = await DisplayRegistry(store: store) + } + + /// Resolves each live display's fingerprint into the registry (recognizing or minting), so the + /// menu bar and Settings can show user aliases and remember them across reconnects. + private func resolveRecords(_ snapshot: TopologySnapshot) async { + guard let registry else { return } + let observer = self.observer + let observations = snapshot.observations.filter { $0.cgDisplayID != nil } + guard !observations.isEmpty else { if !records.isEmpty { records = [:] }; return } + // EDID fingerprint reads are nonisolated CG/IOKit accessors — gather them OFF the main actor, + // then resolve the whole set in ONE batched (single disk-write) registry call. + let prepared: [(DisplayRecordID, DisplayFingerprint, String?, DisplayClass)] = + await Task.detached(priority: .utility) { + observations.compactMap { obs -> (DisplayRecordID, DisplayFingerprint, String?, DisplayClass)? in + guard let cgID = obs.cgDisplayID else { return nil } + return (obs.recordID, observer.fingerprint(for: cgID), obs.cgUUID, obs.displayClass) + } + }.value + let resolved = await registry.resolveAll( + prepared.map { (fingerprint: $0.1, cgUUID: $0.2, displayClass: $0.3) } + ) + var byID: [DisplayRecordID: DisplayRecord] = [:] + for (input, record) in zip(prepared, resolved) { byID[input.0] = record } + records = byID + } + + private func setUpScenes() async { + let store: any SceneStoring = + (try? DiskSceneStore.defaultDirectory()).map { DiskSceneStore(directory: $0) } + ?? InMemorySceneStore() + let library = await SceneLibrary(store: store) + sceneLibrary = library + scenes = await library.all() + } + + private static func managedOfflineURL() -> URL? { + (try? DiskCheckpointStore.defaultDirectory())?.appendingPathComponent("managed-offline.json") + } + + /// Persists the managed-offline list so a turned-off display survives an app restart as a + /// recoverable off-card (the in-memory-only list was lost on restart, stranding the display). + private func persistManagedOffline() { + guard let url = Self.managedOfflineURL() else { return } + try? JSONEncoder().encode(managedOffline).write(to: url, options: .atomic) + } + + /// Loads persisted off-displays and reconciles them against the live topology: any that are back + /// online + active are dropped (they returned on their own); the rest remain as off-cards. + private func loadManagedOffline() async { + guard let url = Self.managedOfflineURL(), let data = try? Data(contentsOf: url), + let saved = try? JSONDecoder().decode([OfflineDisplay].self, from: data) else { return } + let snapshot = await observer.currentSnapshot() + let activeIDs = Set(snapshot.activeDisplays.map(\.recordID)) + managedOffline = saved.filter { !activeIDs.contains($0.recordID) } + if managedOffline != saved { persistManagedOffline() } + } + + /// Captures the current arrangement as a named scene (upsert by name). + func saveScene(named name: String) async { + guard let sceneLibrary else { return } + let snapshot = await observer.currentSnapshot() + let id = await sceneLibrary.scene(named: name)?.id ?? "scene_\(UUID().uuidString.prefix(8))" + let scene = SceneRecorder.capture(from: snapshot, name: name, id: String(id)) + await sceneLibrary.save(scene) + scenes = await sceneLibrary.all() + } + + /// Moves a single display to a new origin (used by the drag-to-arrange canvas), then re-reads + /// the resulting topology (Core Graphics may adjust neighbours to keep the layout adjacent). + func setPosition(_ origin: DisplayOrigin, for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + _ = observer.applyArrangement([.init(displayID: cgID, origin: origin, mode: nil)]) + await refresh() + } + + /// Mirrors a display onto the main display (both show the same content) or stops mirroring. + /// Reversible (public Core Graphics mirroring). + func setMirrored(_ on: Bool, for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + _ = await observer.setMirroring(of: cgID, enabled: on) + await refresh() + } + + /// Sets a display's software (gamma) dim, 0.15...1 where 1 = no dim. Works on any display. + func setSoftwareDim(_ level: Float, for observation: DisplayObservation) { + guard let cgID = observation.cgDisplayID else { return } + softwareDim[observation.recordID] = level + observer.setGammaDim(level, for: cgID) + } + + /// Displays currently blacked out (gamma driven to zero — the panel stays logically connected so it + /// can be restored instantly). Reversible and public-API-safe; gamma also resets on display + /// reconfiguration, wake, and logout, so a blackout can never strand a surface. + @Published private(set) var blackedOut: Set = [] + + /// True when the display is currently blacked out. + func isBlackedOut(_ observation: DisplayObservation) -> Bool { + blackedOut.contains(observation.recordID) + } + + /// Toggles Black Out: drive gamma to zero, or restore the display's effective dim level. + func toggleBlackOut(for observation: DisplayObservation) { + guard let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + if blackedOut.contains(id) { + blackedOut.remove(id) + observer.setGammaDim(softwareDim[id] ?? 1.0, for: cgID) + } else { + blackedOut.insert(id) + observer.setGammaDim(0.0, for: cgID) + } + } + + /// Read-only display metadata (EDID-derived) for the menu's info panel. + func displayInfo(for observation: DisplayObservation) -> [(label: String, value: String)] { + guard let cgID = observation.cgDisplayID else { return [] } + var info: [(String, String)] = [ + ("Name", displayName(for: observation)), + ("Type", observation.displayClass == .builtIn ? "Built-in" : "External"), + ] + let vendor = CGDisplayVendorNumber(cgID) + let model = CGDisplayModelNumber(cgID) + let serial = CGDisplaySerialNumber(cgID) + if vendor != 0, vendor != 0xFFFF_FFFF { info.append(("Vendor", String(vendor))) } + if model != 0, model != 0xFFFF_FFFF { info.append(("Model", String(model))) } + if serial != 0 { info.append(("Serial", String(serial))) } + if let mode = observation.mode { + info.append(("Native", "\(mode.pixelWidth) × \(mode.pixelHeight)")) + info.append(("Refresh", "\(Int(mode.refreshHz.rounded())) Hz")) + } + let size = CGDisplayScreenSize(cgID) + if size.width > 0, size.height > 0 { + let inches = (size.width * size.width + size.height * size.height).squareRoot() / 25.4 + info.append(("Size", String(format: "%.1f-inch", inches))) + } + return info + } + + /// Applies a saved scene's positions + modes to the current displays (user-triggered). + func applyScene(_ scene: Scene) async { + let snapshot = await observer.currentSnapshot() + var targets: [CoreGraphicsProvider.ArrangementTarget] = [] + var rotationSkipped = false + for member in scene.members { + guard let observation = resolveSceneMember(member.selector, in: snapshot), + let cgID = observation.cgDisplayID else { continue } + // Rotation writes aren't safely supported — skip the property but still apply the rest, + // surfacing a non-fatal note (PRD: scenes apply everything they safely can). + if let wanted = member.desired.rotation, wanted != observation.rotation { rotationSkipped = true } + targets.append(.init(displayID: cgID, origin: member.desired.position, mode: member.desired.mode)) + } + _ = observer.applyArrangement(targets) + await refresh() + sceneWarning = rotationSkipped + ? "Applied. Rotation in this scene was skipped — not supported on this macOS version." + : nil + } + + func deleteScene(_ scene: Scene) async { + guard let sceneLibrary else { return } + await sceneLibrary.delete(id: scene.id) + scenes = await sceneLibrary.all() + } + + /// Resolves a scene member selector to a current observation (id:/alias:/tag:/main/builtin). + private func resolveSceneMember(_ selector: String, in snapshot: TopologySnapshot) -> DisplayObservation? { + if selector.hasPrefix("id:") { + return snapshot.observation(for: DisplayRecordID(rawValue: String(selector.dropFirst("id:".count)))) + } + if selector.hasPrefix("alias:") { + let alias = String(selector.dropFirst("alias:".count)) + if let id = records.first(where: { $0.value.alias == alias })?.key { return snapshot.observation(for: id) } + } + if selector.hasPrefix("tag:") { + let tag = String(selector.dropFirst("tag:".count)) + if let id = records.first(where: { $0.value.tags.contains(tag) })?.key { return snapshot.observation(for: id) } + } + if selector == "main" { return snapshot.observations.first { $0.isMain } } + if selector == "builtin" { return snapshot.observations.first { $0.displayClass == .builtIn } } + return nil + } + + /// Sets the user alias for a display and re-resolves so the change shows immediately. + func setAlias(_ alias: String, for observation: DisplayObservation) async { + guard let registry, let record = records[observation.recordID] else { return } + await registry.setAlias(alias, for: record.id) + await refresh() + } + + /// Drops cached control values (brightness, DDC, ICC, dim) for displays that are no longer present, + /// so a reconnected display re-reads fresh state instead of showing a stale cached value. Only + /// reassigns a cache when it actually has a stale key, to avoid needless UI invalidation. + private func pruneControlCaches(to ids: Set) { + if !brightness.keys.allSatisfy(ids.contains) { brightness = brightness.filter { ids.contains($0.key) } } + if !brightnessMethod.keys.allSatisfy(ids.contains) { brightnessMethod = brightnessMethod.filter { ids.contains($0.key) } } + if !ddcControlLevel.keys.allSatisfy(ids.contains) { ddcControlLevel = ddcControlLevel.filter { ids.contains($0.key) } } + if !colorPreset.keys.allSatisfy(ids.contains) { colorPreset = colorPreset.filter { ids.contains($0.key) } } + if !inputSource.keys.allSatisfy(ids.contains) { inputSource = inputSource.filter { ids.contains($0.key) } } + if !colorProfileName.keys.allSatisfy(ids.contains) { colorProfileName = colorProfileName.filter { ids.contains($0.key) } } + if !softwareDim.keys.allSatisfy(ids.contains) { softwareDim = softwareDim.filter { ids.contains($0.key) } } + if !colorProfileControllable.keys.allSatisfy(ids.contains) { colorProfileControllable = colorProfileControllable.filter { ids.contains($0.key) } } + if !blackedOut.allSatisfy(ids.contains) { blackedOut = blackedOut.filter(ids.contains) } + #if !PUBLIC_API_ONLY + pruneDDCCaches(to: ids) + #endif + } + + #if !PUBLIC_API_ONLY + /// Releases DDC infrastructure (controllers + their retained IOAVService handles, coalescing maps, + /// and writer tasks) for displays no longer present, so handles don't accumulate across reconnect + /// cycles. Writer tasks are cancelled before their controller is dropped so a draining task can't + /// re-acquire and recreate a removed entry; controllers are lazily rebuilt on next use. + private func pruneDDCCaches(to ids: Set) { + for (id, task) in ddcWriters where !ids.contains(id) { task.cancel(); ddcWriters[id] = nil } + for (id, task) in inputSourceWriter where !ids.contains(id) { task.cancel(); inputSourceWriter[id] = nil } + for (id, task) in colorPresetWriter where !ids.contains(id) { task.cancel(); colorPresetWriter[id] = nil } + for (key, task) in ddcControlWriter where !ids.contains(key.id) { task.cancel(); ddcControlWriter[key] = nil } + for (id, task) in ddcBuilders where !ids.contains(id) { task.cancel(); ddcBuilders[id] = nil } + ddc = ddc.filter { ids.contains($0.key) } + brightnessMax = brightnessMax.filter { ids.contains($0.key) } + ddcTarget = ddcTarget.filter { ids.contains($0.key) } + inputSourceTarget = inputSourceTarget.filter { ids.contains($0.key) } + colorPresetTarget = colorPresetTarget.filter { ids.contains($0.key) } + ddcControlMax = ddcControlMax.filter { ids.contains($0.key.id) } + ddcControlTarget = ddcControlTarget.filter { ids.contains($0.key.id) } + } + #endif + + func refresh() async { + var snapshot = await observer.currentSnapshot() + // Another display-manager app (e.g. BetterDisplay) holding a reconfiguration can make + // enumeration transiently empty or error; a Mac running this app always has ≥1 display, so + // re-poll briefly before trusting an empty list — otherwise the menu blanks out entirely. + var attempts = 0 + while snapshot.observations.isEmpty && attempts < 8 { + try? await Task.sleep(nanoseconds: 100_000_000) + snapshot = await observer.currentSnapshot() + attempts += 1 + } + displays = snapshot.observations.sorted { $0.recordID.rawValue < $1.recordID.rawValue } + pruneControlCaches(to: Set(displays.map(\.recordID))) + // Drop any tracked off-display that has come back on its own (e.g. re-enabled elsewhere). + let priorOffline = managedOffline + managedOffline.removeAll { offline in + displays.contains { $0.recordID == offline.recordID && $0.isActive } + } + if managedOffline != priorOffline { persistManagedOffline() } + // Guard against a no-op republish: the active/total count string is usually unchanged across + // a refresh, and reassigning @Published fires objectWillChange and re-evaluates every view. + let status = "\(snapshot.activeDisplays.count) active · \(snapshot.observations.count) total" + if statusText != status { statusText = status } + phase = displays.isEmpty ? .empty : .ready + await resolveRecords(snapshot) + await refreshDiagnostics() + #if DEBUG + if ProcessInfo.processInfo.environment["OPENDISPLAY_DUMP"] != nil { + Self.dump(snapshot) + let names = displays.map { "cgID=\($0.cgDisplayID ?? 0) → \"\(displayName(for: $0))\"" } + .joined(separator: ", ") + FileHandle.standardError.write(Data("names: \(names)\n".utf8)) + if !managedOffline.isEmpty { + let offline = managedOffline.map { "\($0.name)(cgid:\($0.cgID))" }.joined(separator: ", ") + FileHandle.standardError.write(Data("managedOffline: \(offline)\n".utf8)) + } + } + #endif + } + + // MARK: - Menu controls (Phase 1) + + /// Available resolutions for a display, de-duplicated per point-size — drives the resolution slider. + func availableModes(for observation: DisplayObservation) -> [DisplayMode] { + guard let cgID = observation.cgDisplayID else { return [] } + return observer.availableModes(for: cgID) + } + + /// Every mode (un-deduped) for a display — the detail view caches this once per display and filters + /// it locally for the resolution list, refresh rates, and HiDPI toggle, avoiding three separate + /// CGDisplayCopyAllDisplayModes enumerations per render. + func allModes(for observation: DisplayObservation) -> [DisplayMode] { + guard let cgID = observation.cgDisplayID else { return [] } + return observer.allModes(for: cgID) + } + + /// Resolves and caches the best brightness route for a display, then reads its current level: + /// built-in via DisplayServices (`native`), external via DDC (`hardware`), or — when neither + /// answers — software gamma (`software`), which works on any display including DDC-less externals. + /// This is what lets the popover show a single, always-usable brightness slider. + func refreshBrightness(for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + #if !PUBLIC_API_ONLY + if observation.displayClass == .builtIn { + // DisplayServices is private SPI with a blocking IPC round-trip — read it off the main actor. + let control = brightnessControl + if let value = await Task.detached(priority: .userInitiated, operation: { + control.brightness(for: cgID) + }).value { + brightness[id] = value + brightnessMethod[id] = .native + return + } + } else if let controller = await ddcController(for: observation), + let reading = await controller.read(.brightness), reading.max > 0 { + brightness[id] = Float(reading.current) / Float(reading.max) + brightnessMax[id] = reading.max + brightnessMethod[id] = .hardware + return + } + #endif + // Universal fallback: software gamma is public Core Graphics and works on every display. + brightnessMethod[id] = .software + brightness[id] = softwareDim[id] ?? 1.0 + } + + /// The caption for a display's brightness slider ("Hardware · DDC", "Software · gamma"), or nil for + /// native control where no qualifier is needed. + func brightnessCaption(for observation: DisplayObservation) -> String? { + brightnessMethod[observation.recordID]?.caption + } + + /// Sets a display's brightness (0...1) through whichever route was resolved for it, updating the + /// cache optimistically. Native writes are immediate; DDC writes are coalesced so a fast slider + /// drag never floods the I2C bus; the software route maps onto gamma dimming with a usable floor. + func setBrightness(_ value: Float, for observation: DisplayObservation) { + guard let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + brightness[id] = value + #if PUBLIC_API_ONLY + let method = BrightnessMethod.software + #else + let method = brightnessMethod[id] ?? (observation.displayClass == .builtIn ? .native : .hardware) + #endif + switch method { + case .native: + #if !PUBLIC_API_ONLY + // Private DisplayServices SPI off the main actor; the optimistic cache is already updated. + // DisplayServices is fast IPC (unlike slow I2C), so a fire-and-forget per tick is fine — + // no DDC-style coalescing needed. + let control = brightnessControl + Task.detached(priority: .userInitiated) { _ = control.setBrightness(value, for: cgID) } + #endif + case .hardware: + #if !PUBLIC_API_ONLY + ddcTarget[id] = Int((value * Float(brightnessMax[id] ?? 100)).rounded()) + if ddcWriters[id] == nil { + ddcWriters[id] = Task { [weak self] in await self?.drainDDCWrites(id, observation) } + } + #endif + case .software: + let gamma = max(0.15, value) + softwareDim[id] = gamma + observer.setGammaDim(gamma, for: cgID) + } + } + + #if !PUBLIC_API_ONLY + /// Returns (and caches) the DDC controller for an external display, building it OFF the main actor + /// — `ExternalDisplayDDC.init` does `dlopen` + IOKit registry enumeration, which must not run on + /// the UI thread when a display row first expands. Concurrent first-uses await one shared build, + /// so a display can't end up with two bound IOAVService handles. + private func ddcController(for observation: DisplayObservation) async -> ExternalDisplayDDC? { + let id = observation.recordID + if let existing = ddc[id] { return existing } + if let building = ddcBuilders[id] { return await building.value } + guard let cgID = observation.cgDisplayID else { return nil } + let builder = Task.detached(priority: .utility) { ExternalDisplayDDC(displayID: cgID) } + ddcBuilders[id] = builder + let controller = await builder.value + ddcBuilders[id] = nil + if let controller { ddc[id] = controller } + return controller + } + + private func drainDDCWrites(_ id: DisplayRecordID, _ observation: DisplayObservation) async { + guard let controller = await ddcController(for: observation) else { ddcWriters[id] = nil; return } + while let target = ddcTarget[id] { + ddcTarget[id] = nil + await controller.write(.brightness, target) + } + ddcWriters[id] = nil + } + #endif + + /// Cached level (0...1) of a DDC hardware control, or nil if the display doesn't report it. + func ddcControl(_ control: HardwareControl, for observation: DisplayObservation) -> Float? { + ddcControlLevel[observation.recordID]?[control.vcp] + } + + /// Reads every hardware (DDC) control for an external display into the cache. Skips the built-in + /// and any feature the panel reports as unsupported. No-op in the public-API-only build. + func refreshHardwareControls(for observation: DisplayObservation) async { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn, let controller = await ddcController(for: observation) else { return } + let id = observation.recordID + for control in HardwareControl.allCases { + guard let feature = ExternalDisplayDDC.Feature(rawValue: control.vcp), + let reading = await controller.read(feature), reading.max > 0 else { continue } + ddcControlLevel[id, default: [:]][control.vcp] = Float(reading.current) / Float(reading.max) + ddcControlMax[DDCControlKey(id: id, vcp: control.vcp)] = reading.max + } + #endif + } + + /// Sets a DDC hardware control (0...1), updating the cache optimistically and coalescing the + /// I2C writes the same way brightness does. + func setHardwareControl(_ control: HardwareControl, _ value: Float, for observation: DisplayObservation) { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn else { return } + let id = observation.recordID + let key = DDCControlKey(id: id, vcp: control.vcp) + ddcControlLevel[id, default: [:]][control.vcp] = value + ddcControlTarget[key] = Int((value * Float(ddcControlMax[key] ?? 100)).rounded()) + if ddcControlWriter[key] == nil { + ddcControlWriter[key] = Task { [weak self] in await self?.drainHardwareWrites(key, control, observation) } + } + #endif + } + + #if !PUBLIC_API_ONLY + private func drainHardwareWrites(_ key: DDCControlKey, _ control: HardwareControl, _ observation: DisplayObservation) async { + guard let feature = ExternalDisplayDDC.Feature(rawValue: control.vcp), + let controller = await ddcController(for: observation) else { ddcControlWriter[key] = nil; return } + while let target = ddcControlTarget[key] { + ddcControlTarget[key] = nil + await controller.write(feature, target) + } + ddcControlWriter[key] = nil + } + #endif + + /// Reads the external display's current DDC input source into the cache. + func refreshInputSource(for observation: DisplayObservation) async { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn, let controller = await ddcController(for: observation), + let reading = await controller.read(.inputSource) else { return } + inputSource[observation.recordID] = reading.current + #endif + } + + /// Switches the external display's DDC input source to `code` (e.g. HDMI/DisplayPort). User-driven. + /// Coalesced through a single per-display writer (like brightness/contrast) so rapid selections + /// settle the panel on the last choice and can't reorder on the shared I2C bus. + func setInputSource(_ code: Int, for observation: DisplayObservation) { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn else { return } + let id = observation.recordID + inputSource[id] = code + inputSourceTarget[id] = code + if inputSourceWriter[id] == nil { + inputSourceWriter[id] = Task { [weak self] in await self?.drainInputSourceWrites(id, observation) } + } + #endif + } + + #if !PUBLIC_API_ONLY + private func drainInputSourceWrites(_ id: DisplayRecordID, _ observation: DisplayObservation) async { + guard let controller = await ddcController(for: observation) else { inputSourceWriter[id] = nil; return } + while let target = inputSourceTarget[id] { + inputSourceTarget[id] = nil + await controller.write(.inputSource, target) + } + inputSourceWriter[id] = nil + } + #endif + + /// Refreshes a display's cached ICC state — controllability, current profile name, and (once) the + /// installed-profile list — all OFF the main actor, since ColorSync iterates and parses profiles + /// from disk. The menu reads only the published caches and never touches ColorSync in a view body. + func refreshColorProfile(for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + let needProfiles = availableColorProfilesCache.isEmpty + let result = await Task.detached(priority: .userInitiated) { + (controllable: ColorProfileService.isControllable(cgID), + name: ColorProfileService.currentProfileName(for: cgID), + profiles: needProfiles ? ColorProfileService.availableProfiles() : nil) + }.value + colorProfileControllable[id] = result.controllable + colorProfileName[id] = result.name + if let profiles = result.profiles { availableColorProfilesCache = profiles } + } + + /// Assigns an ICC profile to a display (validated by ColorSyncProfileVerify inside the service), + /// then re-reads the applied name off-main ("verify, don't assume"). User-driven. + func setColorProfile(_ profile: ICCProfile, for observation: DisplayObservation) async { + guard FeatureFlags.iccProfileWrite, let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + let name = await Task.detached(priority: .userInitiated) { () -> String? in + ColorProfileService.setProfile(profile, for: cgID) + return ColorProfileService.currentProfileName(for: cgID) + }.value + colorProfileName[id] = name + } + + /// Reverts a display to its factory ICC profile, then re-reads the resulting name off-main. + func resetColorProfile(for observation: DisplayObservation) async { + guard FeatureFlags.iccProfileWrite, let cgID = observation.cgDisplayID else { return } + let id = observation.recordID + let name = await Task.detached(priority: .userInitiated) { () -> String? in + ColorProfileService.resetToFactory(for: cgID) + return ColorProfileService.currentProfileName(for: cgID) + }.value + colorProfileName[id] = name + } + + /// Current rotation of a display in degrees (0/90/180/270), read via public Core Graphics. + func currentRotation(for observation: DisplayObservation) -> Int { + guard let cgID = observation.cgDisplayID else { return 0 } + return rotationBackend.currentRotation(for: cgID) + } + + /// The reason rotation writes are unavailable (drives the read-only UI label), or nil if writable. + var rotationUnavailableReason: String? { + if case .unavailable(let reason) = rotationBackend.capability { return reason } + return nil + } + + /// Whether rotation writes are available (the experimental backend is enabled). + var rotationWritable: Bool { + if case .experimental = rotationBackend.capability { return true } + return false + } + + /// Rotates a display (experimental path). Writes a recovery marker first so a stranded layout is + /// detected + recovered on next launch; on failure runs Reconnect All; clears the marker after. + func setRotation(_ degrees: Int, for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + busy = true + defer { busy = false } + let safeAngle = rotationBackend.currentRotation(for: cgID) + Self.writeRotationMarker(RotationMarker(cgID: cgID, safeAngle: safeAngle)) + do { + try await rotationBackend.setRotation(degrees, for: cgID) + } catch { + // The helper validates + rolls back itself, but ensure the safe angle is restored and a + // safe surface remains even if the helper died before its own rollback. + try? await rotationBackend.setRotation(safeAngle, for: cgID) + _ = await coordinator.reconnectAll() + } + Self.clearRotationMarker() + await refresh() + } + + /// Pending-rotation marker: records which display was being rotated and the angle it was at before, + /// so that if the app/helper dies mid-rotation, the next launch can restore that exact safe angle. + private struct RotationMarker: Codable { let cgID: CGDirectDisplayID; let safeAngle: Int } + + private static func rotationMarkerURL() -> URL? { + (try? DiskCheckpointStore.defaultDirectory())?.appendingPathComponent("rotation.pending") + } + private static func writeRotationMarker(_ marker: RotationMarker) { + guard let url = rotationMarkerURL(), let data = try? JSONEncoder().encode(marker) else { return } + try? data.write(to: url, options: .atomic) + } + private static func readRotationMarker() -> RotationMarker? { + guard let url = rotationMarkerURL(), let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(RotationMarker.self, from: data) + } + private static func clearRotationMarker() { + if let url = rotationMarkerURL() { try? FileManager.default.removeItem(at: url) } + } + + /// Opens System Settings → Displays — the supported way to rotate on this macOS. + func openDisplaySettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.Displays-Settings.extension", + "x-apple.systempreferences:com.apple.preference.displays", + ] + for string in candidates { + if let url = URL(string: string), NSWorkspace.shared.open(url) { return } + } + } + + /// Reads the external display's current DDC colour preset (VCP 0x14) + its max code into the cache. + func refreshColorPreset(for observation: DisplayObservation) async { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn, let controller = await ddcController(for: observation), + let reading = await controller.read(.colorPreset) else { return } + colorPreset[observation.recordID] = reading.current + colorPresetMax[observation.recordID] = max(reading.max, 1) + #endif + } + + /// Sets the external display's DDC colour preset (sRGB / colour-temperature / native). User-driven. + /// Coalesced per display (like input source) so rapid taps settle on the last choice in order. + func setColorPreset(_ code: Int, for observation: DisplayObservation) { + #if !PUBLIC_API_ONLY + guard observation.displayClass != .builtIn else { return } + let id = observation.recordID + colorPreset[id] = code + colorPresetTarget[id] = code + if colorPresetWriter[id] == nil { + colorPresetWriter[id] = Task { [weak self] in await self?.drainColorPresetWrites(id, observation) } + } + #endif + } + + #if !PUBLIC_API_ONLY + private func drainColorPresetWrites(_ id: DisplayRecordID, _ observation: DisplayObservation) async { + guard let controller = await ddcController(for: observation) else { colorPresetWriter[id] = nil; return } + while let target = colorPresetTarget[id] { + colorPresetTarget[id] = nil + await controller.write(.colorPreset, target) + } + colorPresetWriter[id] = nil + } + #endif + + /// Applies a chosen resolution/mode, then re-reads the topology. + func setMode(_ mode: DisplayMode, for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID else { return } + _ = observer.applyArrangement([.init(displayID: cgID, origin: nil, mode: mode)]) + await refresh() + } + + /// Refresh rates available at the display's current resolution (same point size + HiDPI), descending. + func refreshRates(for observation: DisplayObservation) -> [Double] { + guard let cgID = observation.cgDisplayID, let current = observation.mode else { return [] } + let rates = observer.allModes(for: cgID) + .filter { $0.pointWidth == current.pointWidth && $0.pointHeight == current.pointHeight && $0.isHiDPI == current.isHiDPI } + .map { ($0.refreshHz * 10).rounded() / 10 } + return Array(Set(rates)).sorted(by: >) + } + + /// Switches the refresh rate at the current resolution. + func setRefresh(_ hz: Double, for observation: DisplayObservation) async { + guard var target = observation.mode else { return } + target.refreshHz = hz + await setMode(target, for: observation) + } + + /// True when the current resolution offers both a HiDPI (Retina) and a non-HiDPI variant. + func hiDPIToggleAvailable(for observation: DisplayObservation) -> Bool { + guard let cgID = observation.cgDisplayID, let current = observation.mode else { return false } + let modes = observer.allModes(for: cgID) + .filter { $0.pointWidth == current.pointWidth && $0.pointHeight == current.pointHeight } + return modes.contains(where: { $0.isHiDPI }) && modes.contains(where: { !$0.isHiDPI }) + } + + /// Switches the current resolution between HiDPI (Retina) and non-HiDPI, keeping the best refresh. + func setHiDPI(_ on: Bool, for observation: DisplayObservation) async { + guard let cgID = observation.cgDisplayID, let current = observation.mode else { return } + let candidate = observer.allModes(for: cgID) + .filter { $0.pointWidth == current.pointWidth && $0.pointHeight == current.pointHeight && $0.isHiDPI == on } + .max(by: { $0.refreshHz < $1.refreshHz }) + guard let candidate else { return } + await setMode(candidate, for: observation) + } + + /// Makes a display the main display by re-anchoring every origin so this one sits at (0,0) — + /// Core Graphics treats the display at the origin as main. + func setMain(for observation: DisplayObservation) async { + guard !observation.isMain else { return } + let snapshot = await observer.currentSnapshot() + let dx = -observation.origin.x + let dy = -observation.origin.y + let targets = snapshot.observations.compactMap { obs -> CoreGraphicsProvider.ArrangementTarget? in + guard let cgID = obs.cgDisplayID else { return nil } + return .init(displayID: cgID, + origin: DisplayOrigin(x: obs.origin.x + dx, y: obs.origin.y + dy), + mode: nil) + } + _ = observer.applyArrangement(targets) + await refresh() + } + + /// Turns a live display off (flagship). Routes through the coordinator, which preflights and + /// refuses to remove the last active surface; on success the display is remembered as + /// managed-offline so it keeps an "off" card in the menu. Works on any display, the built-in + /// included, as long as another stays active. + func setDisplayActive(_ active: Bool, for observation: DisplayObservation) async { + guard !active else { return } // turning back on is handled by reconnectOffline + busy = true + defer { busy = false } + let offline = OfflineDisplay( + recordID: observation.recordID, + cgID: observation.cgDisplayID ?? 0, + name: displayName(for: observation), + displayClass: observation.displayClass) + let result = try? await coordinator.disconnect( + observation.recordID, + options: DisconnectOptions(actor: .ui, identityConfidence: 1.0)) + if case .committed? = result { + managedOffline.removeAll { $0.recordID == offline.recordID } + managedOffline.append(offline) + persistManagedOffline() + } + await refresh() + } + + /// Turns a previously turned-off display back on. Reconnects by raw display id (a disabled + /// display drops off the online list, so UUID resolution can fail), then drops it from the + /// managed-offline list and re-reads the topology. + func reconnectOffline(_ offline: OfflineDisplay) async { + busy = true + defer { busy = false } + let reconnectID = offline.cgID != 0 + ? DisplayRecordID(rawValue: "cgid:\(offline.cgID)") + : offline.recordID + try? await lifecycle.reconnect(reconnectID, deadline: Date().addingTimeInterval(10)) + managedOffline.removeAll { $0.recordID == offline.recordID } + persistManagedOffline() + await refresh() + } + + /// Long-lived subscription to the observer's reconfiguration events: every hotplug, unplug, + /// sleep, or enable/disable refreshes the UI and re-checks the always-one-active invariant. + private func observeTopologyChanges() async { + let stream = await observer.changes() + for await _ in stream { + await refresh() + await enforceActiveSurfaceInvariant() + } + } + + /// The "one display is always active" safety net. If a topology change leaves nothing active — + /// e.g. the last external is physically unplugged while the built-in is logically off — re-enable + /// the built-in (or the most-recently disabled display) so the user is never left black-screened. + private func enforceActiveSurfaceInvariant() async { + guard !busy, displays.filter(\.isActive).isEmpty else { return } + let fallback = managedOffline.first(where: { $0.displayClass == .builtIn }) ?? managedOffline.last + guard let fallback else { return } + #if DEBUG + Self.err("ACTIVE-SURFACE GUARD: 0 active displays — re-enabling \(fallback.name)") + #endif + await reconnectOffline(fallback) + } + + /// Emergency recovery — always available (PRD LIF-010). With live observation and no + /// managed-offline displays yet, this is a safe no-op until a disconnect path is exercised. + func reconnectAll() async { + busy = true + defer { busy = false } + _ = await coordinator.reconnectAll() + await refresh() + } + + #if DEBUG + /// Diagnostic dump of the observed topology to stderr, gated on `OPENDISPLAY_DUMP` so it is + /// silent in normal runs. Run the app binary directly with the env var set to verify live + /// enumeration without needing the menu-bar UI. DEBUG-only (excluded from release / App Store). + private static func dump(_ snapshot: TopologySnapshot) { + var out = "OpenDisplay topology \(snapshot.generation):\n" + for o in snapshot.observations.sorted(by: { $0.recordID.rawValue < $1.recordID.rawValue }) { + let mode = o.mode.map { "\($0.pixelWidth)x\($0.pixelHeight)@\(Int($0.refreshHz.rounded()))" } ?? "—" + out += " \(o.isActive ? "●" : "○") \(o.recordID.rawValue)" + out += " cgID=\(o.cgDisplayID ?? 0)\(o.isMain ? " [main]" : "")" + out += " \(o.displayClass.rawValue) \(mode) origin=(\(o.origin.x),\(o.origin.y))" + out += o.isMirrored ? " mirrors=\(o.mirrorSourceID?.rawValue ?? "")" : "" + out += "\n" + } + FileHandle.standardError.write(Data(out.utf8)) + } + + /// M0 live-test harness (DEBUG only, gated on `OPENDISPLAY_DISCONNECT=`): runs + /// one real disconnect through the full coordinator transaction, logs the result + stage path + /// to stderr, then **always reconnects after 3s** so a live test can never strand a display. + /// Never target the main display — the coordinator blocks removing the last safe surface. + private func debugDisconnectCycle(token: String) async { + let snapshot = await observer.currentSnapshot() + guard let observation = snapshot.observations.first(where: { + $0.cgDisplayID.map(String.init) == token || $0.recordID.rawValue == token + }) else { + Self.err("DISCONNECT: no display matches \(token)") + return + } + let target = observation.recordID + // Reconnect by raw display ID so restore can't fail on UUID resolution after the display + // drops off the online list while logically disabled. + let reconnectID = observation.cgDisplayID.map { DisplayRecordID(rawValue: "cgid:\($0)") } ?? target + + Self.err("DISCONNECT target \(target.rawValue) (cgID \(observation.cgDisplayID ?? 0)) — running coordinator transaction…") + do { + let result = try await coordinator.disconnect( + target, options: DisconnectOptions(actor: .cli, identityConfidence: 1.0) + ) + let stages = await coordinator.lastTransition.map { "\($0)" }.joined(separator: " → ") + Self.err("DISCONNECT result: \(result)\n stages: \(stages)") + } catch { + Self.err("DISCONNECT error: \(error)") + } + // Proof of mechanism: with the private disable, the target drops out of the online list + // (or is inactive with NO mirror source). The public mirror fallback would instead leave it + // online with mirrorSourceID set. Captured during the offline window, before reconnect. + let post = await observer.currentSnapshot() + let summary = post.observations + .map { "\($0.recordID.rawValue) active=\($0.isActive) mirror=\($0.mirrorSourceID?.rawValue ?? "none")" } + .joined(separator: " | ") + Self.err("POST-DISCONNECT online=\(post.observations.count): \(summary)") + // Restore unconditionally so the test is self-healing. The hold is configurable (default + // 3s) so a cross-process test can re-enable the display from another process meanwhile; + // forAppOnly also reverts the disable if this process exits first. + let holdSeconds = ProcessInfo.processInfo.environment["OPENDISPLAY_HOLD_SECONDS"] + .flatMap(Double.init) ?? 3 + try? await Task.sleep(nanoseconds: UInt64(holdSeconds * 1_000_000_000)) + do { + try await lifecycle.reconnect(reconnectID, deadline: Date().addingTimeInterval(10)) + Self.err("RECONNECT \(reconnectID.rawValue): done") + } catch { + Self.err("RECONNECT \(reconnectID.rawValue) error: \(error)") + } + await refresh() + } + + private static func err(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } + + /// Records a global-hotkey activation to stderr AND a fixed file, so a manual keypress test can + /// be confirmed even when the app is launched via LaunchServices (which doesn't inherit stderr). + private static func debugMarkHotKeyFired() { + let message = Data("HOTKEY: Reconnect All triggered\n".utf8) + FileHandle.standardError.write(message) + let url = URL(fileURLWithPath: "/tmp/opendisplay_hotkey_fired.log") + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(message) + try? handle.close() + } else { + try? message.write(to: url) + } + } + #endif +} + +/// Prefers a primary lifecycle provider and falls back to a public one only when the primary +/// reports the operation `.unsupported` (e.g. the private SkyLight symbols are absent on this OS). +/// Other failures propagate — a real OS rejection must not silently retry by another mechanism. +private struct RoutedLifecycleProvider: LifecycleProvider { + let primary: any LifecycleProvider + let fallback: any LifecycleProvider + + let providerID = "routed.lifecycle.v1" + var isExperimental: Bool { primary.isExperimental } + + func probe(_ environment: ProviderEnvironment) async -> ProviderProbe { + let probe = await primary.probe(environment) + return probe.status == .supported ? probe : await fallback.probe(environment) + } + + func disconnect(_ target: DisplayRecordID, deadline: Date) async throws { + try await active().disconnect(target, deadline: deadline) + } + + func reconnect(_ target: DisplayRecordID, deadline: Date) async throws { + try await active().reconnect(target, deadline: deadline) + } + + func recover(to checkpoint: Checkpoint) async throws { + try await active().recover(to: checkpoint) + } + + /// Pick the provider by probe status — a forward capability check, so an unsupported primary + /// never even attempts the operation. (`catch as ProviderFailure` is now also boundary-safe: the + /// shared core ships as dynamic frameworks — see project.yml — so `ProviderFailure` has exactly + /// one runtime type across all images. Error-based fallback would work too; probe-based routing is + /// kept because deciding up front beats reacting to a thrown failure.) + private func active() async -> any LifecycleProvider { + #if arch(arm64) + let appleSilicon = true + #else + let appleSilicon = false + #endif + let environment = ProviderEnvironment( + osBuild: "", isAppleSilicon: appleSilicon, transport: .unknown, displayClass: .unknown + ) + return await primary.probe(environment).status == .supported ? primary : fallback + } +} + +/// Coarse menu-bar load state (a subset of the designed states: scanning → ready/empty). +enum DisplayLoadPhase: Equatable { + case scanning + case ready + case empty +} + +/// A provider status row shown in Settings → Diagnostics & Recovery. +struct DisplayDiagnostic: Identifiable, Hashable { + var id: String { provider } + var provider: String + var status: String + var risk: String + var experimental: Bool + var reasons: [String] +} +#endif diff --git a/Apps/OpenDisplay/Sources/ColorProfileService.swift b/Apps/OpenDisplay/Sources/ColorProfileService.swift new file mode 100644 index 0000000..8a24c8b --- /dev/null +++ b/Apps/OpenDisplay/Sources/ColorProfileService.swift @@ -0,0 +1,99 @@ +#if os(macOS) +import ApplicationServices +import CoreGraphics +import Foundation + +/// An installed ICC display profile the user can assign. +struct ICCProfile: Identifiable, Hashable { + let id: String // file path — stable across launches + let name: String + let url: URL +} + +/// Per-display ICC colour-profile control via **public ColorSync** (App-Store-safe). Displays are +/// targeted by their persistent ColorSync device UUID (= the CG display UUID), never by index, so a +/// profile change only ever touches the intended display. +/// +/// ColorSync's `k…` key constants are imported as non-`Sendable` mutable globals (strict concurrency +/// rejects referencing them), so we use their documented string values directly. +enum ColorProfileService { + // Computed (not stored) so there's no non-Sendable static state; values are the documented + // ColorSync constant strings, recovered from the live framework. + private static var displayClass: CFString { "mntr" as CFString } + private static var defaultProfileID: CFString { "DeviceDefaultProfileID" as CFString } + + static func deviceUUID(for displayID: CGDirectDisplayID) -> CFUUID? { + CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() + } + + /// True when ColorSync exposes a device for this display (so writes can target it safely). + static func isControllable(_ displayID: CGDirectDisplayID) -> Bool { + guard let uuid = deviceUUID(for: displayID) else { return false } + return ColorSyncDeviceCopyDeviceInfo(displayClass, uuid)?.takeRetainedValue() != nil + } + + /// Installed display (RGB) ICC profiles, de-duplicated by name and sorted. + static func availableProfiles() -> [ICCProfile] { + var collected: [ICCProfile] = [] + withUnsafeMutablePointer(to: &collected) { pointer in + let callback: ColorSyncProfileIterateCallback = { dict, context in + guard let context, let info = dict as NSDictionary? else { return true } + let list = context.assumingMemoryBound(to: [ICCProfile].self) + guard let url = info["com.apple.ColorSync.ProfileURL"] as? URL, + let name = info["com.apple.ColorSync.ProfileDescription"] as? String + else { return true } + if let space = info["com.apple.ColorSync.ProfileColorSpace"] as? String, space != "RGB" { + return true + } + list.pointee.append(ICCProfile(id: url.path, name: name, url: url)) + return true + } + ColorSyncIterateInstalledProfiles(callback, nil, pointer, nil) + } + var seen = Set() + return collected + .filter { seen.insert($0.name).inserted } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// The display's current profile name (a custom override if set, else its factory default). + static func currentProfileName(for displayID: CGDirectDisplayID) -> String? { + guard let uuid = deviceUUID(for: displayID), + let info = ColorSyncDeviceCopyDeviceInfo(displayClass, uuid)?.takeRetainedValue() as NSDictionary? + else { return nil } + if let custom = info["CustomProfiles"] as? NSDictionary, + let url = custom.allValues.compactMap({ $0 as? URL }).first { + return profileDescription(url) ?? url.deletingPathExtension().lastPathComponent + } + if let factory = info["FactoryProfiles"] as? NSDictionary, + let url = factory.allValues.compactMap({ $0 as? URL }).first { + return (profileDescription(url) ?? url.deletingPathExtension().lastPathComponent) + " (factory)" + } + return "Factory default" + } + + private static func profileDescription(_ url: URL) -> String? { + guard let profile = ColorSyncProfileCreateWithURL(url as CFURL, nil)?.takeRetainedValue() else { return nil } + return ColorSyncProfileCopyDescriptionString(profile)?.takeRetainedValue() as String? + } + + /// Assigns an ICC profile to a display after validating it opens + verifies. Returns success. + @discardableResult + static func setProfile(_ profile: ICCProfile, for displayID: CGDirectDisplayID) -> Bool { + guard let uuid = deviceUUID(for: displayID), + let cgProfile = ColorSyncProfileCreateWithURL(profile.url as CFURL, nil)?.takeRetainedValue(), + ColorSyncProfileVerify(cgProfile, nil, nil) + else { return false } + let map: [CFString: Any] = [defaultProfileID: profile.url] + return ColorSyncDeviceSetCustomProfiles(displayClass, uuid, map as CFDictionary) + } + + /// Removes any custom profile, reverting the display to its factory profile. + @discardableResult + static func resetToFactory(for displayID: CGDirectDisplayID) -> Bool { + guard let uuid = deviceUUID(for: displayID) else { return false } + let map: [CFString: Any] = [defaultProfileID: kCFNull as Any] + return ColorSyncDeviceSetCustomProfiles(displayClass, uuid, map as CFDictionary) + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/DisplayDetailView.swift b/Apps/OpenDisplay/Sources/DisplayDetailView.swift new file mode 100644 index 0000000..b9e6629 --- /dev/null +++ b/Apps/OpenDisplay/Sources/DisplayDetailView.swift @@ -0,0 +1,321 @@ +#if os(macOS) +import DisplayDomain +import OpenDisplayDesignSystem +import SwiftUI + +/// The per-display detail pane (Settings → Displays). This is where everything that used to crowd the +/// menu-bar card now lives: resolution & refresh, appearance (rotation/colour), hardware controls, +/// input, "use as", identity, and read-only info — grouped into System-Settings-style cards. The menu +/// bar deep-links here via "Display settings…". See `Docs/InterfaceRedesign.md`. +struct DisplayDetailView: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + ResolutionCard(display: display) + AppearanceCard(display: display) + if display.displayClass != .builtIn { ControlsCard(display: display) } + DimmingCard(display: display) + UseAsCard(display: display) + IdentityCard(display: display) + InformationCard(display: display) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .task(id: display.recordID) { + await model.refreshBrightness(for: display) + await model.refreshColorProfile(for: display) + if display.displayClass != .builtIn { + await model.refreshHardwareControls(for: display) + await model.refreshColorPreset(for: display) + await model.refreshInputSource(for: display) + } + } + } +} + +// MARK: - Resolution + +private struct ResolutionCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + /// Full mode list, enumerated once per display (off the per-render path) and filtered locally for + /// the resolution list, refresh rates, and HiDPI toggle — avoids three CGDisplayCopyAllDisplayModes + /// enumerations on every body evaluation. Filters use the *current* display.mode, so they stay + /// correct across resolution switches without re-enumerating. + @State private var allModes: [DisplayMode] = [] + + /// One entry per point-size (HiDPI preferred, then highest refresh), area-sorted. + private var resolutions: [DisplayMode] { + var best: [String: DisplayMode] = [:] + for mode in allModes { + let key = "\(mode.pointWidth)x\(mode.pointHeight)" + let rank = (mode.isHiDPI ? 1 : 0, mode.refreshHz) + if let existing = best[key] { + if rank > (existing.isHiDPI ? 1 : 0, existing.refreshHz) { best[key] = mode } + } else { + best[key] = mode + } + } + return best.values.sorted { $0.pointWidth * $0.pointHeight < $1.pointWidth * $1.pointHeight } + } + + /// Refresh rates at the current resolution (same point-size + HiDPI), descending. + private var rates: [Double] { + guard let current = display.mode else { return [] } + let hz = allModes + .filter { $0.pointWidth == current.pointWidth && $0.pointHeight == current.pointHeight && $0.isHiDPI == current.isHiDPI } + .map { ($0.refreshHz * 10).rounded() / 10 } + return Array(Set(hz)).sorted(by: >) + } + + /// True when the current resolution offers both a HiDPI and a non-HiDPI variant. + private var hiDPIAvailable: Bool { + guard let current = display.mode else { return false } + let here = allModes.filter { $0.pointWidth == current.pointWidth && $0.pointHeight == current.pointHeight } + return here.contains(where: { $0.isHiDPI }) && here.contains(where: { !$0.isHiDPI }) + } + + var body: some View { + ODCard(title: "Resolution", + footnote: "Scaled resolutions use HiDPI (Retina) rendering for crisper text.") { + ODRow("Resolution") { + if resolutions.count > 1, let mode = display.mode { + Menu("\(mode.pointWidth) × \(mode.pointHeight)") { + ForEach(resolutions, id: \.self) { m in + Button("\(m.pointWidth) × \(m.pointHeight)") { + Task { await model.setMode(m, for: display) } + } + } + } + .menuStyle(.borderlessButton).fixedSize() + } else { + Text(display.mode.map { "\($0.pointWidth) × \($0.pointHeight)" } ?? "—") + .font(.system(size: 11)).foregroundStyle(.secondary) + } + } + if rates.count > 1, let mode = display.mode { + ODDivider() + ODRow("Refresh rate") { + Menu("\(Int(mode.refreshHz.rounded())) Hz") { + ForEach(rates, id: \.self) { hz in + Button("\(Int(hz.rounded())) Hz") { Task { await model.setRefresh(hz, for: display) } } + } + } + .menuStyle(.borderlessButton).fixedSize() + } + } + if hiDPIAvailable, let mode = display.mode { + ODDivider() + ODRow("Retina (HiDPI)") { + Toggle("", isOn: Binding(get: { mode.isHiDPI }, + set: { on in Task { await model.setHiDPI(on, for: display) } })) + .labelsHidden().toggleStyle(.switch).controlSize(.small) + } + } + } + .task(id: display.recordID) { allModes = model.allModes(for: display) } + } +} + +// MARK: - Appearance (rotation + colour) + +private struct AppearanceCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ODCard(title: "Appearance") { + ODRow("Rotation") { + if model.rotationWritable { + HStack(spacing: 6) { + ODBadge("Experimental", tone: .orange) + Menu("\(model.currentRotation(for: display))°") { + ForEach([0, 90, 180, 270], id: \.self) { deg in + Button("\(deg)°") { Task { await model.setRotation(deg, for: display) } } + } + } + .menuStyle(.borderlessButton).fixedSize().disabled(model.busy) + } + } else { + Text("\(model.currentRotation(for: display))°").font(.system(size: 11)).foregroundStyle(.secondary) + } + } + if display.displayClass != .builtIn { + ODDivider() + ODRow("Colour mode") { + Menu(model.colorPreset[display.recordID].map { model.presetName($0) } ?? "—") { + let maxCode = max(model.colorPresetMax[display.recordID] ?? 5, 1) + ForEach(1...maxCode, id: \.self) { code in + Button(model.presetName(code)) { model.setColorPreset(code, for: display) } + } + } + .menuStyle(.borderlessButton).fixedSize() + } + } + ODDivider() + ODRow("Colour profile") { + if model.colorProfileControllable[display.recordID] == true { + Menu(model.colorProfileName[display.recordID] ?? "—") { + Button("Factory Default") { Task { await model.resetColorProfile(for: display) } } + Divider() + ForEach(model.availableColorProfilesCache) { profile in + Button(profile.name) { Task { await model.setColorProfile(profile, for: display) } } + } + } + .menuStyle(.borderlessButton).fixedSize() + } else { + Text("Unavailable").font(.system(size: 11)).foregroundStyle(.tertiary) + } + } + } + if let reason = model.rotationUnavailableReason { + Text(reason).font(.system(size: 11)).foregroundStyle(.secondary).padding(.horizontal, 10) + } + } +} + +// MARK: - Hardware controls (DDC, external only) + +private struct ControlsCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ODCard(title: "Controls", + footnote: "Hardware controls sent over DDC/CI. Availability depends on the monitor.") { + let controls = HardwareControl.allCases.filter { model.ddcControl($0, for: display) != nil } + if controls.isEmpty { + ODRow("No adjustable hardware controls reported") {} + } else { + ForEach(Array(controls.enumerated()), id: \.element) { index, control in + if index > 0 { ODDivider() } + ODRow(control.label) { + sliderWithReadout(level: model.ddcControl(control, for: display) ?? 0.5) { value in + model.setHardwareControl(control, value, for: display) + } + } + } + } + ODDivider() + ODRow("Input source") { + Menu(model.inputSource[display.recordID].map { model.inputName($0) } ?? "—") { + ForEach(AppModel.standardInputs, id: \.code) { input in + Button(input.name) { model.setInputSource(input.code, for: display) } + } + } + .menuStyle(.borderlessButton).fixedSize() + } + } + } + + private func sliderWithReadout(level: Float, set: @escaping (Float) -> Void) -> some View { + HStack(spacing: 8) { + Slider(value: Binding(get: { Double(level) }, set: { set(Float($0)) }), in: 0...1) + .frame(width: 160) + Text("\(Int((level * 100).rounded()))%") + .font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + } + } +} + +// MARK: - Software dimming (any display) + +private struct DimmingCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ODCard(title: "Dimming", + footnote: "Software gamma dim, applied on top of brightness. Works on any display, " + + "including below the hardware minimum.") { + ODRow("Software dimming") { + HStack(spacing: 8) { + Slider(value: Binding(get: { Double(model.softwareDim[display.recordID] ?? 1) }, + set: { model.setSoftwareDim(Float($0), for: display) }), in: 0.15...1) + .frame(width: 160) + Text("\(Int(((model.softwareDim[display.recordID] ?? 1) * 100).rounded()))%") + .font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + } + } + } + } +} + +// MARK: - Use as + +private struct UseAsCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ODCard(title: "Use as") { + ODRow("Use as main display", secondary: "Menu bar and Dock appear here") { + Toggle("", isOn: Binding(get: { display.isMain }, + set: { on in if on { Task { await model.setMain(for: display) } } })) + .labelsHidden().toggleStyle(.switch).controlSize(.small) + .disabled(display.isMain || model.busy) + } + if !display.isMain { + ODDivider() + ODRow("Mirror to main display") { + Toggle("", isOn: Binding(get: { display.isMirrored }, + set: { on in Task { await model.setMirrored(on, for: display) } })) + .labelsHidden().toggleStyle(.switch).controlSize(.small).disabled(model.busy) + } + } + ODDivider() + ODRow("Turn display off", secondary: "Logical disconnect — reconnectable") { + Button("Turn Off", role: .destructive) { + Task { await model.setDisplayActive(false, for: display) } + } + .controlSize(.small) + .disabled(model.busy || model.activeDisplayCount <= 1) + } + } + } +} + +// MARK: - Identity (rename) + +private struct IdentityCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + @State private var alias = "" + + var body: some View { + ODCard(title: "Name") { + ODRow("Display name") { + TextField(model.displayName(for: display), text: $alias) + .textFieldStyle(.roundedBorder).frame(width: 200) + .onSubmit { Task { await model.setAlias(alias, for: display) } } + } + } + .onAppear { alias = model.records[display.recordID]?.alias ?? "" } + } +} + +// MARK: - Information (read-only) + +private struct InformationCard: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + + var body: some View { + ODCard(title: "Information") { + let info = model.displayInfo(for: display) + ForEach(Array(info.enumerated()), id: \.element.label) { index, item in + if index > 0 { ODDivider() } + ODRow(item.label) { + Text(item.value).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + } + } + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/GlobalHotKey.swift b/Apps/OpenDisplay/Sources/GlobalHotKey.swift new file mode 100644 index 0000000..d86f612 --- /dev/null +++ b/Apps/OpenDisplay/Sources/GlobalHotKey.swift @@ -0,0 +1,62 @@ +#if os(macOS) +import AppKit +import Carbon.HIToolbox + +/// A single system-wide hotkey registered via Carbon `RegisterEventHotKey`. Carbon hotkeys do NOT +/// require the Accessibility permission an event tap would, which matters because the whole point +/// of a global Reconnect-All is to work when the menu bar is unreachable (recovery hierarchy step +/// 3, PRD §9.11 / LIF-009). The bound action runs on the main actor. +@MainActor +final class GlobalHotKey { + // Carbon C handles, only written in init and read in deinit (no concurrent access), so the + // unchecked isolation lets the nonisolated deinit clean them up. + private nonisolated(unsafe) var hotKeyRef: EventHotKeyRef? + private nonisolated(unsafe) var eventHandler: EventHandlerRef? + private let action: () -> Void + + /// Registers the default Reconnect-All chord, ⌃⌥⌘R. Returns nil if registration fails (e.g. the + /// chord is already claimed) so the caller can fall back to the menu-bar item. + static func reconnectAll(action: @escaping () -> Void) -> GlobalHotKey? { + GlobalHotKey( + keyCode: UInt32(kVK_ANSI_R), + modifiers: UInt32(controlKey | optionKey | cmdKey), + action: action + ) + } + + private init?(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) { + self.action = action + + var eventSpec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + let installStatus = InstallEventHandler( + GetApplicationEventTarget(), + { _, _, userData -> OSStatus in + guard let userData else { return OSStatus(eventNotHandledErr) } + // Carbon delivers hotkey events on the main run loop, so this is the main actor. + MainActor.assumeIsolated { + Unmanaged.fromOpaque(userData).takeUnretainedValue().action() + } + return noErr + }, + 1, &eventSpec, selfPtr, &eventHandler + ) + guard installStatus == noErr else { return nil } + + let hotKeyID = EventHotKeyID(signature: OSType(0x4F44_4953) /* 'ODIS' */, id: 1) + let registerStatus = RegisterEventHotKey( + keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef + ) + guard registerStatus == noErr else { + if let eventHandler { RemoveEventHandler(eventHandler) } + return nil + } + } + + deinit { + if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } + if let eventHandler { RemoveEventHandler(eventHandler) } + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/MenuBarView.swift b/Apps/OpenDisplay/Sources/MenuBarView.swift new file mode 100644 index 0000000..29051fb --- /dev/null +++ b/Apps/OpenDisplay/Sources/MenuBarView.swift @@ -0,0 +1,338 @@ +#if os(macOS) +import AppKit +import DisplayDomain +import OpenDisplayDesignSystem +import SwiftUI + +/// The menu-bar popover (primary surface), styled after the design kit's `MBDisplay`: a compact +/// per-display row that expands to the *fast, frequent* controls — one unified brightness slider, +/// volume when the panel reports it, status chips, and a few quick actions. Everything detailed +/// (resolution, colour, rotation, hardware/DDC, rename, info) lives one click away in Settings, so the +/// popover stays lean. See `Docs/InterfaceRedesign.md`. +struct MenuBarView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.openSettings) private var openSettingsAction + @State private var expandedID: DisplayRecordID? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + header + ODSectionLabel("Displays") + content + if model.isDegraded { + ODInlineBanner(tone: .orange, systemImage: "exclamationmark.triangle.fill", + title: "Some providers are unavailable", + message: "Open Diagnostics in Settings to see which routes are degraded.") + .padding(.horizontal, 2).padding(.top, 2) + } + ODDivider().padding(.vertical, 4) + toolsSection + } + .padding(8) + .frame(width: 320) + .onChange(of: model.displays.count, initial: true) { _, _ in + if expandedID == nil { expandedID = model.displays.first(where: { $0.isMain })?.recordID } + } + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "display").font(.system(size: 18)).foregroundStyle(ODColor.accent) + VStack(alignment: .leading, spacing: 1) { + Text("Displays").font(.system(size: 14, weight: .semibold)) + Text(model.statusText).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button { showSettings() } label: { + Image(systemName: "gearshape").font(.system(size: 15)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + .accessibilityLabel("Open Settings") + Menu { + Button("About OpenDisplay") { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(nil) + } + Divider() + Button("Quit OpenDisplay") { NSApp.terminate(nil) } + } label: { + Image(systemName: "ellipsis.circle").font(.system(size: 15)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + .accessibilityLabel("More options") + } + .padding(.horizontal, 6).padding(.top, 2).padding(.bottom, 2) + } + + @ViewBuilder + private var content: some View { + if model.phase == .scanning { + HStack(spacing: ODSpacing.sm) { + ProgressView().controlSize(.small) + Text("Scanning displays…").foregroundStyle(.secondary) + } + .padding(8).frame(maxWidth: .infinity, alignment: .leading) + } else if model.displays.isEmpty && model.managedOffline.isEmpty { + Label("No displays detected", systemImage: "display.trianglebadge.exclamationmark") + .foregroundStyle(.secondary).padding(8) + } else { + ForEach(model.displays, id: \.recordID) { display in + DisplayCard(display: display, expandedID: $expandedID) { + model.selectedDisplayID = display.recordID + showSettings() + } + } + ForEach(model.managedOffline) { offline in + OfflineDisplayCard(offline: offline) + } + } + } + + private var toolsSection: some View { + VStack(spacing: 2) { + ODSectionLabel("Tools") + MenuActionRow(title: model.busy ? "Reconnecting…" : "Reconnect all", + systemImage: "arrow.triangle.2.circlepath", showChevron: false, + enabled: !model.busy) { Task { await model.reconnectAll() } } + MenuActionRow(title: "Displays & arrangement…", systemImage: "rectangle.3.group", + showChevron: true) { showSettings() } + MenuActionRow(title: "Check for updates", systemImage: "arrow.down.circle", soon: true) + } + } + + /// Opens Settings and brings the window to the display the user is actually looking at. With + /// "Displays have separate Spaces" the SwiftUI Settings window opens on the main display's + /// Space, so clicking the menu bar on an extended display otherwise appears to do nothing. + private func showSettings() { + openSettingsAction() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + guard let window = NSApp.windows.first(where: { + $0.styleMask.contains(.titled) && $0.canBecomeMain + }) else { return } + window.collectionBehavior.insert(.moveToActiveSpace) + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + if let screen = NSScreen.screens.first(where: { + NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) + }) { + let visible = screen.visibleFrame + let size = window.frame.size + window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2, + y: visible.midY - size.height / 2)) + } + } + } +} + +/// One display: a tappable header (glyph · name · sub · state badge · chevron) that expands to the +/// fast controls — unified brightness, volume (when reported), status chips, and quick actions. +private struct DisplayCard: View { + @EnvironmentObject private var model: AppModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let display: DisplayObservation + @Binding var expandedID: DisplayRecordID? + let onOpenSettings: () -> Void + @State private var probedHardware = false + + private var isExpanded: Bool { expandedID == display.recordID } + private var id: DisplayRecordID { display.recordID } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + header + if isExpanded && display.isActive { + brightnessRow + if let volume = model.ddcControl(.volume, for: display) { volumeRow(volume) } + chipRow + quickActions + MenuActionRow(title: "Display settings…", systemImage: "slider.horizontal.3", + showChevron: true) { onOpenSettings() } + } + } + .padding(isExpanded ? 8 : 4) + .background(isExpanded ? ODColor.cardBackground : .clear, + in: RoundedRectangle(cornerRadius: ODRadius.popover)) + .overlay { + if isExpanded { + RoundedRectangle(cornerRadius: ODRadius.popover).strokeBorder(ODColor.separator, lineWidth: 0.5) + } + } + .onAppear { Task { await model.refreshBrightness(for: display) } } + .task(id: isExpanded) { + guard isExpanded, display.displayClass != .builtIn, !probedHardware else { return } + await model.refreshHardwareControls(for: display) + probedHardware = true + } + } + + private var header: some View { + Button { + if reduceMotion { expandedID = isExpanded ? nil : id } + else { withAnimation(.easeInOut(duration: 0.15)) { expandedID = isExpanded ? nil : id } } + } label: { + HStack(spacing: 9) { + ODGlyphTile(display.displayClass == .builtIn ? "laptopcomputer" : "display", + tone: display.isMain ? .accent : .neutral) + VStack(alignment: .leading, spacing: 1) { + Text(model.displayName(for: display)) + .font(.system(size: 13, weight: .semibold)).foregroundStyle(.primary).lineLimit(1) + Text(subtitle).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 6) + trailingBadge + if display.isActive { + Image(systemName: "chevron.right").font(.system(size: 11)).foregroundStyle(.tertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + .padding(.horizontal, 4).padding(.vertical, 3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!display.isActive) + } + + private var subtitle: String { + guard display.isActive else { return "Inactive" } + guard let mode = display.mode else { return "—" } + return "\(mode.pointWidth) × \(mode.pointHeight) · \(Int(mode.refreshHz.rounded())) Hz" + } + + @ViewBuilder private var trailingBadge: some View { + if model.isBlackedOut(display) { + ODBadge("Blacked Out", tone: .neutral) + } else if display.isMain { + ODBadge("Main", tone: .accent, solid: true) + } else if display.isMirrored { + ODBadge("Mirrored") + } else if display.isActive { + ODDot(ODColor.connected) + } + } + + private var brightnessRow: some View { + VStack(alignment: .leading, spacing: 0) { + ODSliderRow( + systemImage: "sun.min", trailingSystemImage: "sun.max", + value: Binding(get: { Double(model.brightness[id] ?? 0.5) }, + set: { model.setBrightness(Float($0), for: display) }), + valueText: "\(Int(((model.brightness[id] ?? 0.5) * 100).rounded()))%", + accessibilityLabel: "Brightness") + if let caption = model.brightnessCaption(for: display) { + Text(caption).font(.system(size: 9)).foregroundStyle(.tertiary) + .padding(.leading, 32).padding(.bottom, 2) + } + } + } + + private func volumeRow(_ volume: Float) -> some View { + ODSliderRow( + systemImage: "speaker.fill", trailingSystemImage: "speaker.wave.3.fill", + value: Binding(get: { Double(model.ddcControl(.volume, for: display) ?? volume) }, + set: { model.setHardwareControl(.volume, Float($0), for: display) }), + valueText: "\(Int((volume * 100).rounded()))%", + accessibilityLabel: "Volume") + } + + private var chipRow: some View { + HStack(spacing: 6) { + if let mode = display.mode { + ODChip("\(mode.pointWidth) × \(mode.pointHeight)", systemImage: "rectangle.on.rectangle", + action: onOpenSettings) + ODChip("\(Int(mode.refreshHz.rounded())) Hz", systemImage: "timer", action: onOpenSettings) + if mode.isHiDPI { ODChip("Retina", on: true) } + } + if model.currentRotation(for: display) != 0 { + ODChip("\(model.currentRotation(for: display))°", systemImage: "rotate.right", + action: onOpenSettings) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8).padding(.top, 2) + } + + private var quickActions: some View { + HStack(spacing: 6) { + if !display.isMain { + ODQuickAction("Set as Main", systemImage: "star", enabled: !model.busy) { + Task { await model.setMain(for: display) } + } + } + ODQuickAction(model.isBlackedOut(display) ? "Restore" : "Black Out", + systemImage: model.isBlackedOut(display) ? "sun.max.fill" : "moon.fill") { + model.toggleBlackOut(for: display) + } + ODQuickAction("Turn Off", systemImage: "power", tone: .red, + enabled: !model.busy && model.activeDisplayCount > 1) { + Task { await model.setDisplayActive(false, for: display) } + } + } + .padding(.horizontal, 8).padding(.top, 2) + } +} + +/// A display the app has turned off: stays visible (dimmed) with a Reconnect affordance. The OS no +/// longer enumerates it, so its data comes from AppModel's managed-offline list. +private struct OfflineDisplayCard: View { + @EnvironmentObject private var model: AppModel + let offline: AppModel.OfflineDisplay + + var body: some View { + HStack(spacing: 9) { + ODGlyphTile(offline.displayClass == .builtIn ? "laptopcomputer" : "display", tone: .neutral) + .opacity(0.6) + VStack(alignment: .leading, spacing: 1) { + Text(offline.name).font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary).lineLimit(1) + Text("Managed offline").font(.system(size: 11)).foregroundStyle(.tertiary) + } + Spacer(minLength: 6) + Button { + Task { await model.reconnectOffline(offline) } + } label: { + Label("Reconnect", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 11)) + } + .buttonStyle(.bordered).controlSize(.small).disabled(model.busy) + } + .padding(.horizontal, 4).padding(.vertical, 6) + } +} + +/// A single full-width menu row: leading icon, title, and a trailing chevron (push), "Soon" pill, or +/// nothing (immediate action). Used for the Tools section and the per-card "Display settings…" link. +private struct MenuActionRow: View { + let title: String + let systemImage: String + var soon = false + var showChevron = true + var enabled = true + var action: () -> Void = {} + @State private var hovering = false + + private var active: Bool { enabled && !soon } + + var body: some View { + Button { if active { action() } } label: { + HStack(spacing: 10) { + Image(systemName: systemImage).font(.system(size: 14)).frame(width: 18) + .foregroundStyle(active ? .secondary : .tertiary) + Text(title).font(.system(size: 13)).foregroundStyle(active ? .primary : .secondary) + Spacer() + if soon { + ODBadge("Soon") + } else if showChevron { + Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 8).padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(hovering && active ? ODColor.rowHover : .clear, + in: RoundedRectangle(cornerRadius: ODRadius.control)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/OpenDisplayApp.swift b/Apps/OpenDisplay/Sources/OpenDisplayApp.swift new file mode 100644 index 0000000..7b42f01 --- /dev/null +++ b/Apps/OpenDisplay/Sources/OpenDisplayApp.swift @@ -0,0 +1,22 @@ +#if os(macOS) +import SwiftUI + +/// Menu-bar-first entry point (PRD UX-001). `LSUIElement` keeps it out of the Dock; the primary +/// surface is the menu-bar popover, with a Settings window for detail. The full surface set +/// (topology, scenes, automation, health & recovery, Labs) lands in M1–M3. +@main +struct OpenDisplayApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + MenuBarExtra("OpenDisplay", systemImage: "display") { + MenuBarView().environmentObject(model) + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView().environmentObject(model) + } + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/OpenDisplayIntents.swift b/Apps/OpenDisplay/Sources/OpenDisplayIntents.swift new file mode 100644 index 0000000..775486b --- /dev/null +++ b/Apps/OpenDisplay/Sources/OpenDisplayIntents.swift @@ -0,0 +1,116 @@ +#if os(macOS) +import AppIntents +import CoreGraphicsProvider +import DisplayDomain +import Foundation +import ProviderInterfaces +import TopologyCore +#if !PUBLIC_API_ONLY +import ExperimentalLifecycleProvider +#endif + +/// Shortcuts / Siri integration (PRD §1 automation, recovery hierarchy step 3). Every intent routes +/// through the same `CommandGateway` the menu bar and CLI use, so it inherits the full safety, +/// verification, and audit path. Each invocation builds a fresh gateway (the intent may run outside +/// the running app), mirroring the CLI's independent composition. +enum OpenDisplayAutomation { + static func makeGateway() async -> CommandGateway { + let observer = CoreGraphicsProvider() + #if arch(arm64) + let appleSilicon = true + #else + let appleSilicon = false + #endif + let environment = ProviderEnvironment( + osBuild: ProcessInfo.processInfo.operatingSystemVersionString, + isAppleSilicon: appleSilicon, transport: .unknown, displayClass: .unknown + ) + let lifecycle: any LifecycleProvider + #if !PUBLIC_API_ONLY + let experimental = ExperimentalLifecycleProvider() + lifecycle = await experimental.probe(environment).status == .supported ? experimental : observer + #else + lifecycle = observer + #endif + let checkpoints: any CheckpointStoring = + (try? DiskCheckpointStore.defaultDirectory()).map(DiskCheckpointStore.init(directory:)) + ?? InMemoryCheckpointStore() + let audit = (try? DiskAuditLog.defaultDirectory()).map(DiskAuditLog.init(directory:)) + return CommandGateway(observer: observer, lifecycleProvider: lifecycle, + checkpoints: checkpoints, auditLog: audit) + } +} + +/// Reconnects every managed-offline display — the always-available recovery action, now usable from +/// Shortcuts, Siri, and the Shortcuts menu-bar surface. +struct ReconnectAllIntent: AppIntent { + static let title: LocalizedStringResource = "Reconnect All Displays" + static let description = IntentDescription( + "Reconnects every OpenDisplay-managed offline display — the always-available recovery action." + ) + // The intent does its own work; no need to bring the app forward. + static let openAppWhenRun = false + + func perform() async throws -> some IntentResult & ProvidesDialog { + let envelope = await OpenDisplayAutomation.makeGateway().reconnectAll(actor: .appIntent) + let restored = envelope.targets.filter { target in + target.operations.contains { $0.verification == .verified } + }.count + let message = restored == 0 + ? "No displays needed reconnecting." + : "Reconnected \(restored) display\(restored == 1 ? "" : "s")." + return .result(dialog: IntentDialog(stringLiteral: message)) + } +} + +/// Sets the built-in display's brightness from Shortcuts / Siri (via the same private DisplayServices +/// path the menu uses). Excluded from the public-API-only build. +struct SetBrightnessIntent: AppIntent { + static let title: LocalizedStringResource = "Set Display Brightness" + static let description = IntentDescription("Sets the built-in display's brightness (0–100%).") + static let openAppWhenRun = false + + @Parameter(title: "Brightness", inclusiveRange: (0, 100)) + var percent: Int + + func perform() async throws -> some IntentResult & ProvidesDialog { + let clamped = max(0, min(100, percent)) + #if !PUBLIC_API_ONLY + let observer = CoreGraphicsProvider() + let snapshot = await observer.currentSnapshot() + guard let builtIn = snapshot.observations.first(where: { $0.displayClass == .builtIn }), + let cgID = builtIn.cgDisplayID else { + return .result(dialog: "No built-in display found.") + } + let ok = DisplayServicesBrightnessProvider().setBrightness(Float(clamped) / 100, for: cgID) + let message = ok ? "Set built-in brightness to \(clamped)%." : "Couldn't set the brightness." + return .result(dialog: IntentDialog(stringLiteral: message)) + #else + return .result(dialog: "Brightness control isn't available in this build.") + #endif + } +} + +struct OpenDisplayShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ReconnectAllIntent(), + phrases: [ + "Reconnect all displays with \(.applicationName)", + "\(.applicationName) reconnect all displays" + ], + shortTitle: "Reconnect All", + systemImageName: "arrow.triangle.2.circlepath" + ) + AppShortcut( + intent: SetBrightnessIntent(), + phrases: [ + "Set \(.applicationName) brightness", + "\(.applicationName) set brightness" + ], + shortTitle: "Set Brightness", + systemImageName: "sun.max" + ) + } +} +#endif diff --git a/Apps/OpenDisplay/Sources/RotationBackend.swift b/Apps/OpenDisplay/Sources/RotationBackend.swift new file mode 100644 index 0000000..c0a7626 --- /dev/null +++ b/Apps/OpenDisplay/Sources/RotationBackend.swift @@ -0,0 +1,102 @@ +#if os(macOS) +import CoreGraphics +import Foundation + +/// Whether rotation *writes* are available. Reading orientation always works (public CGDisplayRotation); +/// only setting it needs a backend, and there is no Apple-supported rotation setter — so the stable +/// build is read-only and a private path stays strictly experimental (PRD: safety before capability). +enum RotationCapability: Equatable { + case readOnly + case experimental + case unavailable(reason: String) +} + +enum RotationError: Error, Equatable { + case unsupported(String) + case invalidAngle + case displayOffline + case unsafe(String) + case verificationFailed +} + +/// Reads + (maybe) writes a display's rotation. The UI and scene model depend only on this protocol, +/// so swapping in the experimental backend never touches them. +protocol RotationBackend: Sendable { + var capability: RotationCapability { get } + /// Current rotation in degrees (0/90/180/270) via public Core Graphics. + func currentRotation(for displayID: CGDirectDisplayID) -> Int + /// Sets rotation. The stable backend always throws `.unsupported`. + func setRotation(_ degrees: Int, for displayID: CGDirectDisplayID) async throws +} + +extension RotationBackend { + /// Valid quarter-turn angles. + static var validAngles: [Int] { [0, 90, 180, 270] } +} + +/// The stable, App-Store-safe backend: reads rotation via public Core Graphics, refuses all writes. +/// This is the default everywhere; the experimental SkyLight backend is opt-in and never the default. +struct ReadOnlyRotationBackend: RotationBackend { + static let unavailableReason = "Rotation changes are not safely supported on this macOS version." + + var capability: RotationCapability { .unavailable(reason: Self.unavailableReason) } + + func currentRotation(for displayID: CGDirectDisplayID) -> Int { + Int(CGDisplayRotation(displayID).rounded()) + } + + func setRotation(_ degrees: Int, for displayID: CGDirectDisplayID) async throws { + throw RotationError.unsupported(Self.unavailableReason) + } +} + +#if !PUBLIC_API_ONLY +/// EXPERIMENTAL rotation backend — opt-in only, never the default, compiled out of App Store builds. +/// Runs the private rotation through the `opendisplay` helper's gated `_rotate-exp` command in a +/// short-lived isolated process, so a WindowServer-client crash kills only the helper, not the app. +/// The helper does its own angle/display validation, post-rotation verification, and rollback. +struct ExperimentalRotationBackend: RotationBackend { + var capability: RotationCapability { .experimental } + + func currentRotation(for displayID: CGDirectDisplayID) -> Int { + Int(CGDisplayRotation(displayID).rounded()) + } + + func setRotation(_ degrees: Int, for displayID: CGDirectDisplayID) async throws { + guard Self.validAngles.contains(degrees) else { throw RotationError.invalidAngle } + guard let helper = Self.helperURL else { throw RotationError.unsupported("rotation helper not found") } + let process = Process() + process.executableURL = helper + process.arguments = ["_rotate-exp", String(displayID), String(degrees)] + process.environment = ProcessInfo.processInfo.environment + .merging(["OPENDISPLAY_EXPERIMENTAL_ROTATION": "1"]) { _, new in new } + // Await the helper's verified exit WITHOUT blocking a cooperative-pool thread for the whole + // rotation (spawn + private rotation + verification + possible rollback): resume from the + // termination handler instead of Process.waitUntilExit(). The helper does its own angle/display + // validation, post-rotation verification, and rollback; success gates strictly on exit code 0. + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { proc in + if proc.terminationStatus == 0 { + continuation.resume() + } else { + continuation.resume(throwing: RotationError.verificationFailed) + } + } + do { try process.run() } catch { continuation.resume(throwing: error) } + } + } + + /// Locate the `opendisplay` helper: shipped under Contents/Helpers in a release bundle, or sitting + /// beside the .app in the build-products dir during development. + private static var helperURL: URL? { + var candidates: [URL] = [] + if let macOS = Bundle.main.executableURL?.deletingLastPathComponent() { + candidates.append(macOS.deletingLastPathComponent().appendingPathComponent("Helpers/opendisplay")) + } + // Dev: the CLI is a sibling of OpenDisplay.app in the build-products directory. + candidates.append(Bundle.main.bundleURL.deletingLastPathComponent().appendingPathComponent("opendisplay")) + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } +} +#endif +#endif diff --git a/Apps/OpenDisplay/Sources/SettingsView.swift b/Apps/OpenDisplay/Sources/SettingsView.swift new file mode 100644 index 0000000..f0a214d --- /dev/null +++ b/Apps/OpenDisplay/Sources/SettingsView.swift @@ -0,0 +1,326 @@ +#if os(macOS) +import DisplayDomain +import OpenDisplayDesignSystem +import SwiftUI + +/// Settings window. A sidebar (Displays · Arrange · Scenes · Health & Recovery) replaces the old +/// 3-tab shell: "Displays" is now a selection list feeding a per-display detail pane (so the topology +/// isn't duplicated with the menu bar), "Arrange" is promoted out of Scenes into its own item, and +/// diagnostics/recovery/Labs are unified under "Health & Recovery". See `Docs/InterfaceRedesign.md`. +struct SettingsView: View { + @EnvironmentObject private var model: AppModel + @State private var section: SettingsSection? = .displays + + var body: some View { + NavigationSplitView { + List(SettingsSection.allCases, selection: $section) { item in + Label(item.title, systemImage: item.icon).tag(item) + } + .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 220) + } detail: { + switch section ?? .displays { + case .displays: DisplaysSection() + case .arrange: ArrangeSection() + case .scenes: ScenesSection() + case .health: HealthSection() + } + } + .frame(minWidth: 720, idealWidth: 720, minHeight: 480, idealHeight: 520) + .task { + await model.refreshDiagnostics() + await model.refreshActivity() + } + // Deep-link from the menu bar's "Display settings…": jump to the Displays section so the + // selected display is shown even if Settings was already open on another section. + .onChange(of: model.selectedDisplayID) { _, newValue in + if newValue != nil { section = .displays } + } + } +} + +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case displays, arrange, scenes, health + var id: String { rawValue } + var title: String { + switch self { + case .displays: return "Displays" + case .arrange: return "Arrange" + case .scenes: return "Scenes" + case .health: return "Health & Recovery" + } + } + var icon: String { + switch self { + case .displays: return "display" + case .arrange: return "rectangle.3.group" + case .scenes: return "square.stack.3d.up" + case .health: return "stethoscope" + } + } +} + +// MARK: - Displays (selection list → detail pane) + +private struct DisplaysSection: View { + @EnvironmentObject private var model: AppModel + + private var selected: DisplayObservation? { + model.displays.first { $0.recordID == model.selectedDisplayID } + ?? model.displays.first { $0.isMain } + ?? model.displays.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if model.displays.isEmpty { + ContentUnavailableView("No displays detected", systemImage: "display.trianglebadge.exclamationmark") + } else { + if model.displays.count > 1 { + Picker("", selection: Binding( + get: { selected?.recordID ?? model.displays.first?.recordID }, + set: { model.selectedDisplayID = $0 })) { + ForEach(model.displays, id: \.recordID) { display in + Text(model.displayName(for: display)).tag(Optional(display.recordID)) + } + } + .pickerStyle(.segmented).labelsHidden() + .padding(.horizontal, 16).padding(.top, 16) + } + if let display = selected { + DisplayDetailView(display: display) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .navigationTitle("Displays") + } +} + +// MARK: - Arrange + +private struct ArrangeSection: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: ODSpacing.md) { + DisplayArrangementView() + Text("Drag a display to reposition it. Changes apply immediately; save the layout as a scene under Scenes.") + .font(.caption).foregroundStyle(.secondary) + } + .padding(ODSpacing.lg) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Arrange Displays") + } +} + +// MARK: - Scenes + +private struct ScenesSection: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: ODSpacing.md) { + if let warning = model.sceneWarning { + ODInlineBanner(tone: .orange, systemImage: "exclamationmark.triangle", title: warning) + } + if model.scenes.isEmpty { + Text("No saved scenes yet. Arrange your displays, then save the current arrangement below.") + .font(.callout).foregroundStyle(.secondary) + } else { + ODCard { + ForEach(Array(model.scenes.enumerated()), id: \.element.id) { index, scene in + if index > 0 { ODDivider() } + ODRow(scene.name, secondary: "\(scene.members.count) displays") { + HStack(spacing: 8) { + Button("Apply") { Task { await model.applyScene(scene) } } + .controlSize(.small).disabled(model.busy) + Button(role: .destructive) { + Task { await model.deleteScene(scene) } + } label: { Image(systemName: "trash") } + .buttonStyle(.borderless) + } + } + } + } + } + SaveSceneRow() + } + .padding(ODSpacing.lg) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Scenes") + } +} + +private struct SaveSceneRow: View { + @EnvironmentObject private var model: AppModel + @State private var name = "" + + var body: some View { + HStack(spacing: ODSpacing.sm) { + TextField("New scene name", text: $name).textFieldStyle(.roundedBorder) + Button("Save Current Arrangement") { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + Task { await model.saveScene(named: trimmed); name = "" } + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } +} + +// MARK: - Health & Recovery (diagnostics + recovery + Labs + activity) + +private struct HealthSection: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: ODSpacing.md) { + Text("Providers").font(.title3) + ForEach(model.diagnostics) { row in + HStack(spacing: ODSpacing.sm) { + Image(systemName: row.status == "supported" ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(row.status == "supported" ? ODColor.connected : ODColor.caution) + VStack(alignment: .leading, spacing: 2) { + Text(row.provider) + Text("\(row.status) · risk \(row.risk)\(row.reasons.isEmpty ? "" : " · \(row.reasons.joined(separator: ", "))")") + .font(.caption).foregroundStyle(.secondary) + } + if row.experimental { ODBadge("Labs", tone: .orange) } + Spacer() + } + } + + Divider() + + Text("Recovery").font(.title3) + LabeledContent("Persistence policy", value: model.settings.persistencePolicy.rawValue) + LabeledContent("Global hotkey", + value: model.settings.reconnectAllHotkeyEnabled ? model.reconnectAllHotkey : "disabled") + LabeledContent("Checkpoints", value: model.checkpointLocation) + Button { + Task { await model.reconnectAll() } + } label: { + Label("Reconnect All", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(model.busy) + + #if !PUBLIC_API_ONLY + Divider() + + Text("Labs").font(.title3) + Toggle(isOn: $model.experimentalRotationEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Experimental display rotation") + Text("Rotate displays via a private API. Off by default; runs through a safety-checked, " + + "isolated helper with automatic rollback, and is excluded from App Store builds.") + .font(.caption).foregroundStyle(.secondary) + } + } + #endif + + Divider() + + Text("Recent Activity").font(.title3) + if model.recentActivity.isEmpty { + Text("No recorded activity yet.").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(Array(model.recentActivity.enumerated()), id: \.offset) { _, entry in + HStack(spacing: ODSpacing.sm) { + Text(entry.command).font(.caption).bold() + Text(entry.status).font(.caption).foregroundStyle(.secondary) + if !entry.targets.isEmpty { + Text(entry.targets.joined(separator: ", ")) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Text(entry.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + .padding(ODSpacing.lg) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Health & Recovery") + } +} + +// MARK: - Arrange canvas (drag-to-position) + +/// The drag-to-arrange canvas: each active display is a proportionally-sized, positioned tile +/// (mirroring System Settings › Displays › Arrange). Dropping a tile applies its new origin live; +/// Core Graphics then re-snaps the layout so displays stay adjacent and the canvas re-renders. +private struct DisplayArrangementView: View { + @EnvironmentObject private var model: AppModel + private let canvas = CGSize(width: 480, height: 210) + + var body: some View { + let tiles = model.displays.compactMap { display -> (DisplayObservation, CGRect)? in + guard let mode = display.mode, display.isActive else { return nil } + return (display, CGRect(x: CGFloat(display.origin.x), y: CGFloat(display.origin.y), + width: CGFloat(mode.pointWidth), height: CGFloat(mode.pointHeight))) + } + let union = tiles.map(\.1).reduce(CGRect.null) { $0.union($1) } + let scale: CGFloat = (union.isNull || union.width < 1 || union.height < 1) + ? 0.05 + : min(canvas.width / union.width, canvas.height / union.height) * 0.82 + + return ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.secondary.opacity(0.1)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.secondary.opacity(0.3))) + ForEach(tiles, id: \.0.recordID) { display, frame in + DisplayTile( + display: display, + tileSize: CGSize(width: frame.width * scale, height: frame.height * scale), + center: CGPoint(x: (frame.midX - union.midX) * scale + canvas.width / 2, + y: (frame.midY - union.midY) * scale + canvas.height / 2), + scale: scale) + } + } + .frame(width: canvas.width, height: canvas.height) + } +} + +private struct DisplayTile: View { + @EnvironmentObject private var model: AppModel + let display: DisplayObservation + let tileSize: CGSize + let center: CGPoint + let scale: CGFloat + @State private var drag: CGSize = .zero + + var body: some View { + let tint = display.isMain ? Color.accentColor : Color.secondary + RoundedRectangle(cornerRadius: 4) + .fill(tint.opacity(0.18)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(tint, lineWidth: display.isMain ? 2 : 1)) + .overlay( + VStack(spacing: 1) { + Text(model.displayName(for: display)).font(.caption2).lineLimit(1).padding(.horizontal, 3) + if display.isMain { Text("Main").font(.system(size: 8)).foregroundStyle(.secondary) } + } + ) + .frame(width: max(tileSize.width, 36), height: max(tileSize.height, 24)) + .position(x: center.x + drag.width, y: center.y + drag.height) + .gesture( + DragGesture() + .onChanged { drag = $0.translation } + .onEnded { value in + let dx = Int((value.translation.width / scale).rounded()) + let dy = Int((value.translation.height / scale).rounded()) + drag = .zero + guard dx != 0 || dy != 0 else { return } + let origin = DisplayOrigin(x: display.origin.x + dx, y: display.origin.y + dy) + Task { await model.setPosition(origin, for: display) } + } + ) + } +} +#endif diff --git a/Apps/OpenDisplayRescue/README.md b/Apps/OpenDisplayRescue/README.md new file mode 100644 index 0000000..383ccc6 --- /dev/null +++ b/Apps/OpenDisplayRescue/README.md @@ -0,0 +1,14 @@ +# OpenDisplay Rescue + +**macOS target — independent, minimal-dependency, signed/notarized.** A standalone rescue +app + CLI that can reconnect managed-offline displays, disable auto-apply policies, restore a +checkpoint, and launch safe mode **even when the main app is corrupt, crashed, or displayed on +the very screen being removed** (PRD LIF-011, DIA-010, D-004). + +Reads the rescue-readable `CheckpointStore` format directly. Its safety/restore logic reuses +`Packages/DisplayDomain` + `Packages/TopologyCore` and the `LifecycleProvider.recover(to:)` +contract. Rescue work always preempts ordinary queued operations. + +Milestone: **M0 (proof) → M2 (shipped)**. + +> Stub — Xcode target added on macOS. Process topology / IPC auth is open question Q-003. diff --git a/Apps/OpenDisplayRescue/Resources/Info.plist b/Apps/OpenDisplayRescue/Resources/Info.plist new file mode 100644 index 0000000..7af6258 --- /dev/null +++ b/Apps/OpenDisplayRescue/Resources/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleName + OpenDisplay Rescue + CFBundleDisplayName + OpenDisplay Rescue + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundlePackageType + APPL + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + diff --git a/Apps/OpenDisplayRescue/Resources/OpenDisplayRescue.entitlements b/Apps/OpenDisplayRescue/Resources/OpenDisplayRescue.entitlements new file mode 100644 index 0000000..2f6e399 --- /dev/null +++ b/Apps/OpenDisplayRescue/Resources/OpenDisplayRescue.entitlements @@ -0,0 +1,9 @@ + + + + + + com.apple.security.app-sandbox + + + diff --git a/Apps/OpenDisplayRescue/Sources/RescueApp.swift b/Apps/OpenDisplayRescue/Sources/RescueApp.swift new file mode 100644 index 0000000..6048333 --- /dev/null +++ b/Apps/OpenDisplayRescue/Sources/RescueApp.swift @@ -0,0 +1,139 @@ +#if os(macOS) +import CoreGraphicsProvider +import DisplayDomain +import ExperimentalLifecycleProvider +import Foundation +import SwiftUI +import TopologyCore + +/// The independent rescue utility (PRD LIF-011, DIA-010, D-004). It reads the last-known-safe +/// checkpoint the main app persisted to Application Support — a single well-known JSON file — and +/// restores the recorded arrangement even when the main app is unavailable. Recovery runs BOTH +/// mechanisms best-effort: a SkyLight re-enable transaction (undoes a private logical disconnect) +/// and a public Core Graphics un-mirror (undoes the mirroring fallback). Minimal-dependency. +@main +struct OpenDisplayRescueApp: App { + var body: some Scene { + WindowGroup("OpenDisplay Rescue") { + RescueView() + } + .defaultSize(width: 480, height: 360) + } +} + +@MainActor +final class RescueModel: ObservableObject { + @Published private(set) var status = "Reading last-known-safe checkpoint…" + @Published private(set) var displays: [DisplayObservation] = [] + @Published private(set) var capturedAt: Date? + @Published private(set) var busy = false + + private let store: (any CheckpointStoring)? + private let observer = CoreGraphicsProvider() // also the public un-mirror LifecycleProvider + private let reEnable = ExperimentalLifecycleProvider() // private SkyLight re-enable + private var checkpoint: Checkpoint? + + init() { + store = (try? DiskCheckpointStore.defaultDirectory()).map(DiskCheckpointStore.init(directory:)) + Task { + await load() + #if DEBUG + if ProcessInfo.processInfo.environment["OPENDISPLAY_RESCUE_RUN"] != nil { + await Self.dump(observer, "RESCUE before") + await reconnectAll() + await Self.dump(observer, "RESCUE after") + Self.err("RESCUE status: \(status)") + } + #endif + } + } + + func load() async { + guard let store else { + status = "Couldn't locate the checkpoint store." + return + } + guard let checkpoint = await store.latest() else { + status = "No checkpoint found yet — launch OpenDisplay once to record a baseline." + return + } + self.checkpoint = checkpoint + displays = checkpoint.observations.sorted { $0.recordID.rawValue < $1.recordID.rawValue } + capturedAt = checkpoint.createdAt + status = "Loaded the last-known-safe checkpoint. This runs independently of the main app." + } + + /// Restores the recorded arrangement. Runs both recovery paths best-effort — re-enabling a + /// privately-disabled display (SkyLight transaction) and un-mirroring a mirrored one (public + /// Core Graphics). Each is a no-op when not applicable, so it's safe to run unconditionally. + func reconnectAll() async { + guard let checkpoint else { return } + busy = true + defer { busy = false } + try? await reEnable.recover(to: checkpoint) // undo a private logical disconnect + try? await observer.recover(to: checkpoint) // undo the mirroring fallback + status = "Reconnect All complete — restored the recorded arrangement." + } + + #if DEBUG + private static func dump(_ observer: CoreGraphicsProvider, _ label: String) async { + let snapshot = await observer.currentSnapshot() + let summary = snapshot.observations + .map { "\($0.recordID.rawValue) active=\($0.isActive) mirror=\($0.mirrorSourceID?.rawValue ?? "none")" } + .joined(separator: " | ") + err("\(label) online=\(snapshot.observations.count): \(summary)") + } + + private static func err(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } + #endif +} + +struct RescueView: View { + @StateObject private var model = RescueModel() + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Image(systemName: "checkmark.shield").font(.system(size: 32)).foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text("OpenDisplay Rescue").font(.title3).bold() + if let capturedAt = model.capturedAt { + Text("Checkpoint captured \(capturedAt.formatted(date: .abbreviated, time: .standard))") + .font(.caption).foregroundStyle(.secondary) + } + } + } + + Divider() + + if model.displays.isEmpty { + Text(model.status).font(.callout).foregroundStyle(.secondary) + } else { + ForEach(model.displays, id: \.recordID) { display in + HStack(spacing: 8) { + Circle() + .fill(display.isActive ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(display.recordID.rawValue).font(.system(.body, design: .monospaced)) + if display.isMain { Text("Main").font(.caption2).foregroundStyle(.secondary) } + Spacer() + Text(display.isActive ? "Active" : "Offline") + .font(.caption).foregroundStyle(.secondary) + } + } + Text(model.status).font(.caption).foregroundStyle(.secondary) + } + + Spacer() + + Button("Reconnect All") { Task { await model.reconnectAll() } } + .keyboardShortcut(.defaultAction) + .disabled(model.busy || model.displays.isEmpty) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} +#endif diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..507a5a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to OpenDisplay are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows +[Semantic Versioning](https://semver.org/). OpenDisplay is pre-1.0 (0.x); anything may +change until 1.0. + +## [0.1.0] — 2026-06-23 + +First developer preview. The platform-independent safety core (domain models, state +machines, scene planner, `SafetyEngine`, serialized `TopologyCoordinator` with +checkpoint/rollback) is unit-tested (78 tests), and the macOS menu-bar app is functional +and verified on Apple Silicon hardware. + +### Added +- Menu-bar app with a unified **brightness** slider (built-in via DisplayServices, external + via DDC/CI, software-gamma fallback), **hardware controls** (contrast / volume / input / + colour preset over DDC/CI), **mirroring**, **resolution / refresh / HiDPI** switching, a + drag-to-arrange canvas, **per-display ICC colour profiles** (public ColorSync), **Black + Out**, and **software dimming**. +- **Safe logical disconnect / reconnect** with an always-one-display-active guarantee, + persisted managed-offline tracking, automatic fall-back to the built-in panel, and + independent recovery (menu, the global ⌃⌥⌘R hotkey, and a separate `OpenDisplayRescue` app). +- **Scenes**: capture and re-apply display arrangements. +- `opendisplay` **CLI** and **Shortcuts/Siri** intents that drive the same audited, + safety-checked command path as the UI. +- **Labs:** opt-in experimental display rotation through an isolated helper process — off by + default and compiled out of the public-API / App Store build. + +### Performance +- Apple-Silicon optimisation pass: private SPI (DisplayServices), DDC/CI controller + construction, ColorSync iteration, and EDID fingerprinting moved off the main thread; + batched registry persistence (one write per topology event instead of one per display); + cached display-mode enumeration in the detail pane; opt-in reconfiguration-callback + registration that also closes a callback/deinit race; pruned DDC handle caches across + reconnects. Removed a dead control-provider abstraction and other unused code. + +### Foundations +- Project scaffolding: SPM monorepo with platform-independent domain packages + (`DisplayDomain`, `ProviderInterfaces`, `SceneEngine`, `AutomationSchema`, + `TopologyCore`) plus `SimulatorProvider`, and their unit tests. +- Safety core: lifecycle & transaction state machines, `SafetyEngine` (safe-surface and + preflight rules), `IdentityScorer` (multi-signal confidence), and the serialized + `TopologyCoordinator` with checkpoint/rollback. +- `SceneEngine` desired-state planner with deterministic, idempotent, safely-ordered diffs. +- Stable `AutomationSchema` JSON result envelope and selector grammar. +- Initial documentation (architecture, recovery model, decisions, PRD) and open-source + governance (contributing, security, code of conduct, RFC and issue/PR templates). +- macOS target scaffolding for the app, rescue utility, CLI, providers, and design system. +- Local-first developer tooling: `Makefile` (`make bootstrap`/`build`/`test`/`lint`/`xcode`) + and `scripts/bootstrap-swift.sh` to install a Swift 6 toolchain on Ubuntu / verify Xcode on macOS. +- Xcode project scaffolding via XcodeGen (`project.yml`, `scripts/generate-xcodeproj.sh`, + `make xcode`): macOS app + public-API-only variant, rescue app, CLI, design-system and + provider frameworks, with compile-ready stubs wired to the `SimulatorProvider`. + +### Changed +- Hardened the disconnect transaction after review: `.blocked` preflights are non-bypassable + (removed the `userOverride` escape hatch); the confirmation handler now defaults to *cancel* + rather than silently approving `.needsConfirmation`; and verification now rolls back if any + unrelated active display is unexpectedly lost, not only the target (PRD §9.2/§9.4). +- Verification is now **local-first**: removed the remote GitHub Actions CI workflow; run + `make test` locally before pushing. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cb2ecc1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,34 @@ +# Code of Conduct + +## Our pledge + +We as members, contributors, and maintainers pledge to make participation in the OpenDisplay +community a harassment-free experience for everyone, regardless of age, body size, visible or +invisible disability, ethnicity, sex characteristics, gender identity and expression, level +of experience, education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +We adopt the **[Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)** +as our code of conduct. The full text — including expected behavior, unacceptable behavior, +enforcement responsibilities, scope, and enforcement guidelines — is incorporated here by +reference. + +## Standards (summary) + +Examples of behavior that contributes to a positive environment: empathy and kindness, +respect for differing opinions, graceful acceptance of constructive feedback, and focusing on +what is best for the community. + +Unacceptable behavior includes: sexualized language or imagery, trolling or insulting +comments, public or private harassment, publishing others' private information without +permission, and other conduct reasonably considered inappropriate in a professional setting. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the +maintainers at `conduct@opendisplay.example` *(placeholder — replace before public launch)*. +All complaints will be reviewed and investigated promptly and fairly. Maintainers will +respect the privacy and security of the reporter. + +Maintainers who do not follow or enforce this Code of Conduct in good faith may face +temporary or permanent repercussions as determined by the project's maintainer council. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a350cf3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to OpenDisplay + +Thanks for your interest. OpenDisplay is a **clean-room**, safety-first, open-source macOS +project. Please read this before opening a PR. + +## Clean-room rule (important) + +OpenDisplay is functionally inspired by publicly documented display-management workflows but +is **not** affiliated with BetterDisplay. Do **not** contribute: + +- decompiled or reverse-engineered proprietary code, +- copied UI layouts, marketing copy, icons, screenshots, or trade dress, +- code with unknown or incompatible license/provenance. + +Every nontrivial contribution must be **your original work**, or it must identify the +upstream source and its license. Maintainers may ask for provenance notes. + +## Developer Certificate of Origin (DCO) + +Sign off every commit (`git commit -s`) to certify the DCO. Your `Signed-off-by:` line +asserts you have the right to submit the work under the project license. + +## Getting started + +```sh +make bootstrap # ensure a Swift 6 toolchain (installs on Ubuntu; checks Xcode on macOS) +make test # builds & runs the cross-platform test suite (Swift 6, macOS or Linux) +``` + +The macOS app, providers, rescue utility, CLI, and SwiftUI design system require **Xcode +16+** (generate the project with `make xcode`). New safety/state logic should land in the +cross-platform packages with unit tests so it can be verified locally with `make test`, +no hardware needed. + +## What every PR needs + +- A linked issue and a clear summary. +- **Tests:** unit/state-machine tests for logic; for provider changes, hardware evidence + (Mac model/chip, OS build, route, display) per the compatibility report form. +- **Verify locally before pushing:** `make test` green; SwiftLint clean; the + **public-API-only** build still compiles (no experimental-provider deps). There is no + remote CI — local verification is the gate. +- Docs updated when behavior changes. +- The PR checklist completed (see the pull request template). + +## Changes that need extra review + +Any change to **lifecycle, the transaction coordinator, checkpoints, the rescue path, +startup, IPC, capture, update, or network** requires a **threat & recovery review**, and +typically an [RFC](Docs/RFCs/0000-template.md). The same applies to provider interfaces, +schema/API breaking changes, telemetry, licensing, and Labs → Core graduation. + +## Safety expectations + +- Never weaken a §9.2 invariant without an accepted RFC. +- A provider call is not success — verify postconditions or report `unverified`. +- No feature may obscure or intercept the emergency recovery command. + +## Code style + +Swift 6, 4-space indentation, `swift-format`/SwiftLint configs in the repo root. Prefer +value types and dependency injection so logic stays testable against `SimulatorProvider`. + +By contributing, you agree your contributions are licensed under the project license +(GPL-3.0-or-later) and you abide by the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/Docs/Architecture/decisions.md b/Docs/Architecture/decisions.md new file mode 100644 index 0000000..133b2c4 --- /dev/null +++ b/Docs/Architecture/decisions.md @@ -0,0 +1,36 @@ +# Architecture decision records + +Accepted and proposed decisions carried from the [PRD](../PRD.md) §21 decision log. New +significant decisions are added here (newest first) and, when they change provider +interfaces, lifecycle invariants, schema/API, telemetry, licensing, or Labs graduation, +must go through an [RFC](../RFCs/0000-template.md). + +| ID | Decision | Status | Rationale | +|----|----------|--------|-----------| +| D-001 | Core / Labs product split | Accepted | Keeps experimental system mechanisms out of normal startup & recovery. | +| D-002 | Apple Silicon is the certified lifecycle baseline | Accepted | Public evidence shows materially different Intel behavior. | +| D-003 | Logical disconnect is a transaction, not a direct command | Accepted | Enables preflight, checkpoint, verification, rollback, audit. | +| D-004 | Ship a standalone rescue utility | Accepted | The main UI may be on the very display being removed. | +| D-005 | Normal quit reconnects managed-offline displays by default | Accepted | Conservative recovery expectation; persistence stays explicit. | +| D-006 | No analytics by default | Accepted | Open-source trust; display/capture sensitivity. | +| D-007 | Direct signed/notarized distribution is the baseline | Accepted | Advanced lifecycle features may not fit App Store constraints. | +| D-008 | Maintain a public-API-only build path | Accepted | Reduces platform/legal risk; preserves a stable subset. | +| D-009 | Stable internal IDs + scored fingerprint evidence | Accepted | Transient display IDs and identical hardware make single-key identity unsafe. | +| D-010 | Provider call success is not product success | Accepted | All applicable operations require observation/read-back or an explicit `unverified` result. | +| D-011 | Working license direction is GPL-3.0-or-later for the app | Proposed | Strong copyleft supports the open-source goal; counsel/community approval required. | +| D-012 | Working project name is "OpenDisplay" | Proposed | Internal label only; trademark/package-identifier clearance required. | + +## How decisions map to code + +- D-003 / D-010 → `Packages/TopologyCore` (`TopologyCoordinator`, `SafetyEngine`) and the + transaction state machine in `Packages/DisplayDomain/LifecycleState.swift`. +- D-009 → `Packages/DisplayDomain/Identity.swift` (`IdentityScorer`, confidence threshold). +- D-001 / D-008 → provider isolation behind `Packages/ProviderInterfaces`; the + experimental lifecycle and virtual-display providers are separable targets absent from + the public-API-only flavor. +- D-004 → `Apps/OpenDisplayRescue` reads the `CheckpointStore` format independently. + +## Open questions (need owners / legal) + +Q-001 certified OS/Mac matrix · Q-002 private-API/entitlement set · Q-003 rescue process +topology · Q-004 default recovery hotkey · Q-005 license/SDK boundary. See PRD §21.2. diff --git a/Docs/Architecture/overview.md b/Docs/Architecture/overview.md new file mode 100644 index 0000000..a2e0807 --- /dev/null +++ b/Docs/Architecture/overview.md @@ -0,0 +1,78 @@ +# Architecture overview + +This document summarizes how OpenDisplay is structured. The normative source is +[the PRD](../PRD.md) §10 (Technical architecture) and §9 (Safe display disconnection). + +## Layering + +``` +UI (SwiftUI) / App Intents / CLI / local HTTP (1.x) / Rescue + │ + CommandGateway / AutomationGateway ← all external commands take the same + │ safety/verification/audit path + TopologyCoordinator (actor) ← single owner of every topology/lifecycle write + ┌───────────────┼────────────────┐ + ScenePlanner SafetyEngine Activity/Audit + └────────── Desired State ───────┘ + │ + DisplayRegistry (actor) ← single source of OBSERVED truth; topology generations + IdentityResolver · CapabilityResolver + │ + ProviderRouter / ControlRouter + ┌──────────┬──────────┬───────────┬──────────────┬───────────────────────┐ + CoreGraphics DDC NativeControl Capture ExperimentalLifecycle VirtualDisplay + Provider Provider Provider Provider (optional target) (Labs target) + │ + macOS + display hardware + +Persistent: SettingsStore · CheckpointStore (rescue-readable) · HealthMarker · + DiagnosticsStore · Keychain · UpdateCompatibility · RecoveryService +``` + +## What lives where + +| Layer | Packages / targets | Platform | +|-------|--------------------|----------| +| Domain (pure logic) | `DisplayDomain`, `ProviderInterfaces`, `SceneEngine`, `AutomationSchema`, `TopologyCore`, `SimulatorProvider` | cross-platform; verified locally with `make test` | +| Concrete providers | `Providers/*` | macOS | +| Apps & tools | `Apps/OpenDisplay`, `Apps/OpenDisplayRescue`, `Tools/opendisplay` | macOS | +| Design system | `Packages/OpenDisplayDesignSystem` | macOS (SwiftUI) | + +The split is deliberate: the safety-critical logic (identity scoring, the lifecycle & +transaction state machines, the safety engine, the scene planner) is **platform-independent +and fully unit-testable without hardware**. Concrete providers implement the protocols in +`ProviderInterfaces`; the coordinator only ever talks to protocols, so it can be exercised +end-to-end against `SimulatorProvider`. + +## Concurrency & ownership + +- `DisplayRegistry` (actor) owns normalized **observed** state and bumps the + `TopologyGeneration` only after the topology stabilizes. +- `TopologyCoordinator` (actor) owns the **mutation queue**; at most one transaction is + non-terminal at a time. Recovery preempts ordinary work. +- Providers are stateless where practical; per-route caches are versioned by topology + generation. +- UI consumes immutable snapshots and submits commands — it never mutates domain models. + +## Key invariants (enforced in `TopologyCore`) + +1. At most one topology/lifecycle transaction is active. +2. No logical disconnect without an atomic last-known-safe checkpoint. +3. No default operation removes the last known-safe recoverable display. +4. A target below the destructive identity-confidence threshold is not mutated without + explicit confirmation. +5. Success is reported only after observed postconditions; otherwise failed / unverified / + degraded / rolled back. +6. Reconnect All preempts queued work and is reachable from an independent process. + +See [the recovery model](../Recovery/recovery.md) for the disconnect transaction stages and +the recovery hierarchy. + +## Build flavors + +- **Core / full** — public APIs + hardware protocols + narrowly isolated experimental + providers approved by maintainers. +- **Public-API-only** — documented Apple APIs + hardware/network protocols only; no + private lifecycle/virtual/system-override provider. Keep this flavor compiling locally (NFR-010). +- **Labs** — opt-in, kill-switchable modules for unstable/undocumented behavior; never a + Core startup or recovery dependency. diff --git a/Docs/Compatibility/README.md b/Docs/Compatibility/README.md new file mode 100644 index 0000000..8855df6 --- /dev/null +++ b/Docs/Compatibility/README.md @@ -0,0 +1,17 @@ +# Compatibility + +This directory will hold the **certified compatibility matrix**: which Mac/OS/display/route +combinations are certified, experimental, or unsupported for each capability — especially the +logical-disconnect lifecycle (PRD §15.4–15.5, §17.4). + +Compatibility is established by our own instrumented hardware testing, not assumed from public +reports. Each lifecycle certification entry records: Mac model/chip, OS build, display model/ +firmware, route (direct/dock/KVM/adapter), lid/power state, and the results of first-use, +repeat, wake, reboot, normal-quit, crash, provider-hang, route-loss, and Reconnect All tests, +plus at least one accessibility (keyboard/VoiceOver) recovery run. + +Community results come in through the **Compatibility report** issue form; please redact +serials and other identifying data. + +> Baseline: macOS 13 Ventura → macOS 26 Tahoe; Apple Silicon first; Intel best-effort and +> capability-gated. The signed compatibility/kill-switch dataset ships with each stable release. diff --git a/Docs/HANDOVER.md b/Docs/HANDOVER.md new file mode 100644 index 0000000..c193c9d --- /dev/null +++ b/Docs/HANDOVER.md @@ -0,0 +1,139 @@ +# OpenDisplay — Session Handover + +**Last updated:** 2026-06-22 · **Branch:** `claude/trusting-dirac-pewpub` · **HEAD:** `3b9f74a` +· **PR:** #10 (open, ready for review) · **Repo:** `aquitaine/OpenDisplay` + +## TL;DR +OpenDisplay is an open-source **macOS** display-management app (Swift 6, SwiftUI/AppKit, +actor-isolated coordinator, provider architecture). Headline feature: **safe logical display +disconnect/reconnect with independent recovery**. The platform-independent core is built and +**unit-tested (42/42)**; the macOS app/providers/CLI/rescue are **scaffolded but not yet +Xcode-compiled**. Pick up by building on a Mac, then start the **M0 safety spike**. + +## ⚠️ Environment reality (the local-vs-remote confusion) +All work so far happened in a **Linux cloud container** (Claude Code on the web), **not on a +Mac**. That container has a Linux Swift 6.0.3 toolchain (runs `swift test`) but **no Xcode, +no SwiftUI/AppKit/CoreGraphics**. Consequences: +- The cross-platform packages are **verified** (compiled + tested on Linux Swift 6). +- The macOS-only sources (`Apps/`, `Providers/`, `Tools/`, `Packages/OpenDisplayDesignSystem`) + were **authored but never compiled** — expect to fix a few first-build errors on the Mac. +- Nothing is on your Mac's disk yet; the code lives only in Git. **This new session should run + on your Mac** (or a macOS environment) so it can use Xcode. + +## Get the code (on your Mac) +```sh +cd ~/Developer # or wherever you keep projects +git clone https://github.com/aquitaine/OpenDisplay.git +cd OpenDisplay +git checkout claude/trusting-dirac-pewpub +``` + +## Build & run (on your Mac) +Full steps in `Docs/MacQuickstart.md`. Short version (needs Xcode 16+ / Swift 6 and Homebrew): +```sh +make bootstrap # verifies Swift 6 / Xcode +make test # cross-platform core — expect 42/42 +make xcode # installs XcodeGen via brew, runs `xcodegen generate` +open OpenDisplay.xcodeproj +``` +Run the **OpenDisplay** scheme → menu-bar app (LSUIElement, no Dock icon) showing 3 demo +displays + working **Reconnect All**, backed by an in-memory `SimulatedDisplaySystem`. +Headless: `xcodebuild -scheme OpenDisplay build`, `-scheme OpenDisplay-PublicAPIOnly build`, +`-scheme opendisplay build` then `opendisplay list` / `opendisplay recover`. + +## Current status +| Area | State | +|------|-------| +| Cross-platform core | ✅ implemented + **42 tests pass** (Swift 6.0.3) | +| Safety logic (SafetyEngine, TopologyCoordinator, state machines) | ✅ implemented + tested; Codex P1s fixed | +| Scene planner, identity scoring, selectors, result schema | ✅ implemented + tested | +| macOS app / providers / CLI / rescue / design system | 🟡 scaffolded, compile-ready stubs, **not Xcode-built** | +| Xcode project (XcodeGen `project.yml`) | ✅ present; generate with `make xcode` (not committed) | +| Remote CI | ❌ removed by design — local `make test` is the gate | +| Real display providers (CoreGraphics / lifecycle) | ⬜ not started — this is M0 | + +## Repo layout (94 files) +``` +Package.swift SPM manifest — CROSS-PLATFORM core only (keep Linux-green) +project.yml XcodeGen spec for the macOS targets (generates OpenDisplay.xcodeproj) +Makefile make bootstrap | test | xcode | lint | clean +Packages/ + DisplayDomain/ ✅ models, identity scoring, lifecycle+transaction state machines + ProviderInterfaces/ ✅ provider protocols + typed failures + SceneEngine/ ✅ desired-state scene diff/plan (idempotent, safely ordered) + AutomationSchema/ ✅ stable JSON result envelope + selector grammar + TopologyCore/ ✅ SafetyEngine + TopologyCoordinator + InMemoryCheckpointStore + SimulatorProvider/ ✅ in-memory display system + fault injection (tests/previews) + OpenDisplayDesignSystem/ 🟡 SwiftUI tokens stub + reference/ (the original design kit = source of truth) +Providers/ 🟡 CoreGraphics, DDC, NativeControl, Capture, ExperimentalLifecycle, VirtualDisplay (stubs) +Apps/OpenDisplay/ 🟡 menu-bar app (OpenDisplayApp, AppModel, MenuBarView, SettingsView) +Apps/OpenDisplayRescue/ 🟡 independent rescue app +Tools/opendisplay/ 🟡 CLI (list/recover stub; ArgumentParser + full grammar in M1) +Docs/ PRD.md (normative spec), Architecture/, Recovery/, Compatibility/, RFCs/, MacQuickstart.md +Tests/ Fixtures/, HardwareLab/ (placeholders for M0+) +``` +Tests live in `Packages//Tests`. `make test` runs all 42. + +## Architecture (1-minute version) +`DisplayRegistry` (observed truth, actor) → `TopologyCoordinator` (the **only** writer of +topology/lifecycle, actor) which runs every disconnect as a staged transaction: +**resolve → preflight (SafetyEngine) → checkpoint → confirm → apply (provider) → observe → +verify → commit/rollback.** Providers sit behind protocols (`ProviderInterfaces`); the +experimental lifecycle + virtual-display providers are separable and excluded from the +public-API-only build. Provider success ≠ product success — outcomes are verified or reported +`unverified`. Details: `Docs/Architecture/overview.md`, `Docs/Recovery/recovery.md`, PRD §9–§10. + +## Key invariants (don't weaken without an RFC) — PRD §9.2 +One active transaction at a time · no disconnect without an atomic checkpoint · never remove +the last safe recoverable display by default · success only after observed postconditions · +Reconnect All preempts + works from an independent process · safe mode disables experimental +providers first. + +## Decisions & open questions +- **Accepted:** Core/Labs split (D-001), Apple Silicon lifecycle baseline (D-002), disconnect + is a transaction (D-003), standalone rescue utility (D-004), reconnect-on-quit default + (D-005), no analytics (D-006), Developer ID signed/notarized distribution (D-007), + public-API-only build path (D-008), stable IDs + scored fingerprints (D-009), verify-not-assume + (D-010). Full list: `Docs/Architecture/decisions.md`. +- **Proposed/legal:** GPL-3.0-or-later (D-011), project name "OpenDisplay" (D-012). +- **Open (need you/legal):** certified OS/Mac matrix (Q-001), private-API/entitlement set + (Q-002), rescue process topology/IPC (Q-003), default recovery hotkey (Q-004), license/SDK + boundary (Q-005). The app/rescue entitlements currently set `app-sandbox = false` pending Q-002. + +## GitHub +- **PR #10** open (ready for review). Codex left 3 P1s on `TopologyCoordinator` — all fixed in + `b6ca341` (non-bypassable blocked preflights; fail-safe default confirm handler; verify "no + unexpected endpoint lost"). +- **Epics #1–#9** track the roadmap, labeled by milestone (`M0`…`M4`). NOTE: GitHub *milestone + objects* couldn't be created via tooling — they're encoded as labels; create real milestones + in the UI if you want them. +- **No remote CI** (removed). Verify locally with `make test` before pushing. + +## What the new (Mac) session should do first — M0 safety spike +1. Build the scaffold (`make xcode` → run **OpenDisplay**); fix any first-build compile issues. +2. **CoreGraphicsProvider**: real display enumeration + a `TopologyObserving` event source; + swap into `Apps/OpenDisplay/Sources/AppModel.swift` in place of `SimulatedDisplaySystem`. +3. **ExperimentalLifecycleProvider**: logical disconnect/reconnect spike on Apple Silicon; + wire behind `#if !PUBLIC_API_ONLY`; verify the full coordinator path on real hardware. +4. Disk-backed, rescue-readable `CheckpointStore` + global Reconnect-All hotkey; finish the + rescue app end-to-end. +5. Port design-system components + the 11 menu-bar states from + `Packages/OpenDisplayDesignSystem/reference/`. +6. Hardware certification (PRD §15), fault/recovery subset first (T-006/007/008/017/021). + +The detailed full-lifecycle plan is in the PRD (`Docs/PRD.md`) §19 and the architecture docs. + +## Gotchas +- Keep `Package.swift` cross-platform (no macOS imports) so `make test` runs without Xcode. + macOS code lives outside SPM target paths and is built only by Xcode. +- The generated `OpenDisplay.xcodeproj` is **git-ignored** — regenerate with `make xcode`. +- Provider files are guarded by `#if os(macOS)`; the CLI `main.swift` uses top-level `await`. +- On macOS, if `swift --version` shows 5.x, run `sudo xcode-select -s /Applications/Xcode.app`. + +## Kickoff prompt for the new session (paste this) +> I'm continuing the OpenDisplay project on my Mac (Xcode 16, Apple Silicon). The repo is +> checked out on branch `claude/trusting-dirac-pewpub`. Read `Docs/HANDOVER.md` and +> `Docs/MacQuickstart.md`, then: run `make test` (expect 42/42), `make xcode`, build & run the +> OpenDisplay scheme, and fix any compile issues. After it runs, start the M0 safety spike — +> implement a real `CoreGraphicsProvider` (display enumeration + `TopologyObserving`) and wire +> it into `AppModel`. Verify with `make test` before any push. diff --git a/Docs/InterfaceRedesign.md b/Docs/InterfaceRedesign.md new file mode 100644 index 0000000..3f20ceb --- /dev/null +++ b/Docs/InterfaceRedesign.md @@ -0,0 +1,150 @@ +# Interface Redesign — Restore the Menu-Bar / Settings Division of Labor + +Status: Proposed · Owner: TBD · Target: M1 polish + +## Problem + +The menu-bar popover has absorbed the entire Settings "Detail" pane. The per-display +card in [`MenuBarView.swift`](../Apps/OpenDisplay/Sources/MenuBarView.swift) carries a +**12-row action list with nested disclosure-within-disclosure** (Set as main, Display +mode, Mirror, Move in arrangement, Rotation, Colour mode, Colour profile, Image +adjustments, Hardware control, Input source, Rename, Display info). + +The reference design kit (the project's source of truth, +[`reference/screens-shared.jsx`](../Packages/OpenDisplayDesignSystem/reference/screens-shared.jsx) +`MBDisplay`) intends a lean card: brightness + volume sliders, a status-chip row, and a +small set of quick actions. The Settings window — meant to be a sidebar (Detail · Arrange +· Scenes · Automation · Health & Recovery · Labs) — is instead a thin 3-tab shell that +**duplicates the display list** and **mis-files Arrange under Scenes**. + +### Concrete redundancy / doubling inventory + +1. **Brightness ×3** — native slider, "Image adjustments" (software gamma), "Hardware + control" (DDC brightness). All dim the screen; three separate places. +2. **Display list ×2** — menu-bar cards + Settings "Displays" tab (adds only alias edit). +3. **Resolution ×2** — inline slider + "Display mode" expandable (refresh / HiDPI). +4. **Colour ×2 rows** — "Colour mode" (DDC preset) vs "Colour profile" (ColorSync). +5. **Arrangement ×3 entry points** — card "Move in arrangement…", Tools "Displays & + arrangement…", and the canvas itself filed under Settings → **Scenes**. +6. **Reconnect All ×2** — menu bar + Settings (this one is *intentional* per PRD recovery + requirement; keep both but share one component). +7. **Rotation split** — control lives in the card; its enable-toggle is buried in + Settings → Diagnostics → Labs. +8. **Settings structure drift** — intended sidebar collapsed into 3 tabs; Arrange under + "Scenes" is a category error. + +### Key finding + +Every capability in the reference (`blackOut`, `monitorPower`/Sleep, `volume`, +`nativeBrightness` / `ddcBrightness` / `softwareDimming`, `hdr`, `colorProfile`, …) is +**already modeled** in [`Capability.swift`](../Packages/DisplayDomain/Sources/DisplayDomain/Capability.swift). +This is a **UI reorganization**, not a backend build. The only genuinely new wiring is +Black Out / Sleep quick actions (capabilities exist; provider hookup may be partial). + +## Principle + +**Menu bar = fast, frequent, safe. Settings = detail, configuration, recovery.** +Every change below follows from re-establishing that split, matching the reference kit. + +## Decisions (locked) + +- **Scope:** Faithful rebuild toward the reference kit (both surfaces). +- **Brightness:** One capability-aware slider (native → DDC → software-gamma, auto-picked, + method shown as a caption). Explicit manual split moves to Settings → Controls. + +--- + +## Phased plan + +### Phase 0 — Design-system components (foundation) + +The card hand-rolls everything inline (`MenuActionRow`, `DisplayCard`). Port the shared +kit components into `OpenDisplayDesignSystem` so both surfaces consume them (README already +scopes these as the "14 components" port): + +- `Badge`, `Dot`, `GlyphTile`, `SectionLabel` +- `Card`, `Row`, `LabeledRow` +- `MBSliderRow`, `MBChip`, `QuickAction` + +Each ships SwiftUI `#Preview`s mirroring the reference states. No behavior change yet. + +### Phase 1 — Capability-aware brightness service + +Introduce a `BrightnessController` (in `AppModel` or a dedicated service) that resolves the +best route per display from capability: `nativeBrightness` → `ddcBrightness` → +`softwareDimming`. Exposes: + +- `level(for:) -> Double?` and `setLevel(_:for:)` +- `method(for:) -> BrightnessMethod` (`native` / `hardware` / `software`) for the caption + +Consolidates the three existing paths (`brightness`, `softwareDim`, DDC brightness). The +explicit per-route controls survive only in Settings → Controls for power users. + +### Phase 2 — Slim the menu-bar card (`MBDisplay`) + +Rebuild `DisplayCard` to match the reference: + +- **Collapsed:** GlyphTile · name · sub (`res · Hz`) · trailing badge (Main / Offline / + Reconnecting / Degraded / Ambiguous) · chevron. +- **Expanded (active only):** + - Brightness slider (unified, with method caption) + - Volume slider — *rendered only when `volume` capability is supported* + - Status-chip row: resolution · Hz · HDR · True Tone (chips reflect/toggle state) + - Quick actions: **Black Out · Sleep · Set as main** (and Disconnect where the header + on/off toggle lives today) — *each gated on its capability; hidden or "Soon" when absent* + - One **"Display settings…"** deep-link row (replaces the 12-row list) + +Remove from the card → moves to Settings Detail: Display mode, Colour mode, Colour +profile, Image adjustments, Hardware control, Input source, Rotation, Rename, Display info. + +Capability gating rule: never render a faked control. If a capability is `unsupported`, +hide it; if `unknown`/probing, show "Reading…"; consistent with today's "Soon" pill. + +### Phase 3 — Settings: sidebar + per-display Detail pane + +Replace the 3-tab `TabView` with a `NavigationSplitView` sidebar matching the kit: + +- **Displays** — a selection list (left) feeding a **Detail pane** (right). The list + *replaces* today's duplicated "Displays" tab. Detail pane is `Card`-based: + - *Resolution* — resolution menu/slider + refresh + HiDPI (from card "Display mode") + - *Appearance* — rotation, colour mode, colour profile, image adjustments + - *Controls* — DDC hardware (contrast/volume), input source, explicit brightness split + - *Use as* — set as main, mirror + - *Lifecycle* — disconnect / reconnect / managed-offline state + - *Info* + *Rename* (alias) +- **Arrange** — promote `DisplayArrangementView` out of Scenes into its own item. +- **Scenes** — keep, minus the arrangement canvas. +- **Health & Recovery** — rename "Diagnostics & Recovery"; providers + recovery + recent + activity, with **Labs** (rotation enable toggle, future virtual displays, kill switch) + as a section here or its own sidebar item. + +### Phase 4 — Deep-linking & entry-point dedupe + +- Card **"Display settings…"** opens Settings to the selected display's Detail pane + (add `selectedDisplayID` to `AppModel`). +- Collapse "Move in arrangement…" + "Rename & manage…" + per-feature "Open Display + Settings…" into that single deep-link. +- Extract a shared `ReconnectAllButton` used by both the menu bar and Health & Recovery + (keep both placements — recovery-critical per PRD UX-001 / §recovery). + +### Phase 5 — Polish & verify + +- Risk pills (UX-06), VoiceOver labels (UX-07), reduced-motion for disclosure animations. +- Build, launch, screenshot both surfaces; compare against reference screens. + +--- + +## Sequencing & risk + +- Phase 0 and 1 are prerequisites. Phases 2 and 3 can proceed in parallel once 0/1 land. + Phase 4 depends on 3. +- Lowest risk: it's reorganization over an already-complete domain/provider layer. No new + private APIs; rotation stays gated as today. +- Biggest behavioral change for users: the card gets dramatically shorter; detail moves + one click away into Settings. Mitigate with the deep-link so detail is never buried. + +## Out of scope (this pass) + +- New providers for Black Out / Sleep beyond wiring existing capabilities. +- Automation surface (stub only / defer). +- Virtual displays, Recovery OSD full-screen (tracked separately in the kit). diff --git a/Docs/MacQuickstart.md b/Docs/MacQuickstart.md new file mode 100644 index 0000000..ea16e45 --- /dev/null +++ b/Docs/MacQuickstart.md @@ -0,0 +1,61 @@ +# macOS Quickstart (Claude Code on your Mac) + +This repo's cross-platform core was built and tested on Linux; the **macOS app, providers, +rescue utility, CLI, and design system are built on a Mac**. Use this to pick up on macOS. + +Pick up from branch **`claude/trusting-dirac-pewpub`** (PR #10). The cross-platform core has +42 passing tests; the Xcode targets are scaffolded (XcodeGen) and wired to an in-memory +`SimulatedDisplaySystem`, so the app runs before the real providers exist. + +## Prerequisites +- **Xcode 16+** (Swift 6). Verify: `swift --version` → 6.x. If it shows 5.x, run + `sudo xcode-select -s /Applications/Xcode.app`. +- **Homebrew** (used to install XcodeGen). + +## Get it running (turnkey) +```sh +git fetch origin +git checkout claude/trusting-dirac-pewpub && git pull + +make bootstrap # verifies Swift 6 / Xcode +make test # cross-platform core — expect 42/42 passing + +make xcode # installs XcodeGen via Homebrew, runs `xcodegen generate` +open OpenDisplay.xcodeproj +``` +In Xcode, run the **OpenDisplay** scheme: a menu-bar app appears (no Dock icon — it's an +`LSUIElement` agent) showing three demo displays with a working **Reconnect All**. + +Headless equivalents: +```sh +xcodebuild -scheme OpenDisplay build +xcodebuild -scheme OpenDisplay-PublicAPIOnly build # public-API-only flavor (NFR-010) +xcodebuild -scheme opendisplay build +# then: +opendisplay list # ● disp_builtin (main) / ● disp_studio / ○ disp_lg +opendisplay recover # reconnects managed-offline displays +``` + +> The macOS sources (`Apps/`, `Providers/`, `Tools/`, `Packages/OpenDisplayDesignSystem`) +> were authored on Linux and have **not** been Xcode-compiled. Expect to fix a few +> compile issues on first build — that's the point of moving to the Mac. + +## First M0 tasks (in order) +See the [PRD](PRD.md) §9–§10 and the architecture/recovery docs. +1. **CoreGraphicsProvider** — real display enumeration + a `TopologyObserving` event source; + swap it into `Apps/OpenDisplay/Sources/AppModel.swift` in place of `SimulatedDisplaySystem`. +2. **ExperimentalLifecycleProvider** — logical disconnect/reconnect spike on Apple Silicon; + wire into the app behind `#if !PUBLIC_API_ONLY`; verify the full `TopologyCoordinator` path + (preflight → checkpoint → apply → verify → commit/rollback) on real hardware. +3. **Disk-backed, rescue-readable `CheckpointStore`** + the global Reconnect-All hotkey; finish + `OpenDisplayRescue` end-to-end (reads the checkpoint independently of the main app). +4. **Design-system port** — components + the 11 menu-bar states from + `Packages/OpenDisplayDesignSystem/reference/`. +5. **Hardware certification** — PRD §15, starting with the fault/recovery subset + (T-006/T-007/T-008/T-017/T-021). + +## Verification +`make test` → 42/42; `xcodebuild -scheme OpenDisplay build` and +`-scheme OpenDisplay-PublicAPIOnly build` succeed; the menu-bar app runs and Reconnect All +works; `opendisplay list`/`recover` print the expected output. The fault-injection + recovery +suite is the release gate (PRD §16.2). diff --git a/Docs/PRD.md b/Docs/PRD.md new file mode 100644 index 0000000..2d20190 --- /dev/null +++ b/Docs/PRD.md @@ -0,0 +1,1902 @@ +**OPENDISPLAY** + +Product Requirements +Document + +An open-source macOS display-management platform + +| | | | +|-----|-----|-----| + +| | **Primary product promise** Reliable control of multiple displays with safe, reversible display disconnection, strong recovery, and open governance. The design is clean-room and functionally inspired by publicly documented display-management workflows; it is not affiliated with or endorsed by BetterDisplay. | +|-----|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +**DRAFT v1.0** + +Prepared: 21 June 2026 + +Status: Product and technical baseline for discovery, architecture, and delivery planning + +Audience: Product, macOS engineering, design, QA, security, legal, and open-source maintainers + +**Working name only.** “OpenDisplay” requires trademark and package-identifier clearance before public use. + +# Document control + +| **Field** | **Definition** | +|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| Document owner | Product lead / founding maintainer | +| Technical owner | macOS platform lead | +| Decision authority | Maintainer council for product scope; security owner for recovery-critical changes | +| Status | Draft baseline | +| Version | 1.0 | +| Date | 21 June 2026 | +| Target release | Core 1.0, followed by Core 1.x and opt-in Labs | +| Primary platforms | macOS 13 Ventura through macOS 26 Tahoe; Apple Silicon first | +| License direction | GPL-3.0-or-later for the application and recovery stack; Apache-2.0 or MIT for a separately packaged SDK, subject to legal review | +| Research method | Clean-room synthesis of public product pages, documentation, release notes, issue reports, Apple documentation, and adjacent open-source projects | + +## Approval record + +| **Role** | **Name** | **Decision** | **Date** | +|----------------------|----------|--------------|----------| +| Product | TBD | Pending | — | +| Engineering | TBD | Pending | — | +| Security | TBD | Pending | — | +| Design/accessibility | TBD | Pending | — | +| Legal/open-source | TBD | Pending | — | + +## How to use this PRD + +This document establishes product intent, scope, user outcomes, functional and non-functional requirements, safety rules, architecture boundaries, release stages, acceptance gates, and research provenance. It deliberately separates Core features from Labs features that may rely on undocumented macOS behavior. Requirement IDs are normative. Narrative sections explain rationale and implementation constraints but do not override explicit acceptance criteria. + +| | **Normative language** “Shall” indicates a release requirement. “Should” indicates a committed target that may be deferred only through an explicit product decision. “Could” indicates optional scope. “Experimental” does not relax safety, recovery, privacy, or transparency requirements. | +|-----|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +# Contents + +| **1** | [Executive summary](#executive-summary) | +|--------|----------------------------------------------------------------------------------------------------------------------| +| **2** | [Product principles and clean-room boundary](#product-principles-and-clean-room-boundary) | +| **3** | [Problem, opportunity, and users](#problem-opportunity-and-users) | +| **4** | [Goals, success definition, and non-goals](#goals) | +| **5** | [Scope and release model](#scope-and-release-model) | +| **6** | [Research synthesis](#research-synthesis) | +| **7** | [Reference feature inventory and proposed disposition](#reference-feature-inventory-and-proposed-disposition) | +| **8** | [Product experience and core workflows](#product-experience-and-core-workflows) | +| **9** | [Safe display disconnection subsystem](#safe-display-disconnection-subsystem) | +| **10** | [Technical architecture](#technical-architecture) | +| **11** | [Detailed requirements](#detailed-requirements) | +| **12** | [Automation and integration contract](#automation-and-integration-contract) | +| **13** | [Data, configuration, and migration](#data-configuration-and-migration) | +| **14** | [Security, privacy, and permissions](#security-privacy-and-permissions) | +| **15** | [Quality, test strategy, and hardware matrix](#quality-test-strategy-and-hardware-matrix) | +| **16** | [Success metrics and release gates](#success-metrics-and-release-gates) | +| **17** | [Distribution and update strategy](#distribution-and-update-strategy) | +| **18** | [Open-source governance and licensing](#open-source-governance-and-licensing) | +| **19** | [Delivery roadmap](#delivery-roadmap) | +| **20** | [Risk register](#risk-register) | +| **21** | [Decision log and open questions](#decision-log-and-open-questions) | +| **22** | [Sources and research notes](#sources-and-research-notes) | +| **23** | [Glossary](#glossary) | + +| | **Navigation note** The contents links are clickable in Word-compatible readers. Requirement and feature tables use stable IDs for issue tracking and traceability. | +|-----|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +# 1. Executive summary + +OpenDisplay is an independently designed, open-source macOS display-management application for people who need predictable control over multiple displays. Its defining capability is safe display lifecycle management: the user can logically disconnect and reconnect supported displays without physically unplugging them, while retaining emergency recovery even when the display containing the app is removed from the active desktop. + +| | **Recommended product strategy** Ship a dependable Core before pursuing feature parity at the system-override layer. Core 1.0 should make multi-display identity, topology, scenes, DDC/software controls, automation, disconnect/reconnect, diagnostics, and recovery trustworthy. HiDPI overrides, virtual displays, EDID/system overrides, forced HDR/XDR behavior, and streaming belong in opt-in Labs. | +|-----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +## Product thesis + +macOS exposes useful public display APIs, but advanced users still experience fragile identities, inconsistent wake behavior, limited external-monitor controls, and no unified way to describe a desired multi-display state. Existing utilities often solve one slice—DDC, placement, virtual displays, or brightness—while a full platform must coordinate them as one stateful system. OpenDisplay will treat the desktop as a reconciled topology with explicit desired state, transactional changes, verified outcomes, and independent recovery. + +## Primary outcomes + +- **Control many displays.** One consistent registry, topology view, scene model, and automation surface for built-in, external, wireless, virtual, active, and remembered-offline endpoints. + +- **Disconnect without fear.** Logical disconnect is a guarded transaction with identity confidence, safe-surface preflight, checkpoint, verification, rollback, and Reconnect All. + +- **Automate predictably.** Stable selectors, idempotent scenes, dry-run planning, App Intents, CLI, and later authenticated local HTTP integration. + +- **Stay open and inspectable.** Source, architecture, recovery logic, schemas, SBOM, release provenance, and issue decisions are public. + +- **Degrade honestly.** Unsupported behavior is explained by OS, hardware, route, permission, build flavor, or safety policy; the app never reports false success. + +## Core 1.0 definition + +Core 1.0 is complete when a user can install a signed/notarized build, identify and organize multiple displays, save and apply scenes, control supported brightness/audio/input routes, logically disconnect and reconnect supported Apple Silicon displays with automatic recovery, automate common actions, export diagnostics, and recover through safe mode or a standalone rescue utility. No Labs feature is required for Core stability or startup. + +## Key product decisions + +| **Decision** | **Baseline** | +|----------------------|---------------------------------------------------------------------------------------------------------------------------------| +| Implementation | Swift 6, SwiftUI plus AppKit where needed; actor-isolated state coordinator; provider interfaces around OS/hardware mechanisms. | +| Distribution | Direct Developer ID signed and notarized releases; optional public-API-only build later. | +| License direction | Strong copyleft for the application/recovery stack; permissive license for a separately packaged SDK, subject to counsel. | +| Telemetry | None by default. Opt-in diagnostics and crash reporting only, with preview/redaction. | +| Disconnect semantics | Four explicit actions: Black Out, Monitor Sleep/Power, Logical Disconnect, Reconnect. | +| Safety model | No destructive lifecycle action bypasses preflight, transaction serialization, verification, or recovery. | +| Compatibility | Apple Silicon first; Intel best-effort and capability-gated; macOS 13 through current macOS 26 baseline. | +| Branding | No BetterDisplay name, iconography, copy, UI cloning, or proprietary implementation reuse. | + +## What this document does not assert + +- It does not assert that all publicly advertised reference features can be implemented using public APIs. + +- It does not promise that logical disconnect is equivalent to cable removal, releases a GPU display pipeline, or increases a Mac model's supported display count. + +- It does not treat public issue reports as prevalence data; they are design inputs and failure examples. + +- It does not approve a final project name, license, entitlement set, or use of any third-party code without legal and technical review. + +# 2. Product principles and clean-room boundary + +## 2.1 Product principles + +| **Principle** | **Application** | +|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| Safety before capability | A feature that can make the desktop unreachable is incomplete until recovery is independently usable. | +| Observed state is not desired state | The product must record what macOS/hardware currently reports, what the user wants, and which actor changed it. | +| Stable identity over transient IDs | A display ID is an observation, not an identity. Persistent behavior uses multi-signal fingerprints and user confirmation. | +| One coordinator owns topology | UI, rules, Shortcuts, CLI, HTTP, and recovery requests converge on the same planner, queue, safety checks, and audit log. | +| Verify, do not assume | A provider call is not success. Operations are verified through OS events, read-back, or an explicit unverified result. | +| Capability is contextual | Support depends on Mac, OS, display, cable, adapter, dock/KVM, route, permission, build flavor, and policy. | +| Open by default, risky by consent | Source and behavior are inspectable; experimental system changes are opt-in and clearly reversible. | +| No false equivalence | Black Out, monitor power, logical disconnect, and physical unplug are separate concepts throughout product copy and APIs. | + +## 2.2 Clean-room implementation policy + +The project may study public behavior, public documentation, user reports, and legally usable open-source implementations to understand the problem space. It shall not copy BetterDisplay's proprietary executable, assets, strings, screenshots, internal structure, trade dress, or non-public behavior obtained through prohibited means. Feature names that are generic descriptions may be used when necessary, but product information architecture and interface design must be independently created. + +- **Allowed inputs.** Public websites, public wiki pages, release notes, public issue reports, Apple's public documentation, observable OS behavior, and dependencies with compatible verified licenses. + +- **Disallowed inputs.** Decompiled proprietary implementation, extracted private assets, copied UI layouts or marketing copy, confidential information, or code with unknown/incompatible provenance. + +- **Contribution rule.** Every nontrivial contribution must be the contributor's original work or identify the upstream source and license. Maintainers may request provenance notes. + +- **Naming rule.** Use a distinct project name, bundle identifier, icon, website, terminology hierarchy, and visual identity. Include a non-affiliation statement where comparison is discussed. + +- **Compatibility language.** Describe functional outcomes and supported environments; do not imply drop-in identity or endorsement by the reference product. + +| | **Legal review gate** Before public launch, counsel should review trademark clearance, license choice, contributor terms, use of undocumented APIs, distribution representations, and any code inspired by public repositories whose license or provenance is unclear. | +|-----|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +## 2.3 Build-flavor boundary + +| **Flavor** | **Permitted implementation** | **Expected capability** | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| Core / full | Public APIs, compatible open-source libraries, hardware protocols, and narrowly isolated experimental providers approved by maintainers. | Full Core feature set including guarded logical disconnect where supported. | +| Public-API-only | Documented Apple APIs and hardware/network protocols only. | Topology, modes where public, DDC/software controls, scenes, capture, automation; no private lifecycle/virtual/system-override provider. | +| Labs | Opt-in modules for unstable or undocumented behavior with separate compatibility flags. | HiDPI/custom-mode overrides, virtual endpoints, EDID/system overrides, forced HDR/XDR, advanced redirection/streaming. | + +# 3. Problem, opportunity, and users + +## 3.1 Problem statement + +People with more than one display often manage a coupled system: display identity, placement, main-display assignment, mirroring, mode and refresh, brightness, audio, input source, profiles, sleep/wake, docking, and automation. macOS can change parts of this state after wake, reconnect, cable-route changes, or OS updates. Hardware protocols add another layer: the same monitor may expose DDC directly but not through a dock or KVM. A logical disconnect is particularly risky because it can remove the very screen needed to reverse the action. + +## 3.2 Opportunity + +An open-source product can make this domain inspectable and community-testable while consolidating capabilities that are currently spread across system settings and specialized tools. The differentiator is not the number of toggles. It is a trustworthy state and recovery model: stable identity, capability reasoning, transactional scene application, verifiable provider outcomes, and an emergency path that does not depend on the main UI. + +## 3.3 Jobs to be done + +- When I dock or undock, restore the intended arrangement, modes, controls, and active displays without flicker or manual cleanup. + +- When a display should not participate in the desktop, remove it logically and make recovery obvious even if I chose the wrong screen. + +- When I use identical monitors or change ports, keep my names, positions, and policies attached to the correct physical device. + +- When a monitor or dock cannot perform an action, tell me whether the limitation is the OS, display, route, permission, or safety policy. + +- When I automate my workspace, provide stable selectors, predictable errors, dry runs, and idempotent commands. + +- When an update or experiment fails, start safely, reconnect displays, and give me a diagnostic record of what happened. + +## 3.4 Personas + +| **Persona** | **Context** | **Primary needs** | +|-----------------------------|----------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| Multi-display professional | Uses 2-6 external displays, docks, and changing workspaces. | One-click scenes, predictable identity, safe disconnect, mode/layout protection. | +| Laptop clamshell user | Moves between desk, meeting room, and mobile use. | Auto-disconnect/reconnect built-in display, clear lid and power behavior. | +| Creative/HDR user | Needs accurate profiles, HDR/XDR control, and consistent brightness. | Profiles, nits-aware controls, guardrails against clipping or washout. | +| Developer/automator | Wants reproducible setup from scripts, Shortcuts, and CI-like checks. | Stable selectors, CLI/HTTP/App Intents, idempotent commands, machine-readable output. | +| Accessibility/eye-care user | Needs reduced brightness, color filters, and predictable keyboard control. | Software dimming, filter profiles, Night Shift support, no inaccessible recovery path. | +| Remote/headless operator | Runs a Mac without a permanently attached physical monitor. | Virtual display lifecycle, persistent scenes, remote-safe recovery, clear unsupported states. | +| IT/power user | Supports varied Mac models, monitors, docks, and KVMs. | Diagnostics bundle, capability explanations, reversible settings, documented compatibility. | + +## 3.5 Representative usage environments + +| **Environment** | **Typical topology** | **Critical concerns** | +|-----------------|------------------------------------------------------------------|-------------------------------------------------------------------| +| Laptop desk | Built-in + 1-3 external via dock/KVM | Built-in auto-disconnect, DDC route changes, wake reconciliation. | +| Studio | 2-6 direct or docked displays, HDR/reference display | Profiles, HDR guardrails, consistent brightness, mode protection. | +| Presentation | Built-in + projector/TV + capture/teleprompter | Fast scenes, mirroring, input switch, privacy, recovery. | +| Remote/headless | No permanent physical display; optional virtual/headless adapter | Safe startup, remote-resilient modes, no black-screen loop. | +| Hot desk | Frequent unknown monitors and docks | Capability scan, no destructive default policy, portable scenes. | +| Lab/IT support | Many Macs, OS versions, adapters, and identical panels | Diagnostics, deterministic test fixtures, compatibility database. | + +# 4. Goals, success definition, and non-goals + +## 4.1 Goals + +1\. Deliver the safest practical logical display disconnect/reconnect experience on supported Macs, with independent recovery. + +2\. Manage at least eight active or remembered displays without identity, ordering, or automation ambiguity. + +3\. Unify topology, modes, controls, profiles, scenes, rules, and diagnostics in one consistent state model. + +4\. Expose stable, documented automation through CLI and App Intents in Core 1.0; add authenticated local HTTP/event integrations in Core 1.x. + +5\. Make unsupported states and experimental mechanisms transparent, capability-gated, and testable. + +6\. Publish source, schemas, architecture decisions, security policy, release provenance, and contributor governance. + +7\. Maintain a useful public-API-only build path even when the full build contains isolated experimental providers. + +## 4.2 Success definition + +The product succeeds when users can move among common multi-display workspaces without repeatedly opening System Settings, and when a failed display action produces a recoverable, explainable state rather than a black-screen incident. For Core 1.0, safety and predictability outweigh breadth: a smaller verified capability set is preferred to a broad set of unverified toggles. + +## 4.3 Non-goals + +- Replicating BetterDisplay's source code, brand, visual design, exact information architecture, licensing model, or every feature at launch. + +- Circumventing Mac hardware limits, Digital Rights Management, HDCP, enterprise controls, or security protections. + +- Guaranteeing DDC through every dock, KVM, adapter, cable, or monitor firmware. + +- Claiming logical disconnect is a physical cable disconnect or that it always frees GPU/display-controller resources. + +- Providing medical treatment or health claims through PWM, dithering, color, or brightness features. + +- Supporting arbitrary remote internet control by default; network interfaces remain local and opt-in. + +- Making Labs features prerequisites for normal startup, recovery, scene storage, or basic display controls. + +- Supporting pre-macOS 13 in the initial maintained release line. + +## 4.4 Prioritization rules + +| **Priority** | **Meaning** | **Decision rule** | +|--------------|------------------------------------------------------|--------------------------------------------------------------------| +| P0 / Must | Required for Core release or safety. | No release with an unmet P0 unless scope is explicitly removed. | +| P1 / Should | High-value, committed target. | May defer only with documented impact and compatibility path. | +| P2 / Could | Optional enhancement. | Schedule after Core reliability and maintenance capacity. | +| Labs | Experimental, system-sensitive, or evidence-limited. | Opt-in, kill-switchable, and never part of Core safety dependency. | + +# 5. Scope and release model + +## 5.1 Compatibility target + +| **Dimension** | **Target** | **Product implication** | +|-------------------|----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| Operating systems | macOS 13 Ventura through current macOS 26 Tahoe | Core build; behavior gated by runtime capability tests. Reassess minimum after telemetry. | +| Architectures | Apple Silicon first; Intel best-effort | Logical disconnect and some lifecycle features may be unavailable or experimental on Intel. | +| Display count | At least 8 active/remembered displays; design for 16 | Includes built-in, external, virtual, Sidecar, AirPlay, and offline remembered devices. | +| Connections | USB-C/Thunderbolt, HDMI, DisplayPort, docks, KVMs, network-controlled displays | Per-route capability matrix; no assumption that DDC passes through. | +| Display classes | Built-in, external, HDR/XDR, TVs, projectors, headless dongles, virtual displays | Features exposed only when safe and supported. | +| Distribution | Direct signed/notarized package; optional public-API-only flavor | App Store distribution is not the baseline for experimental lifecycle features. | + +## 5.2 Release rings + +| **Ring** | **Audience** | **Behavior** | +|-------------------|------------------------------|--------------------------------------------------------------------------------------------| +| Canary | Maintainers and hardware lab | Experimental providers enabled only by explicit developer flags; full diagnostics. | +| Preview | Technical contributors | Core defaults; Labs opt-in; rapid compatibility flags and rollback. | +| Beta | Broader volunteers | Signed/notarized; migration supported; opt-in telemetry; known-issue list. | +| Stable | General users | Only certified OS/hardware combinations auto-enable lifecycle providers. | +| LTS consideration | Organizations/power users | Security and compatibility fixes for selected stable branch if maintainer capacity allows. | + +## 5.3 Core 1.0 scope + +- Display registry, persistent identity, aliases/tags, topology model, capability explanations, and detailed display inspector. + +- Safe logical disconnect/reconnect on certified Apple Silicon configurations, Black Out, monitor sleep/power where supported, Reconnect All, safe mode, and rescue utility. + +- Layout, main display, mirroring, resolution/refresh/rotation, favorites, property protection, and scenes with preview and rollback. + +- Native/DDC/software brightness, volume/mute/contrast/input where supported, keyboard routing, OSD, groups, sync, and rate limiting. + +- Menu-bar UI, full settings window, accessibility baseline, CLI, App Intents/Shortcuts, export/import, logs, and diagnostics bundle. + +- Direct signed/notarized open-source distribution, SBOM, contributor docs, security policy, and reproducible release metadata. + +## 5.4 Deferred to Core 1.x + +- Authenticated local HTTP API, event subscriptions, URL scheme, advanced rules, and plugin SDK. + +- Nits-aware sync, richer color controls, SDR/HDR profile rules, network display/receiver providers. + +- ScreenCaptureKit picture-in-picture, zoom, screenshots, and teleprompter rendering. + +- UI-scale matching, window placement policies, and richer layout adaptation. + +## 5.5 Labs scope + +- Custom/flexible HiDPI and custom mode/system parameter overrides. + +- Virtual displays, arbitrary headless resolutions, virtual HDR/refresh, and persistence. + +- EDID/configuration overrides, encoding/range/chroma manipulation, forced HDR/XDR upscaling. + +- Local streaming, display redirection, rotated Sidecar workarounds, and PWM/dithering mitigation experiments. + +| | **Scope guardrail** A Labs feature may graduate only after it has a provider contract, compatibility matrix, safe-mode bypass, diagnostics, automated fault tests, user documentation, and no open P0/P1 recovery defect. | +|-----|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +# 6. Research synthesis + +## 6.1 Method + +Research reviewed the reference product's public website, public GitHub materials, feature matrix, integration documentation, specialist wiki pages, current release information, representative public issue reports, Apple's display/capture/distribution guidance, and adjacent open-source display utilities. The purpose was to map user-visible outcomes and failure modes, not to infer or reproduce proprietary internals. Sources are listed in Section 22. + +## 6.2 Findings that shape the product + +| **Finding** | **Implication** | **Evidence** | +|--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------| +| Feature breadth | The reference product is a display-management platform, not merely a brightness utility. Public materials span topology, modes, DDC, software image controls, HDR/XDR, virtual displays, streaming, automation, layout protection, diagnostics, and recovery. | \[S01-S09\] | +| Disconnect semantics | Users use the word disconnect for several different outcomes: remove a display from the macOS topology, turn the monitor panel off, black out the image while retaining topology, or emulate physical unplug. The product must name these separately. | \[S01, S03, S19-S22\] | +| Identity is unstable | Transient display IDs may change across reconnection, wake, ports, docks, or identical monitor swaps. Automation needs a multi-signal identity model and confidence scoring, not a single numeric ID. | \[S04, S12, S20, S24\] | +| Wake is a reconciliation event | macOS and hardware may independently reconnect, reorder, or alter modes after sleep. The app should wait for topology to stabilize, reconcile observed state, and then apply policy once rather than repeatedly fighting the system. | \[S20-S26, S29-S30\] | +| DDC is transport-dependent | A monitor can support DDC while a dock, adapter, KVM, or cable blocks it. Capability detection must be per route, degradable, and explain failures without treating the whole display as unsupported. | \[S03, S10-S11, S23, S27\] | +| Recovery is a product feature | A display tool can remove the surface that contains its own recovery UI. Safe mode, reconnect-all, rollback checkpoints, startup bypass, keyboard recovery, and a standalone rescue utility are first-class requirements. | \[S07, S19-S22, S31\] | +| Distribution affects scope | Public Core Graphics and ScreenCaptureKit cover many features, but some lifecycle, virtual-display, and system-override behavior may require undocumented interfaces. A direct, signed, notarized build and a public-API-only build should be planned separately. | \[S14-S18\] | +| Clean-room is mandatory | Open source does not permit copying proprietary code, assets, text, brand identity, or distinctive UI. The project should reproduce user outcomes through independently authored designs and documented public observations. | \[S01-S04, S16\] | + +## 6.3 Representative reports and design response + +| **Observed report** | **Product response** | **Source** | +|---------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|-------------| +| Logical disconnect may target the last or primary surface. | Treat disconnect as recovery-critical; block unsafe defaults and require safe-surface verification. | \[S19\] | +| macOS may reconnect or reorder displays after sleep. | Use a debounced wake reconciliation generation and protected desired state rather than immediate repeated writes. | \[S20\] | +| Intel configurations can present blank screens after disconnect/wake. | Apple Silicon is the certified baseline; Intel lifecycle provider remains unavailable or experimental until separately proven. | \[S21\] | +| Aggressive disconnect may not survive reboot as users expect. | Define persistence as an explicit policy with health delay and bypass; never imply OS-level permanence. | \[S22\] | +| DDC may work directly but fail through a hub. | Probe and cache capabilities per route; report transport failure separately from monitor support. | \[S23\] | +| Modes can differ after reconnect. | Resolve modes by properties, refresh capabilities after topology events, and reject stale identifiers. | \[S24\] | +| Wake can trigger crashes or repeated instability. | Serialize operations, bound retries, maintain a circuit breaker, and preserve a pre-wake checkpoint. | \[S25\] | +| Reconnect All may not wake Sidecar. | Use endpoint-specific semantics and per-target result reporting; never return blanket success. | \[S26\] | +| Power controls buried in UI reduce utility. | Expose safe quick actions in root menu, hotkeys, and automation while retaining clear semantics. | \[S27\] | +| Rapid XDR/brightness changes can produce visual corruption. | Rate-limit/coalesce writes, enforce safe ranges, verify where possible, and provide profile rollback. | \[S28\] | +| Virtual display sleep can move windows or fail to reconnect. | Virtual lifecycle requires explicit window/sleep policy, separate persistence, and safe-mode bypass. | \[S29-S30\] | +| Severe startup/WindowServer incidents are possible in this problem class. | Independent rescue utility, health marker, startup bypass, and conservative OS compatibility flags are release requirements. | \[S31\] | + +## 6.4 Interpretation limits + +- Public issue reports demonstrate possible failure modes; they do not establish frequency, root cause, or current unresolved status. + +- Feature descriptions establish user-visible intent but not the implementation method, entitlement set, or reliability guarantees. + +- Apple documentation describes supported public interfaces; absence of a public API does not prove impossibility, but it changes distribution and maintenance risk. + +- Compatibility must be established by our own instrumented hardware testing for each Mac/OS/route/provider combination. + +# 7. Reference feature inventory and proposed disposition + +The following inventory translates publicly described reference capabilities into independently specified product outcomes. It is a planning map, not a promise of identical implementation or behavior. “Required” means part of the stated release scope; “Capability-gated” means exposed only when the current environment can support and verify it; “Labs” means opt-in and system-sensitive. + +| **Capability domain** | **Items** | **Release distribution** | +|---------------------------------------------|-----------|--------------------------------------------------------------| +| Display lifecycle and topology | 16 | Core 1.0: 14, Core 1.x: 1, Labs: 1 | +| Modes, scaling, and geometry | 16 | Core 1.0: 7, Core 1.x: 4, Labs: 5 | +| Brightness, audio, color, and input | 23 | Core 1.0: 12, Core 1.x: 7, Core read; Labs write: 1, Labs: 3 | +| Virtual displays, capture, and presentation | 11 | Labs: 6, Core 1.x: 5 | +| Automation and integrations | 12 | Core 1.0: 7, Core 1.x: 5 | +| Diagnostics, configuration, and recovery | 12 | Core 1.0: 11, Labs: 1 | +| User experience and accessibility | 10 | Core 1.0: 10 | +| Open-source platform and distribution | 8 | Core 1.0: 6, Core 1.x: 2 | + +| | **Traceability convention** Feature IDs describe product capabilities. Detailed requirement IDs in Section 11 define testable behavior. Source markers such as \[S01\] refer to the source register in Section 22. | +|-----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +## Display lifecycle and topology + +Feature inventory: Display lifecycle and topology + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|-----------------------------------|-------------------------------------------------------------------------------------------------------|------------|----------------------------|----------------------------| +| LIF-01 | Enumerate all display endpoints | Show built-in, external, virtual, Sidecar/AirPlay, mirrored, disconnected/remembered endpoints. | Core 1.0 | Required | \[S01-S04\] | +| LIF-02 | Stable human-readable naming | Custom names/tags and consistent menu ordering. | Core 1.0 | Required | \[S03-S04\] | +| LIF-03 | Logical disconnect | Remove a supported display from the active macOS display topology without unplugging it. | Core 1.0 | Experimental provider | \[S01-S04, S19-S22\] | +| LIF-04 | Logical reconnect | Return a managed-offline display to the active topology. | Core 1.0 | Experimental provider | \[S01-S04, S19-S22\] | +| LIF-05 | Reconnect All | One action to reconnect every display placed offline by the app. | Core 1.0 | Safety-critical | \[S07, S19-S22, S26\] | +| LIF-06 | Black Out | Render black while the display remains active in layout; optional cursor suppression. | Core 1.0 | Public/low risk | \[S03\] | +| LIF-07 | Monitor sleep/power | Send DDC or network power/sleep command while topology may remain active. | Core 1.0 | Hardware-dependent | \[S03, S10-S11, S23, S27\] | +| LIF-08 | Built-in display automation | Disconnect or reconnect the MacBook panel based on external display, lid, power, or scene conditions. | Core 1.0 | Guarded rules | \[S01-S03\] | +| LIF-09 | Persistent managed-offline policy | Reapply an opt-in disconnect policy after login/reboot once health checks pass. | Core 1.x | Opt-in only | \[S22\] | +| LIF-10 | Main display selection | Assign main display and protect it from system reordering. | Core 1.0 | Required | \[S01-S04, S20\] | +| LIF-11 | Mirroring topology | Create, break, and inspect mirror sets; choose source and targets. | Core 1.0 | Public APIs where possible | \[S01-S04\] | +| LIF-12 | Display groups | Group displays for synchronized controls and scene application. | Core 1.0 | Required | \[S01-S03\] | +| LIF-13 | Layout and anchors | Set relative coordinates, align edges, preserve gaps, and anchor important displays. | Core 1.0 | Required | \[S01-S03, S12\] | +| LIF-14 | Layout protection | Observe topology drift and restore protected placement/main/mirror properties. | Core 1.0 | Debounced | \[S01-S03, S20\] | +| LIF-15 | Display redirection | Present one display's contents on another endpoint. | Labs | Research | \[S01-S03\] | +| LIF-16 | Physical-unplug semantics | Detect cable removal and explain that software cannot generally sever the physical link. | Core 1.0 | Explicit non-goal | Product requirement | + +## Modes, scaling, and geometry + +Feature inventory: Modes, scaling, and geometry + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|--------------------------------|-------------------------------------------------------------------------------------|------------|-------------------------------|---------------------| +| MOD-01 | Resolution selection | List and apply available resolutions with logical and pixel dimensions. | Core 1.0 | Required | \[S01-S05, S12\] | +| MOD-02 | Favorite modes | Pin resolutions/refresh combinations for menu and keyboard access. | Core 1.0 | Required | \[S01-S04\] | +| MOD-03 | Resolution slider | Continuous-feeling UI over discrete supported modes. | Core 1.x | Convenience | \[S01-S03\] | +| MOD-04 | HiDPI mode visibility | Expose HiDPI/non-HiDPI status and filter mode lists. | Core 1.0 | Required | \[S03-S05\] | +| MOD-05 | Flexible HiDPI scaling | Offer additional scaled desktop sizes on compatible systems. | Labs | Undocumented/system-sensitive | \[S01-S05\] | +| MOD-06 | Custom resolutions | Create or expose custom mode entries where technically possible. | Labs | High risk | \[S01-S05\] | +| MOD-07 | Arbitrary headless resolutions | Provide custom dimensions for headless/virtual workflows. | Labs | Depends on virtual provider | \[S01-S05\] | +| MOD-08 | Refresh rate | Select fixed refresh rates and report current/maximum rate. | Core 1.0 | Required | \[S01-S04\] | +| MOD-09 | Variable refresh rate | Expose VRR status and supported ranges; allow protected selection where available. | Core 1.x | Capability-gated | \[S01-S04\] | +| MOD-10 | Bit depth and pixel format | Report/apply depth and encoding choices where supported. | Core 1.x | Capability-gated | \[S01-S04\] | +| MOD-11 | Rotation | Apply 0/90/180/270-degree rotation to supported displays. | Core 1.0 | Required | \[S01-S04, S12\] | +| MOD-12 | Rotated Sidecar | Allow or emulate rotation for Sidecar-oriented workflows. | Labs | Research | \[S01-S03\] | +| MOD-13 | UI-scale matching | Calculate matching apparent UI size across displays with different density. | Core 1.x | Differentiator | \[S01-S03\] | +| MOD-14 | Geometry presets | Support TV lower-half, off-center, overscan-safe, and custom viewport arrangements. | Labs | Niche/system-sensitive | \[S01-S03\] | +| MOD-15 | Mode protection | Restore protected resolution, refresh, rotation, HDR, and profile after drift. | Core 1.0 | Required | \[S01-S03\] | +| MOD-16 | Mode diff preview | Show current versus proposed geometry before applying a scene. | Core 1.0 | OpenDisplay enhancement | Product requirement | + +## Brightness, audio, color, and input + +Feature inventory: Brightness, audio, color, and input + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|------------------------------|-------------------------------------------------------------------------------------------------|-----------------------|--------------------------------|---------------------------| +| CTL-01 | Native brightness | Control Apple/native display brightness through supported system interfaces. | Core 1.0 | Required | \[S01-S03, S10\] | +| CTL-02 | DDC brightness | Control external monitor backlight over DDC/CI. | Core 1.0 | Hardware/route-dependent | \[S01-S03, S10-S11, S23\] | +| CTL-03 | Software dimming | Apply a software overlay/gamma/Metal dimmer below hardware minimum. | Core 1.0 | Required | \[S01-S03, S10\] | +| CTL-04 | Combined brightness curve | Seamlessly combine hardware and software ranges with calibrated transitions. | Core 1.x | Quality feature | \[S01-S03, S10\] | +| CTL-05 | Volume and mute | Control display audio volume/mute through native or DDC routes. | Core 1.0 | Capability-gated | \[S01-S03, S10\] | +| CTL-06 | Contrast | Read/write DDC contrast when supported. | Core 1.0 | Capability-gated | \[S01-S03, S10-S11\] | +| CTL-07 | Color channels and presets | Control RGB gain, color temperature, picture modes, or vendor presets when available. | Core 1.x | Capability-gated | \[S01-S03\] | +| CTL-08 | Keyboard media keys | Route brightness and volume keys to the display under pointer, focus, main display, or a group. | Core 1.0 | Required | \[S01-S04, S10\] | +| CTL-09 | Custom on-screen display | Show native-looking feedback for brightness, volume, input, and scene changes. | Core 1.0 | Required | \[S01-S03, S10\] | +| CTL-10 | Control synchronization | Synchronize brightness/volume/color across a group with per-display offsets. | Core 1.0 | Required | \[S01-S03, S10\] | +| CTL-11 | Nits-aware synchronization | Map controls by measured/declared luminance rather than percentage. | Core 1.x | Advanced | \[S01-S03\] | +| CTL-12 | Input source switching | Read/write DDC input source and expose named inputs. | Core 1.0 | Capability-gated | \[S01-S04, S10-S11\] | +| CTL-13 | DDC auto-configuration | Probe VCP support, delays, verification, and route stability. | Core 1.0 | Required | \[S01-S03, S23\] | +| CTL-14 | Network display control | Adapters for supported LG/Samsung/Philips displays and receivers. | Core 1.x | Plugin/provider | \[S01-S03\] | +| CTL-15 | Night Shift on televisions | Extend or coordinate Night Shift-like behavior on displays not handled by macOS. | Core 1.x | Best effort | \[S01-S03\] | +| CTL-16 | Color profile selection | List, apply, and protect ICC/display profiles. | Core 1.0 | Required | \[S01-S03\] | +| CTL-17 | SDR/HDR profile automation | Switch profiles based on dynamic range state or content workflow. | Core 1.x | Advanced | \[S01-S03, S06\] | +| CTL-18 | HDR toggle/force | Expose HDR state and, in Labs, attempt forced HDR modes where feasible. | Core read; Labs write | Risk-gated | \[S01-S03, S06\] | +| CTL-19 | XDR/HDR brightness expansion | Provide guarded extra-brightness workflows on compatible displays. | Labs | Thermal/visual safety | \[S01-S03, S06, S28\] | +| CTL-20 | Encoding/range/chroma | Inspect and, where possible, influence RGB/YCbCr, full/limited range, and chroma. | Labs | System-sensitive | \[S01-S04\] | +| CTL-21 | Image filters | Per-display dimming, grayscale, inversion, tint, white balance, and accessibility filters. | Core 1.x | Metal/overlay pipeline | \[S01-S03, S09\] | +| CTL-22 | PWM/dithering mitigation | Provide carefully worded eye-care modes and diagnostics without medical claims. | Labs | Evidence-limited | \[S01-S03, S09\] | +| CTL-23 | Control rate limiting | Coalesce rapid writes and rollback unsafe color/HDR transitions. | Core 1.0 | OpenDisplay safety enhancement | \[S28\] | + +## Virtual displays, capture, and presentation + +Feature inventory: Virtual displays, capture, and presentation + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|-------------------------------|------------------------------------------------------------------------------|------------|-------------------------------|----------------------| +| VIR-01 | Virtual display creation | Create software display endpoints with configurable size and density. | Labs | Undocumented/system-sensitive | \[S01-S04, S29-S30\] | +| VIR-02 | Multiple virtual displays | Create and manage more than one virtual endpoint subject to system limits. | Labs | Capability-gated | \[S01-S03\] | +| VIR-03 | Virtual refresh and HDR | Configure refresh rate, color depth, and HDR flags where supported. | Labs | Research | \[S01-S03\] | +| VIR-04 | Virtual lifecycle persistence | Reconnect named virtual displays after login/wake using safe policies. | Labs | Recovery required | \[S29-S30\] | +| VIR-05 | Picture in picture | Preview any display in a resizable always-on-top window. | Core 1.x | ScreenCaptureKit | \[S01-S03, S15\] | +| VIR-06 | Display zoom | Zoom/pan a selected display or region for accessibility and inspection. | Core 1.x | ScreenCaptureKit | \[S01-S03, S15\] | +| VIR-07 | Screenshots | Capture full display or selected region with privacy-aware exclusions. | Core 1.x | ScreenCaptureKit | \[S01-S03, S15\] | +| VIR-08 | Local streaming | Stream a display to another local endpoint or browser with explicit consent. | Labs | Security-sensitive | \[S01-S03, S15\] | +| VIR-09 | Headless workspace | Maintain usable remote resolutions when no physical monitor is connected. | Labs | Virtual provider | \[S01-S05\] | +| VIR-10 | Teleprompter/mirror mode | Mirror, flip, or present text/video for teleprompter workflows. | Core 1.x | Capture/render pipeline | \[S01-S03\] | +| VIR-11 | Cursor and window policy | Define whether windows/cursor move when virtual displays sleep or reconnect. | Core 1.x | Required before virtual GA | \[S29-S30\] | + +## Automation and integrations + +Feature inventory: Automation and integrations + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|---------------------------|----------------------------------------------------------------------------------------------------|------------|-------------------------|---------------------| +| AUT-01 | Command-line interface | Script all supported get/set/toggle/scene/lifecycle actions. | Core 1.0 | Required | \[S04, S12\] | +| AUT-02 | Stable display selectors | Address by tag, UUID, fingerprint, name, vendor/product/serial, topology, pointer, focus, or main. | Core 1.0 | Required | \[S04\] | +| AUT-03 | Machine-readable output | JSON output with stable schema, exit codes, warnings, and capability reasons. | Core 1.0 | OpenDisplay enhancement | Product requirement | +| AUT-04 | URL scheme | Invoke safe actions from launchers and automations. | Core 1.x | Opt-in | \[S04\] | +| AUT-05 | Local HTTP API | Loopback server for authenticated control and event subscription. | Core 1.x | Opt-in/security-gated | \[S04\] | +| AUT-06 | Distributed notifications | Publish and consume local process events where appropriate. | Core 1.x | Compatibility | \[S04\] | +| AUT-07 | App Intents and Shortcuts | Expose scenes and common controls to Shortcuts, Spotlight, and Siri surfaces. | Core 1.0 | Required | \[S01-S04\] | +| AUT-08 | Global shortcuts | Bind display, group, scene, brightness, input, and emergency recovery actions. | Core 1.0 | Required | \[S01-S04\] | +| AUT-09 | Events and rules | Trigger actions on connect/disconnect, wake, lid, power source, focus, time, and app launch. | Core 1.x | Rule engine | \[S01-S04\] | +| AUT-10 | Idempotent scene apply | Repeatedly applying the same desired state should not flicker or reorder unnecessarily. | Core 1.0 | Required | Product requirement | +| AUT-11 | Dry run and diff | Return planned operations, risks, and unsupported fields without applying. | Core 1.0 | Safety enhancement | Product requirement | +| AUT-12 | Shell and webhook hooks | Run user-approved local commands or webhooks around scene transitions. | Core 1.x | Sandboxed/explicit | \[S01-S04\] | + +## Diagnostics, configuration, and recovery + +Feature inventory: Diagnostics, configuration, and recovery + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|----------------------------------|----------------------------------------------------------------------------------------------|------------|-----------------|---------------------| +| DIA-01 | Detailed display inspector | Show IDs, fingerprints, connection route, mode, color, HDR, DDC, and topology state. | Core 1.0 | Required | \[S01-S04\] | +| DIA-02 | EDID viewer/export | Parse and export EDID where available; flag inconsistent or missing values. | Core 1.0 | Required | \[S01-S04\] | +| DIA-03 | Configuration and EDID overrides | Manage advanced overrides with backup, validation, and reboot warnings. | Labs | High risk | \[S01-S03\] | +| DIA-04 | Capability explanation | For every disabled control, explain OS, hardware, route, permission, or policy reason. | Core 1.0 | Required | Product requirement | +| DIA-05 | Configuration export/import | Portable, versioned settings with secrets excluded by default. | Core 1.0 | Required | \[S08\] | +| DIA-06 | Safe mode | Launch with all experimental providers and auto-apply policies disabled. | Core 1.0 | Safety-critical | \[S07\] | +| DIA-07 | Reset and selective reset | Reset rules, scenes, display identities, DDC cache, or all settings. | Core 1.0 | Required | \[S07\] | +| DIA-08 | Last-known-safe checkpoint | Persist topology, modes, and policies before risky operations. | Core 1.0 | Safety-critical | \[S19-S22, S31\] | +| DIA-09 | Automatic rollback | Restore checkpoint when verification or watchdog fails. | Core 1.0 | Safety-critical | \[S19-S22, S31\] | +| DIA-10 | Standalone rescue utility | Independent small app/CLI to reconnect displays and disable startup policies. | Core 1.0 | Safety-critical | Product requirement | +| DIA-11 | Diagnostics bundle | Redacted logs, topology timeline, capability probes, crash state, and config schema version. | Core 1.0 | Required | \[S23-S26\] | +| DIA-12 | Health and circuit breaker | Disable a failing provider after bounded failures and surface recovery guidance. | Core 1.0 | Required | Product requirement | + +## User experience and accessibility + +Feature inventory: User experience and accessibility + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|------------------------------|-----------------------------------------------------------------------------------------------------|------------|-----------------|---------------------| +| UX-01 | Menu-bar first UI | Fast access to displays, favorites, scenes, and emergency recovery. | Core 1.0 | Required | \[S01-S03, S27\] | +| UX-02 | Full settings window | Topology map, display details, controls, scenes, automation, and diagnostics. | Core 1.0 | Required | Product requirement | +| UX-03 | Per-display cards | Consistent cards with identity, connection state, mode, brightness, audio, and quick actions. | Core 1.0 | Required | Product requirement | +| UX-04 | Reorder displays in menus | User-defined menu order independent of transient system order. | Core 1.0 | Required | \[S01-S03\] | +| UX-05 | Favorites and recent actions | Pin modes, inputs, scenes, and controls; show undoable recent actions. | Core 1.0 | Required | \[S01-S03\] | +| UX-06 | Risk labels | Mark public, hardware-dependent, experimental, and restart-required actions. | Core 1.0 | Required | Product requirement | +| UX-07 | Accessibility | VoiceOver labels, keyboard navigation, sufficient contrast, reduced motion, and nonvisual recovery. | Core 1.0 | Required | Product requirement | +| UX-08 | Localization-ready copy | String catalog, pluralization, and no layout assumptions based on English length. | Core 1.0 | Required | Product requirement | +| UX-09 | Onboarding capability scan | Explain permissions, DDC routes, experimental features, and recovery before first use. | Core 1.0 | Required | Product requirement | +| UX-10 | Undo and activity log | Undo recent reversible changes and inspect what changed, why, and by which trigger. | Core 1.0 | Required | Product requirement | + +## Open-source platform and distribution + +Feature inventory: Open-source platform and distribution + +| **ID** | **Capability** | **Independently specified outcome** | **Target** | **Disposition** | **Evidence** | +|--------|-------------------------------|------------------------------------------------------------------------------------------|------------|-----------------|---------------------| +| OSS-01 | Clean-room implementation | Independently authored code, copy, UI, icons, and architecture. | Core 1.0 | Mandatory | \[S01-S04, S16\] | +| OSS-02 | Provider architecture | Separate public, hardware, and experimental implementations behind capability contracts. | Core 1.0 | Mandatory | Product requirement | +| OSS-03 | Public-API-only build | Compile/package a reduced feature build without undocumented interfaces. | Core 1.x | Strategic | \[S14-S18\] | +| OSS-04 | Signed and notarized releases | Reproducible release process with Developer ID signing and notarization. | Core 1.0 | Mandatory | \[S17-S18\] | +| OSS-05 | Software bill of materials | Publish dependencies, licenses, checksums, provenance, and security policy. | Core 1.0 | Mandatory | Product requirement | +| OSS-06 | Plugin SDK | Document provider interfaces for DDC, network control, and future hardware adapters. | Core 1.x | Extension point | Product requirement | +| OSS-07 | Contributor governance | DCO/CLA decision, code of conduct, issue templates, RFCs, and maintainer policy. | Core 1.0 | Mandatory | Product requirement | +| OSS-08 | Privacy-first operation | No analytics by default; opt-in diagnostics; local control endpoints only by default. | Core 1.0 | Mandatory | Product requirement | + +# 8. Product experience and core workflows + +## 8.1 Information architecture + +| **Surface** | **Purpose** | **Required contents** | +|--------------------|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| Menu-bar root | Immediate control and recovery | Reconnect All; current scene; display cards; favorite brightness/modes/inputs; Black Out; logical disconnect; health badge. | +| Topology workspace | Visual multi-display management | Active/offline endpoints, arrangement, main display, mirrors, identity confidence, scene preview, protected properties. | +| Display detail | Per-endpoint configuration | Identity, route, mode, controls, profiles, lifecycle policy, automation tags, capability reasons, diagnostics. | +| Scenes | Desired-state authoring | Required/optional members, topology, modes, controls, lifecycle, triggers, dry run, history, export. | +| Automation | External and event control | Shortcuts/App Intents, CLI examples, hotkeys, rules, API status, tokens, audit log. | +| Health & recovery | Prevent and repair unsafe states | Managed-offline list, pending transaction, provider health, Reconnect All, safe mode, restore checkpoint, support bundle. | +| Labs | Explicit experimental opt-in | Compatibility warnings, provider flags, recovery acknowledgement, kill switches, diagnostics. | + +## 8.2 Display card anatomy + +- **Identity.** Alias, display class, model, fingerprint confidence, route, and current reachability. + +- **State.** Active, Blacked Out, monitor power unknown/asleep, managed offline, system absent, reconnecting, degraded, or error. + +- **Mode.** Logical/pixel size, HiDPI, refresh/VRR, rotation, HDR, profile, and main/mirror role. + +- **Controls.** Brightness provider, volume/mute, contrast, input, synchronized-group membership, and verification state. + +- **Quick actions.** Favorite mode, input, scene, Black Out, monitor sleep, logical disconnect/reconnect, details. + +- **Safety.** Risk badge, capability reason, last checkpoint, last action actor, and direct recovery action. + +## 8.3 Onboarding flow + +| **Step** | **User experience** | **System behavior / acceptance** | +|-----------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| 1\. Welcome | Explain that Core is open source and that some advanced features are experimental. | No permission prompt or topology mutation. | +| 2\. Scan | Show discovered displays and connection routes. | Registry and capability resolver run; slow DDC probes are asynchronous. | +| 3\. Name | Offer aliases/tags, especially for identical displays. | Identity evidence and confidence are visible. | +| 4\. Permissions | Request only permissions for selected features. | Core topology/DDC remains usable without capture permission. | +| 5\. Recovery | Teach Reconnect All hotkey and rescue utility before enabling logical disconnect. | User confirms they can invoke keyboard recovery. | +| 6\. Test | Optional first-disconnect test with countdown and automatic reconnect. | Creates checkpoint, verifies transition, and records route-specific consent. | +| 7\. Scene | Offer a starter scene from current state. | Scene is a desired-state snapshot with optional controls. | + +## 8.4 Primary workflow: disconnect one display + +1\. User opens the display card and chooses Logical Disconnect. The action is visually distinct from Black Out and Monitor Sleep. + +2\. The planner resolves the target identity, shows confidence and topology impact, and checks for a safe visible/recoverable surface. + +3\. For first use or elevated risk, the app shows a countdown confirmation with the Reconnect All hotkey and a Cancel button on a safe display. + +4\. The coordinator writes a last-known-safe checkpoint and marks the transaction in progress. + +5\. The lifecycle provider performs the platform-specific request; the registry observes resulting display events. + +6\. The verifier confirms the target is inactive, at least one safe surface remains, and topology has stabilized. + +7\. On success, the target becomes Managed Offline with actor, timestamp, policy, and Reconnect action. On failure, rollback begins automatically. + +## 8.5 Primary workflow: scene transition + +| **Phase** | **Planner behavior** | **User feedback** | +|------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------| +| Resolve | Resolve required/optional displays and capabilities using stable identity. | Missing/ambiguous targets shown before mutation. | +| Diff | Compare observed state with desired fields; omit satisfied operations. | Preview groups normal, hardware-dependent, experimental, and unsupported steps. | +| Checkpoint | Persist topology-critical state and transaction plan. | Activity item shows pending scene and Cancel when safe. | +| Establish safety | Connect destination displays and confirm a safe surface. | Status identifies which display is being prepared. | +| Apply topology | Main/mirror/layout/modes through ordered transactions. | Minimal OSD; no unnecessary intermediate states. | +| Apply controls | Brightness/audio/input/profile with rate limits and optional verification. | Per-field warnings do not masquerade as full success. | +| Retire displays | Disconnect only after the destination is verified. | Countdown used when policy/risk requires. | +| Commit | Record verified state and transaction result. | Scene shows Applied, Applied with warnings, or Rolled back. | + +## 8.6 Wake and dock reconciliation + +Wake is treated as a new topology generation. The app records events but does not immediately fight each one. After a quiet/stability window, it refreshes capabilities, reconciles identities, compares observed state with applicable protected state and rules, produces one plan, and applies it through the transaction coordinator. Repeated OS events extend the stabilization window up to a bound; repeated failures open a circuit breaker and stop writes. + +## 8.7 Failure experience + +| **Failure** | **Immediate response** | **Recovery surface** | +|----------------------------------|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| Target did not disconnect | Report provider timeout/failure; no false success. | Retry, change provider policy, diagnostics. | +| Safe display disappeared | Abort remaining steps and run rollback/reconnect. | Full-screen recovery banner/OSD on any available surface; hotkey/rescue. | +| Identity became ambiguous | Pause before mutation. | Candidate selection with evidence; remember explicit pairing. | +| DDC route stopped responding | Stop repeated writes; mark route degraded. | Use software fallback where valid; show cable/dock guidance. | +| Mode unavailable after reconnect | Reject stale mode and select no automatic substitute unless scene policy permits. | Show closest supported alternatives. | +| App terminated mid-transaction | Health marker remains unclean. | Next launch enters recovery-first flow; rescue utility can restore independently. | + +## 8.8 Accessibility-critical behavior + +- Reconnect All has a configurable global shortcut with a non-conflicting default and an accessible spoken confirmation. + +- The recovery path does not depend on color, pointer placement, animation, or the display that was disconnected. + +- Topology diagrams have a complete list/table representation with the same controls and relationships. + +- Countdowns support extended duration and do not auto-focus a control on a display about to disappear. + +- OSD announcements can be routed to VoiceOver and suppressed visually; reduced motion avoids topology animation. + +- No filter, dimmer, Black Out overlay, or PIP window may obscure or intercept the emergency recovery command. + +# 9. Safe display disconnection subsystem + +Logical display disconnection is the product's highest-risk capability and its primary differentiator. It must be implemented as a lifecycle subsystem with explicit semantics, invariants, provider isolation, transactional verification, and independent recovery—not as a direct button-to-private-API call. + +## 9.1 Semantic model + +| **User action** | **Topology participation** | **Panel/link behavior** | **Window behavior** | **Risk** | +|---------------------------|----------------------------------------------------------------|---------------------------------------------------------|-------------------------------------------------------------|------------------------------| +| Black Out | Display remains active. | Black overlay or render path; panel may remain powered. | Windows normally remain. | Low to medium. | +| Monitor Sleep / Power Off | Usually remains active unless hardware/system also removes it. | DDC/network command; result may be unverified. | Windows normally remain; monitor may wake from OS activity. | Medium / hardware-dependent. | +| Logical Disconnect | Display is removed from active macOS topology when supported. | Physical link may remain; panel behavior varies. | Windows may be moved by macOS or policy. | High / recovery-critical. | +| Reconnect | Managed-offline endpoint is requested back into topology. | Physical/wireless endpoint must still be available. | Layout/mode may require restoration. | Medium. | +| Physical unplug | OS observes link removal. | Cable/link is physically removed. | macOS decides window handling. | Outside app control. | + +## 9.2 Safety invariants + +1\. At most one topology/lifecycle transaction is active. + +2\. No logical disconnect starts without a complete last-known-safe checkpoint. + +3\. No default operation may intentionally remove the last known-safe recoverable display. + +4\. A target below the destructive identity-confidence threshold is not mutated without explicit confirmation. + +5\. Success is reported only after postconditions are observed; otherwise the result is failed, unverified, degraded, or rolled back. + +6\. Reconnect All always preempts ordinary queued work and is available from an independent process. + +7\. Safe mode disables experimental providers and all automatic lifecycle policies before they can run. + +8\. Persistent managed-offline policy runs only after health checks and can be bypassed at startup. + +9\. Normal quit reconnects app-managed displays unless the user explicitly chose persistence. + +10\. Provider failure is bounded; circuit breakers prevent repeated destabilizing calls. + +11\. A display may be system-absent, managed-offline, or monitor-powered-off; these states are never conflated. + +12\. The product never promises to free a hardware pipeline, bypass the Mac's display-count limit, or emulate cable removal. + +## 9.3 Lifecycle state model + +Reachability: +systemAbsent -\> discoveredInactive -\> active +\| \| +v v +managedOffline \<- disconnecting +\| \| +v v +reconnecting -----\> active + +Presentation overlays (orthogonal): +visible \| blackedOut \| dimmed \| filtered + +Monitor power observation (orthogonal): +unknown \| awake \| sleepRequested \| asleepVerified \| powerFailed + +Transaction: +idle -\> resolving -\> preflight -\> checkpointed -\> applying +-\> observing -\> verifying -\> committed +-\> rollingBack -\> recovered \| degraded \| failed + +The state model intentionally separates topology, presentation, and monitor power. A display can be active but blacked out, active while its panel is asleep, or managed offline while the physical monitor remains powered. Orthogonal states prevent UI and automation from claiming the wrong outcome. + +## 9.4 Disconnect transaction + +| **Stage** | **Required work** | **Failure response** | +|------------------|-----------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| +| Resolve target | Use persistent identity; refresh current route and topology generation. | Return ambiguous/not found; no mutation. | +| Reconcile | Wait for any prior topology events to stabilize and refresh observed state. | Timeout to busy/degraded; no mutation. | +| Preflight safety | Safe surface, identity threshold, OS/provider compatibility, recovery service health, pending policy conflicts. | Block or require elevated timed override. | +| Checkpoint | Atomic snapshot of topology, modes, main/mirror, protected fields, managed-offline set, and recovery metadata. | Block; no provider call. | +| Confirm | First-use/risk countdown on a safe display; show target and recovery key. | Cancel cleanly. | +| Apply | Invoke provider through coordinator with transaction ID and deadline. | Begin rollback if any uncertainty can affect reachability. | +| Observe | Collect normalized OS events; suppress policy loops for this transaction. | Continue to bounded verification or rollback. | +| Verify | Target inactive/managed, safe surface active, registry stable, no unexpected endpoints lost. | Rollback or mark degraded with emergency recovery. | +| Commit | Persist managed-offline record, actor, reason, policy, verified state, and new checkpoint. | If persistence fails, restore or surface recovery-critical error. | + +## 9.5 Pseudocode contract + +func disconnect(targetSelector, actor, options) async -\> LifecycleResult { +return await topologyCoordinator.exclusiveTransaction(kind: .disconnect) { tx in +let target = try await registry.resolve(targetSelector, minimumConfidence: options.threshold) +try await stabilizer.awaitStableGeneration() +let preflight = try await safety.preflightDisconnect(target, recoveryHealth: rescue.health) +try preflight.requireAllowed(options.userOverride) + +let checkpoint = try await checkpoints.writeAtomic(currentState, tx.id) +if preflight.needsConfirmation { +try await confirmation.countdown(on: preflight.safeSurface, recoveryShortcut: rescue.shortcut) +} + +do { +try await lifecycleProvider.disconnect(target, deadline: options.deadline) +let observed = try await registry.awaitTopologyChange(correlatedWith: tx.id) +try verifier.requireDisconnected(target, in: observed) +try verifier.requireSafeSurface(in: observed) +try await managedOfflineStore.commit(target, actor, options.policy, tx.id) +return .committed(tx.id, verification: .verified) +} catch { +let recovery = await rollback.restore(checkpoint, priority: .emergency) +return LifecycleResult.from(error, recovery) +} +} +} + +## 9.6 Safe-surface determination + +A safe surface is an active endpoint on which the user can receive recovery feedback and invoke recovery, or a separately verified remote/control surface explicitly configured by the user. The default rule requires a local active display that is not part of the disconnect target set, is not expected to vanish because of lid/power policy, and has a stable identity. Remote/headless overrides are advanced and must verify the rescue utility, remote session, and startup bypass before allowing the last local surface to be disconnected. + +| **Signal** | **Effect on safe-surface score** | +|------------------------------------------------------------|----------------------------------------------------------------------| +| Active, visible, non-mirrored display with stable identity | Strong positive. | +| Built-in panel with lid open | Positive; may be preferred recovery surface. | +| External display on same hub/KVM as target | Lower confidence because route may fail together. | +| Display scheduled for a later scene disconnect | Not safe for the transaction. | +| Blacked out or filtered | Potentially safe only if recovery bypass removes overlays. | +| Sidecar/AirPlay | Provider-specific; not assumed safe without connection verification. | +| Remote session/virtual display | Advanced override only; requires independent recovery proof. | +| Current main display is target | Requires moving recovery UI/main designation before apply. | + +## 9.7 Reconnect strategy + +- **Normal reconnect.** Resolve the remembered endpoint and invoke the corresponding provider; wait for an active registry record before restoring modes/layout. + +- **Reconnect All.** Prioritize physical/built-in endpoints, attempt every managed-offline record independently, then refresh Sidecar/AirPlay/virtual providers with explicit per-target results. + +- **Wake reconciliation.** Trust observed state first. If macOS already reconnected a managed-offline display, decide whether policy should disconnect it only after stabilization and safety checks. + +- **Startup recovery.** On unclean health marker or bypass key, do not reapply offline policies; reconnect first, then present a recovery summary. + +- **Normal quit.** Reconnect managed-offline endpoints unless persistent policy was explicitly approved; record any endpoint that could not be restored. + +## 9.8 Persistence and aggressive policy + +Persistent disconnect is not an OS guarantee. It is a desired-state policy that the application may reapply after login, wake, or reconnect. It is disabled by default, configured per display, and evaluated only after the main app and rescue service have both reported healthy, topology has stabilized, and a safe surface exists. A startup modifier, rescue command, or unclean shutdown suppresses it. Policies must include cooldowns and maximum attempts to prevent reconnect/disconnect loops. + +## 9.9 Provider contract + +| **Method / property** | **Contract** | +|------------------------------|------------------------------------------------------------------------------------------------------------------| +| probe(environment) | Returns supported/unsupported/unknown, reason, risk level, OS range, and health. Must not mutate. | +| disconnect(target, deadline) | Requests logical removal. Must be cancellation-aware and emit structured progress; cannot report success itself. | +| reconnect(target, deadline) | Requests reactivation. Must tolerate already-active state and be idempotent. | +| reconnectAll(candidates) | Optional optimized route; coordinator still verifies each target. | +| recover(checkpoint) | Best-effort emergency restoration path usable with minimal app dependencies. | +| failure semantics | Typed: unsupported, denied, ambiguous, busy, timeout, provider error, OS rejected, partial, unknown. | +| telemetry/logging | No private user content; include provider version, OS build, target pseudonymous ID, timings, and result. | +| isolation | No UI or automation layer may call provider internals directly. | + +## 9.10 Edge-case policy + +| **Scenario** | **Required policy** | +|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| Disconnect current main display | Move recovery UI and, where appropriate, main role to a verified safe display before disconnect. | +| Disconnect all selected displays | Reject by default; advanced override only with independently verified remote recovery. | +| Lid closes during transaction | Pause/abort and reconcile; never assume built-in panel remains a safe surface. | +| Dock disappears mid-transaction | Abort remaining operations, refresh route/capabilities, reconnect available endpoints, and enter degraded recovery state. | +| Identical monitor ambiguity | Block destructive operation until explicit physical pairing/confirmation. | +| Target already absent | Return idempotent no-op only if it is already managed offline; otherwise distinguish system absence. | +| OS reconnects target immediately | Do not loop. Apply cooldown, record policy conflict, and require manual decision or bounded retry. | +| Mode list changes after reconnect | Refresh modes; resolve favorites by properties; do not apply stale mode handles. | +| Provider hangs | Deadline, cancellation, separate watchdog, circuit breaker, and rescue priority. | +| App update changes provider support | Disable incompatible persistent policy before first launch and explain migration. | + +## 9.11 Recovery hierarchy + +1\. Cancel in confirmation countdown. + +2\. Undo from activity item while the transaction remains reversible. + +3\. Reconnect All from menu bar or global hotkey. + +4\. Automatic rollback from checkpoint. + +5\. Standalone rescue utility or rescue CLI. + +6\. Safe-mode startup using modifier key or command. + +7\. Selective reset of lifecycle policies/provider cache. + +8\. Documented manual removal of login item/configuration as last resort. + +| | **P0 release rule** Any known path that can leave a supported default configuration without a usable recovery surface blocks release. A Labs label does not waive this rule. | +|-----|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +# 10. Technical architecture + +## 10.1 Architectural style + +The recommended implementation is a Swift 6 macOS application using SwiftUI for most interface surfaces and AppKit for mature menu-bar, window, keyboard, and display integrations. State-changing operations flow through actor-isolated domain services. Platform mechanisms live behind provider protocols so public APIs, DDC, network control, and experimental lifecycle code can be compiled, tested, disabled, or replaced independently. + +## 10.2 Logical component map + +UI / App Intents / CLI / local API / rescue +\| +Command Gateway +\| +TopologyCoordinator (actor) ++------------+-------------+ +\| \| \| +ScenePlanner SafetyEngine Activity/Audit +\| \| \| ++------ Desired State -----+ +\| +DisplayRegistry (actor) +observed state + identity + capability +\| +Provider Router / CapabilityResolver ++--------+--------+--------+---------+ +CoreGraphics DDC Native Capture Experimental +Provider Provider Control Provider Lifecycle/Virtual +\| +macOS + display hardware + +Persistent services: SettingsStore, CheckpointStore, HealthMarker, +DiagnosticsStore, Keychain, UpdateCompatibility, RecoveryService. + +## 10.3 Components and responsibilities + +| **Component** | **Responsibility** | **Boundary** | +|-------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------| +| AppShell | Menu bar, windows, lifecycle, safe-mode bootstrap, dependency composition. | No direct display mutation. | +| DisplayRegistry | Normalize OS events, maintain active/offline records, observed state, topology generations. | Single source of observed display truth. | +| IdentityResolver | Compute fingerprints/confidence, handle identical monitors, aliases, pairing, selector resolution. | No destructive choice on ambiguity. | +| CapabilityResolver | Combine OS, Mac, display, route, permission, build flavor, provider health, and policy. | Every unavailable feature has a reason. | +| TopologyCoordinator | Serialize transactions, prioritize recovery, manage deadlines/cancellation, correlate events. | Only owner of topology/lifecycle writes. | +| SafetyEngine | Safe-surface checks, risk score, confirmation policy, last-display rules. | Cannot be bypassed by automation. | +| ScenePlanner | Diff desired/observed state, order operations, classify required/optional, generate dry run. | Idempotent and deterministic. | +| CoreGraphicsProvider | Public display enumeration/configuration, bounds, modes, mirror/main operations where supported. | Documented API boundary. | +| ControlRouter | Select native/DDC/software/network providers and map ranges. | Exposes verification/fallback. | +| DDCProvider | Route probing, VCP commands, timing, read-back, raw diagnostics. | Per-route health and rate limit. | +| CaptureProvider | ScreenCaptureKit PIP/zoom/screenshot sessions and permission state. | No capture until explicit action. | +| ExperimentalLifecycleProvider | Logical connect/disconnect mechanisms isolated from Core. | Feature-flagged, kill-switchable, runtime-probed. | +| VirtualDisplayProvider | Labs virtual endpoint lifecycle and configuration. | Absent from Core dependency graph. | +| RecoveryService | Reconnect All, checkpoint restore, startup bypass, rescue IPC. | Minimal dependency set; emergency priority. | +| SettingsStore | Versioned settings/scenes/rules with atomic write, backup, import/export. | No secrets. | +| CheckpointStore | Small atomic last-known-safe records and transaction health marker. | Readable by rescue utility. | +| DiagnosticsService | Structured logs, topology timeline, bundle redaction, provider health. | No raw serials/tokens by default. | +| AutomationGateway | CLI/App Intents/URL/HTTP command normalization and typed results. | Same safety/coordinator path as UI. | + +## 10.4 State ownership and concurrency + +- DisplayRegistry is an actor that owns normalized observed state and increments a topology generation after stabilization. + +- TopologyCoordinator is an actor that owns the mutation queue. Emergency recovery has higher priority than scenes, rules, or external requests. + +- Providers are stateless where practical; any per-route caches are actor-isolated and versioned by topology generation. + +- UI views consume immutable snapshots and submit commands; they do not mutate domain models. + +- OS callback threads enqueue raw events quickly; normalization, debouncing, and identity reconciliation occur off the callback path. + +- Every transaction has a UUID/correlation ID, actor, reason, deadline, checkpoint ID, and event suppression scope. + +## 10.5 Display identity model + +| **Signal** | **Use** | **Caveat** | +|------------------------------------|----------------------------------------------------------|-----------------------------------------------------------------| +| User alias / explicit pairing | Highest-level durable intent. | Must not silently move to another physical device. | +| EDID serial/hash | Strong physical identity when valid. | Missing, duplicated, changed by adapters, or privacy-sensitive. | +| Vendor/product/model/year/week | Model-family evidence. | Insufficient for identical units. | +| IORegistry path / transport | Route and port context. | Changes across docks/ports and OS versions. | +| Physical dimensions | Supporting evidence and scale calculations. | Often rounded or incorrect. | +| Current topology/relative position | Disambiguates identical monitors in a stable desk setup. | Not identity by itself. | +| CG UUID/display ID | Current-session addressing. | May change or switch; never sole persistent key. | +| User tags | Automation grouping. | May intentionally match multiple displays. | + +The resolver stores a stable internal DisplayRecord ID and links observations through scored evidence. Destructive selector resolution requires one high-confidence candidate; read-only queries may return multiple candidates. When evidence conflicts, the system marks the record Uncertain and preserves both candidates until the user resolves them. + +## 10.6 Capability model + +CapabilityDecision { +capability: LogicalDisconnect \| DDCBrightness \| HDRWrite \| ... +status: supported \| unsupported \| unknown \| degraded \| disabledByPolicy +verification: verified \| readBackUnavailable \| notApplicable +risk: normal \| hardwareDependent \| experimental \| recoveryCritical +provider: identifier? +reasons: \[OSVersion, Architecture, DisplayClass, Route, Permission, +BuildFlavor, ProviderHealth, UserPolicy, SafetyPolicy\] +validForTopologyGeneration: UInt64 +} + +## 10.7 Scene planning and operation order + +1\. Resolve identities and capabilities against one topology generation. + +2\. Validate required displays and fields; produce a dry-run diff. + +3\. Create checkpoint and suppress conflicting rules for the transaction scope. + +4\. Reconnect destination displays and wait for registry stabilization. + +5\. Establish safe surface and recovery UI location. + +6\. Apply mirror/main/layout changes using public atomic configuration where possible. + +7\. Refresh mode lists and apply modes/rotation/profile with verification. + +8\. Apply controls, inputs, brightness, filters, and network commands with rate limits. + +9\. Disconnect retiring displays only after destination postconditions pass. + +10\. Commit desired state, activity result, and new last-known-safe checkpoint. + +## 10.8 Storage model + +| **Store** | **Contents** | **Properties** | +|---------------------|----------------------------------------------------------------------------|-----------------------------------------------------| +| Settings | Preferences, UI state, feature flags, provider policies. | Versioned JSON/PropertyList; atomic write; backups. | +| Display records | Stable IDs, aliases, tags, fingerprints, route history, pairing decisions. | Sensitive fields hashed/redacted on export. | +| Scenes/rules | Desired-state documents, selectors, triggers, priority, cooldown. | Human-reviewable, versioned, import diff. | +| Checkpoints | Minimal topology and recovery state for recent risky transaction. | Atomic, bounded, rescue-readable, no secrets. | +| Activity/logs | Transactions, events, provider health, errors, timings. | Structured, rotating, redaction levels. | +| Keychain | API tokens and network credentials. | Never exported by default or logged. | +| Compatibility flags | OS/build/provider certifications and kill switches. | Signed release data; conservative defaults. | + +## 10.9 Public/private API isolation + +The experimental lifecycle and virtual-display modules must be separable build targets with narrow protocol surfaces. Core models may refer to capability concepts but not to private symbols or implementation types. CI shall compile and test a public-API-only flavor. On an unrecognized major OS build, persistent experimental policy is disabled until compatibility is explicitly enabled by a signed release configuration or the user opts into Labs. + +# 11. Detailed requirements + +These requirements are the normative backlog baseline. Acceptance criteria are intentionally testable and should be linked to implementation issues and automated/manual evidence. Release labels indicate the earliest intended delivery; Labs requirements remain subject to opt-in and compatibility gating. + +| **Requirement domain** | **Count** | **Priority mix** | **Release mix** | +|------------------------------------------|-----------|-------------------------------|------------------------------------| +| Display registry and identity | 12 | Must: 11, Should: 1 | Core 1.0: 12 | +| Safe display lifecycle | 22 | Must: 21, Should: 1 | Core 1.0: 21, Core 1.x: 1 | +| Topology, modes, and scenes | 18 | Must: 14, Should: 3, Could: 1 | Core 1.0: 16, Core 1.x: 2 | +| Controls, DDC, color, and audio | 14 | Must: 11, Should: 2, Could: 1 | Core 1.0: 10, Labs: 2, Core 1.x: 2 | +| Virtual display and capture | 8 | Must: 4, Should: 3, Could: 1 | Labs: 5, Core 1.x: 3 | +| Automation and APIs | 12 | Must: 9, Should: 3 | Core 1.0: 7, Core 1.x: 5 | +| Recovery, diagnostics, and configuration | 12 | Must: 9, Should: 3 | Core 1.0: 10, Labs: 1, Core 1.x: 1 | +| User experience and accessibility | 10 | Must: 7, Should: 3 | Core 1.0: 10 | +| Non-functional requirements | 16 | Must: 13, Should: 3 | Core 1.0: 16 | + +| | **Release interpretation** Core 1.0 requirements are part of the first stable release unless removed by an explicit scope decision. Core 1.x requirements are follow-on commitments. Labs requirements define the minimum quality bar for experimentation; they are not permission to ship unsafe behavior. | +|-----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +## Display registry and identity + +Normative requirements: Display registry and identity + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| REG-001 | Discover active displays | The system shall enumerate built-in, external, virtual, Sidecar/AirPlay, mirror members, and headless endpoints exposed by the OS. | A topology snapshot appears within 2 seconds of app readiness and matches Core Graphics/System Settings for all test fixtures. | Must | Core 1.0 | +| REG-002 | Track topology events | The registry shall publish ordered add, remove, mode, bounds, mirror, main-display, and sleep/wake changes. | A recorded test sequence produces one normalized event stream with no duplicate stable-state events. | Must | Core 1.0 | +| REG-003 | Create persistent fingerprints | Each display shall receive a fingerprint derived from available EDID, vendor/product, serial, transport, IORegistry, physical size, and topology signals. | A display reconnected to the same or another port resolves to its prior record when confidence exceeds the configured threshold. | Must | Core 1.0 | +| REG-004 | Handle identical monitors | The identity engine shall distinguish identical models using serial, route/topology, user aliases, and explicit pairing. | Two same-model monitors can be assigned persistent left/right identities and remain correct across ten reconnect cycles in the certified setup. | Must | Core 1.0 | +| REG-005 | Expose confidence and provenance | Every identity resolution shall expose confidence, matched signals, conflicting signals, and whether user confirmation is required. | Diagnostics and API output contain a score and evidence set; destructive actions are blocked below the safety threshold. | Must | Core 1.0 | +| REG-006 | Remember offline devices | The registry shall retain approved display records after disconnect and mark current reachability separately from desired policy. | A managed-offline display remains selectable for reconnect and scene planning after app restart. | Must | Core 1.0 | +| REG-007 | Support aliases and tags | Users shall set unique display aliases and multiple automation tags. | Aliases appear in all UI/API surfaces and tags resolve deterministically or return an ambiguity error. | Must | Core 1.0 | +| REG-008 | Compute per-route capabilities | Capabilities shall be evaluated for the current Mac, OS, display, port, adapter, dock, and policy combination. | Moving a monitor from direct USB-C to a non-DDC dock updates the control availability and explanation without changing its user identity. | Must | Core 1.0 | +| REG-009 | Separate observed and desired state | The model shall retain observed OS/hardware state, user-desired state, policy source, and last verified state. | Diagnostics can explain whether a value came from macOS, a scene, a rule, a user action, or recovery. | Must | Core 1.0 | +| REG-010 | Resolve conflicts explicitly | Ambiguous selectors or competing policies shall never silently choose a destructive target. | CLI/API returns a typed conflict with candidates; UI requests confirmation or policy precedence. | Must | Core 1.0 | +| REG-011 | Version display records | Persisted records shall use a migratable schema with atomic writes and backup. | Upgrade and downgrade fixtures preserve aliases/scenes or fail safely with a readable migration report. | Must | Core 1.0 | +| REG-012 | Export registry diagnostics | The app shall export a redacted machine-readable registry snapshot. | Export includes fingerprints with salted/redacted sensitive fields, capability reasons, and schema version. | Should | Core 1.0 | + +## Safe display lifecycle + +Normative requirements: Safe display lifecycle + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| LIF-001 | Name lifecycle operations precisely | The product shall expose separate actions for Black Out, Monitor Sleep/Power, Logical Disconnect, and Reconnect. | No UI or API labels use these terms interchangeably; help text states topology impact for each. | Must | Core 1.0 | +| LIF-002 | Serialize lifecycle changes | All logical connect/disconnect operations shall run through a single topology transaction coordinator. | Concurrent UI, rule, and CLI requests are queued, coalesced, or rejected with a busy result; no overlapping provider calls occur. | Must | Core 1.0 | +| LIF-003 | Preflight a safe visible surface | Before logical disconnect, the app shall verify that at least one known-safe visible or recoverable surface remains. | Disconnect is blocked when it would remove the last safe surface unless the user completes an advanced, timed, explicit override. | Must | Core 1.0 | +| LIF-004 | Preflight identity confidence | A destructive lifecycle action shall require target identity above a configurable confidence threshold. | A low-confidence identical-monitor fixture cannot be disconnected without explicit target confirmation. | Must | Core 1.0 | +| LIF-005 | Create an atomic checkpoint | The coordinator shall write a last-known-safe topology checkpoint before invoking an experimental lifecycle provider. | Power loss after checkpoint creation leaves either the prior complete checkpoint or the new complete checkpoint, never partial data. | Must | Core 1.0 | +| LIF-006 | Provide first-use confirmation | The first disconnect for each display/route shall present a countdown with Cancel and explain the recovery hotkey. | Cancel during the countdown performs no provider action; acceptance records route-specific consent. | Must | Core 1.0 | +| LIF-007 | Verify postconditions | After provider invocation, the coordinator shall observe system events and verify target state, safe-surface state, and topology stability. | An operation is not reported successful until verified; timeout results in rollback or a degraded-state warning. | Must | Core 1.0 | +| LIF-008 | Rollback failed disconnects | A failed or unsafe transition shall restore the checkpoint using the most reliable available provider path. | Injected failures at every transaction stage return the certified fixture to a usable display within the recovery objective. | Must | Core 1.0 | +| LIF-009 | Reconnect all managed displays | Reconnect All shall attempt every display marked managed-offline, then reconcile results individually. | One failing display does not prevent attempts for others; UI and JSON list success/failure per target. | Must | Core 1.0 | +| LIF-010 | Keep emergency recovery omnipresent | Reconnect All shall be accessible from menu-bar root, global keyboard shortcut, CLI, and rescue utility. | Recovery can be invoked without opening the main settings window and without using a pointer. | Must | Core 1.0 | +| LIF-011 | Ship independent rescue utility | A minimal signed helper shall reconnect managed displays, disable auto-apply policies, and launch the app in safe mode. | The helper operates when the main app configuration is corrupt or the main app crashes on launch. | Must | Core 1.0 | +| LIF-012 | Restore on normal quit by default | Normal quit shall reconnect displays managed offline unless the user enabled an advanced persistent policy. | Default quit on all certified fixtures leaves no display intentionally offline. | Must | Core 1.0 | +| LIF-013 | Recover after unclean exit | A startup health marker shall detect crash/termination during a lifecycle transaction and offer or perform safe restoration. | Killing the app at each transaction stage results in safe-mode startup and checkpoint recovery. | Must | Core 1.0 | +| LIF-014 | Reconcile wake once | After wake, the app shall debounce display events until topology is stable, then apply lifecycle policy at most once per stabilization generation. | Wake storms do not cause repeated disconnect/reconnect loops; logs identify the single reconciliation decision. | Must | Core 1.0 | +| LIF-015 | Guard persistent disconnect | Persistent/aggressive disconnect shall be per-display, off by default, require a healthy startup window, and be bypassable by holding a documented key. | Reboot with bypass key prevents all experimental auto-actions; persistent policy never runs before rescue services are ready. | Must | Core 1.x | +| LIF-016 | Automate built-in panel safely | Built-in display rules shall account for external safe surface, lid state, AC power, and current session. | Removing the last external display reconnects the built-in panel before the external endpoint becomes unavailable when the platform allows. | Must | Core 1.0 | +| LIF-017 | Protect the last/iMac display | The app shall identify configurations where the target may be the only recoverable local surface and increase confirmation/deny unsafe action. | Certified last-display and iMac fixtures cannot enter an unrecoverable black-screen state using default settings. | Must | Core 1.0 | +| LIF-018 | Implement Black Out reversibly | Black Out shall be reversible locally and by recovery hotkey, without changing layout or moving windows unless explicitly configured. | Window positions and active topology remain unchanged over a Black Out cycle. | Must | Core 1.0 | +| LIF-019 | Implement monitor power honestly | DDC/network sleep shall report sent, verified, unverified, unsupported, or failed; it shall not claim logical disconnect. | A route that blocks DDC shows unverified/failed and retains the display in topology. | Must | Core 1.0 | +| LIF-020 | Explain physical link limits | The UI shall state that software generally cannot emulate cable removal, free a hardware display pipeline, or exceed platform display-count limits. | Help and action details contain this limitation; no marketing text promises otherwise. | Must | Core 1.0 | +| LIF-021 | Treat Sidecar/AirPlay separately | Wireless/continuity endpoints shall use provider-specific connect/reconnect semantics and shall not be assumed equivalent to physical displays. | Reconnect All reports unsupported or delegated behavior for Sidecar/AirPlay instead of false success. | Should | Core 1.0 | +| LIF-022 | Expose managed-offline status | Every offline record shall show who disconnected it, when, why, desired reconnect policy, and last failure. | UI and API expose the complete status for troubleshooting. | Must | Core 1.0 | + +## Topology, modes, and scenes + +Normative requirements: Topology, modes, and scenes + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| TOP-001 | Read complete topology | The app shall model bounds, scale, rotation, main display, mirror sets, active mode, refresh, HDR, profile, and connection state. | The topology model round-trips certified fixture state to JSON without loss of supported fields. | Must | Core 1.0 | +| TOP-002 | Apply layout atomically | A layout change shall use one Core Graphics configuration transaction where supported. | A multi-display move presents no observable intermediate overlap in event logs and completes or rolls back as one change. | Must | Core 1.0 | +| TOP-003 | Set main display | Users and scenes shall select the main display using stable identity. | After apply and one wake cycle, the selected display remains main when protection is enabled. | Must | Core 1.0 | +| TOP-004 | Manage mirrors | The app shall create/remove mirror sets and validate source/target compatibility. | Unsupported mirror requests fail before changing topology; valid requests survive export/import. | Must | Core 1.0 | +| TOP-005 | Apply modes safely | Mode changes shall verify width, height, HiDPI flag, refresh, depth, and rotation against current capability. | An unavailable mode is rejected with alternatives; no stale mode ID is applied after reconnect. | Must | Core 1.0 | +| TOP-006 | Pin favorite modes | Users shall mark named mode combinations and invoke them from menu, shortcut, scene, or CLI. | Favorites resolve by properties rather than transient mode IDs and warn when no current equivalent exists. | Should | Core 1.0 | +| TOP-007 | Protect selected properties | Users shall independently protect layout, main display, mode, rotation, HDR, refresh, and profile. | Changing an unprotected field does not trigger restoration; changing a protected field does after the debounce window. | Must | Core 1.0 | +| TOP-008 | Debounce restoration | Protection shall wait for topology stabilization and use bounded retries/circuit breaking. | A deliberately unsupported state does not cause an infinite restore loop or persistent flicker. | Must | Core 1.0 | +| TOP-009 | Define anchors | Scenes may position displays relative to an anchor with edge/center alignment and gaps. | Scenes adapt when an optional display is absent while preserving anchor-relative placement. | Should | Core 1.0 | +| TOP-010 | Model desired state scenes | A scene shall contain optional display membership, topology, modes, controls, profiles, and lifecycle policy. | A scene can omit fields; omitted fields remain unchanged during apply. | Must | Core 1.0 | +| TOP-011 | Preview scene diff | Before manual application, the UI shall show target resolution, operations, unsupported fields, and risk level. | Preview uses the same planner as execution and matches the resulting transaction log. | Must | Core 1.0 | +| TOP-012 | Order scene operations safely | The planner shall connect needed displays before layout/mode changes and disconnect targets only after a safe surface is established. | A desk-to-mobile fixture never disconnects the current safe display before the destination surface is verified. | Must | Core 1.0 | +| TOP-013 | Make scene apply idempotent | Applying an already-satisfied scene shall produce no unnecessary provider calls. | Second apply on a stable fixture yields zero topology writes and zero visible flicker. | Must | Core 1.0 | +| TOP-014 | Support partial availability | Scenes shall declare required and optional displays and a policy for missing capabilities. | Required-missing blocks before mutation; optional-missing continues and reports a warning. | Must | Core 1.0 | +| TOP-015 | Rollback scene failure | A scene transaction shall roll back topology-critical fields when a required step fails. | Fault injection at each required step returns the system to checkpoint or a documented safe degraded state. | Must | Core 1.0 | +| TOP-016 | Export/import scenes | Scenes shall serialize to a documented, versioned, reviewable format. | Round-trip preserves all non-secret fields; imports validate selectors and show a diff before commit. | Must | Core 1.0 | +| TOP-017 | Separate window movement | Window repositioning shall be opt-in and isolated from display topology changes. | Applying a scene with window policy disabled never moves application windows. | Should | Core 1.x | +| TOP-018 | Provide UI scale suggestions | The app shall calculate suggested modes that approximate equal physical UI size across selected displays. | Recommendation includes assumptions and never auto-applies without confirmation. | Could | Core 1.x | + +## Controls, DDC, color, and audio + +Normative requirements: Controls, DDC, color, and audio + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| CTL-001 | Probe DDC per route | The app shall probe DDC/CI availability, VCP support, timing, and verification behavior for the current connection route. | Diagnostics distinguish display support from transport failure and cache results with expiry. | Must | Core 1.0 | +| CTL-002 | Control brightness through best provider | Brightness shall select native, DDC, software, or combined providers according to capability and user policy. | The UI displays the active provider and fallback; provider changes do not produce large visible jumps. | Must | Core 1.0 | +| CTL-003 | Control volume/mute/contrast | Supported DDC/native values shall expose read/write ranges and verification state. | Controls are hidden or disabled with reason when unavailable; writes respect the device's actual range. | Must | Core 1.0 | +| CTL-004 | Switch inputs safely | Input switching shall use named values, optional read-back, and configurable delay before dependent scene steps. | A scene waits for the configured route stabilization or reports unverified transition. | Must | Core 1.0 | +| CTL-005 | Normalize control ranges | Provider-specific ranges shall map to a consistent 0-100 user scale while preserving raw values for diagnostics. | Round-trip error stays within one UI step for certified monitors. | Must | Core 1.0 | +| CTL-006 | Rate-limit writes | Slider, key repeat, automation, and synchronization writes shall be coalesced and bounded per device/provider. | A 100-event burst generates no more than the configured safe provider call rate and converges to the final value. | Must | Core 1.0 | +| CTL-007 | Synchronize groups | Group control shall map one source value to members with per-display curve, min/max, and offset. | Mixed native/DDC/software group converges within tolerance without recursive event loops. | Must | Core 1.0 | +| CTL-008 | Route media keys | Brightness and volume keys shall target main, pointer, focused-window, fixed display, or group policy. | Each routing mode passes keyboard-only tests and shows an identifying OSD. | Must | Core 1.0 | +| CTL-009 | Select and protect profiles | Users/scenes shall select color profiles and optionally protect them. | Profile is restored after a simulated system drift and not restored when protection is disabled. | Must | Core 1.0 | +| CTL-010 | Guard HDR/XDR changes | High dynamic range and brightness expansion writes shall be capability-gated, rate-limited, reversible, and clearly experimental where applicable. | Rapid-change and crash tests do not leave a certified display washed out after recovery. | Must | Labs | +| CTL-011 | Provide image filters | Software filters shall declare capture/overlay limitations and be removable through safe mode/recovery. | Filters never obscure the Reconnect All recovery surface and are disabled in safe mode. | Should | Core 1.x | +| CTL-012 | Support network providers | Network-controlled devices shall use opt-in provider plugins with explicit discovery and credentials handling. | Credentials remain in Keychain; disabling a plugin removes network listeners and discovery. | Could | Core 1.x | +| CTL-013 | Expose raw diagnostics | Advanced users shall inspect raw DDC VCP codes, provider responses, and timing without enabling arbitrary unsafe writes by default. | Diagnostics are read-only unless an advanced developer flag is enabled. | Should | Core 1.0 | +| CTL-014 | Avoid medical claims | Eye-care features shall describe technical effects and uncertainty without diagnosing or promising health outcomes. | Copy review finds no medical efficacy claim; links distinguish user preference from established evidence. | Must | Labs | + +## Virtual display and capture + +Normative requirements: Virtual display and capture + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|--------------|-------------| +| VIR-001 | Gate virtual displays behind Labs | Virtual display creation shall be disabled by default and isolated behind a provider/capability contract. | Core build operates fully with the provider absent; Labs opt-in includes recovery notice. | Must | Labs | +| VIR-002 | Create named virtual endpoints | Users shall create a virtual display with name, logical/pixel size, scale, and optional refresh/HDR parameters. | On supported fixtures, the endpoint appears in registry and can be discarded/recreated by stable virtual ID. | Should | Labs | +| VIR-003 | Persist virtual intent safely | Persistence shall wait for app health and topology stabilization and shall be bypassed by safe-mode startup. | A corrupt virtual definition cannot create a startup loop; invalid entries are quarantined. | Must | Labs | +| VIR-004 | Preview displays with ScreenCaptureKit | PIP/zoom shall use public capture APIs and request only required permissions. | Permission denial leaves topology features functional and provides a direct explanation. | Should | Core 1.x | +| VIR-005 | Protect captured privacy | Capture UI shall show an active indicator, honor system exclusions, and stop streams on lock/logout. | Automated lock test terminates all capture sessions within the defined objective. | Must | Core 1.x | +| VIR-006 | Control PIP behavior | Users shall choose always-on-top, aspect fit/fill, pointer visibility, click-through, and target display/region. | Settings persist per PIP preset and keyboard control remains possible. | Could | Core 1.x | +| VIR-007 | Define virtual sleep policy | Users shall choose whether windows move, display reconnects, or state remains offline after sleep. | Each policy produces documented behavior in sleep/wake integration tests. | Should | Labs | +| VIR-008 | Secure local streaming | Streaming shall be off by default, bind locally by default, require authentication, and show active-session controls. | A network scan finds no listener until enabled; unauthenticated requests fail. | Must | Labs | + +## Automation and APIs + +Normative requirements: Automation and APIs + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|--------------|-------------| +| AUT-001 | Provide a stable CLI | The CLI shall implement list/get/set/toggle/scene/connect/disconnect/recover/diagnose with documented exit codes. | Golden tests validate syntax, stdout JSON, stderr warnings, and idempotency across releases. | Must | Core 1.0 | +| AUT-002 | Use stable selectors | CLI/API selectors shall support fingerprint ID, alias, tag, name with disambiguation, vendor/product/serial, main, pointer, and focus. | Ambiguous selectors return candidates and no mutation. | Must | Core 1.0 | +| AUT-003 | Support dry run | Every multi-field or lifecycle mutation shall offer a dry-run plan. | Dry run performs zero writes and returns operation order, risks, permissions, and unsupported fields. | Must | Core 1.0 | +| AUT-004 | Return typed results | Automation surfaces shall return per-field success, failure, warning, verification state, and transaction ID. | Callers can distinguish unsupported, permission denied, ambiguous, timeout, rollback, and provider failure. | Must | Core 1.0 | +| AUT-005 | Expose App Intents | Common actions and scenes shall be available through App Intents with parameterized display selectors. | Shortcuts can list scenes, apply a scene, adjust brightness, switch input, and invoke Reconnect All. | Must | Core 1.0 | +| AUT-006 | Secure URL actions | The URL scheme shall limit destructive actions or require a confirmation/token policy. | A crafted untrusted URL cannot silently disconnect the last safe display. | Must | Core 1.x | +| AUT-007 | Secure HTTP API | The optional HTTP server shall bind to loopback by default and require a random bearer token. | Requests without token fail; tokens rotate; no secret is included in diagnostics export. | Must | Core 1.x | +| AUT-008 | Publish events | Clients shall subscribe to normalized topology, state, transaction, and recovery events. | Event payloads include monotonic sequence, schema version, source, and correlation ID. | Should | Core 1.x | +| AUT-009 | Evaluate rules deterministically | Rules shall have explicit priority, cooldown, conditions, and conflict resolution. | Given the same event/state fixture, rule evaluation produces the same ordered action plan. | Should | Core 1.x | +| AUT-010 | Audit automation | Every automated mutation shall be logged with actor, selector resolution, policy, and result. | Activity log can answer what changed a display and how to undo it. | Must | Core 1.0 | +| AUT-011 | Rate-limit external callers | CLI/API/HTTP requests shall share coordinator limits and cannot bypass safety checks. | A request flood does not exceed provider rate limits or starve Reconnect All. | Must | Core 1.0 | +| AUT-012 | Maintain backward compatibility | Documented API fields shall follow semantic versioning and deprecation windows. | Compatibility tests run against the prior two minor client schemas. | Should | Core 1.x | + +## Recovery, diagnostics, and configuration + +Normative requirements: Recovery, diagnostics, and configuration + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| DIA-001 | Launch in safe mode | Safe mode shall disable experimental providers, auto-apply rules, persistent disconnect, filters, and virtual recreation. | Holding the startup bypass key or using the rescue utility reaches a usable safe-mode UI on every certified fixture. | Must | Core 1.0 | +| DIA-002 | Offer selective reset | Users shall reset display identity, DDC cache, rules, scenes, providers, or all settings. | Each reset previews affected objects and leaves unrelated settings untouched. | Must | Core 1.0 | +| DIA-003 | Write structured logs | Logs shall include transaction ID, topology generation, provider, state transitions, timing, and redaction level. | A failing integration test can be reconstructed from the diagnostics bundle without personal content. | Must | Core 1.0 | +| DIA-004 | Build a topology timeline | Diagnostics shall retain a bounded sequence of normalized display events around wake, connect, and failure. | Timeline displays stable timestamps and causal transaction IDs. | Should | Core 1.0 | +| DIA-005 | Generate a redacted support bundle | The bundle shall include versions, hardware class, capability matrix, settings schema, logs, and crash metadata with user review. | Default export removes usernames, window titles, IPs, serials, and tokens or hashes them consistently. | Must | Core 1.0 | +| DIA-006 | Show actionable capability reasons | Disabled features shall explain missing OS support, route, hardware, permission, build flavor, or safety policy. | At least 95% of disabled controls in test fixtures have a non-generic reason code and remediation. | Must | Core 1.0 | +| DIA-007 | Detect provider health | Each provider shall expose probe, health, version, failure count, and circuit-breaker state. | Three bounded failures in a configured window disable the provider and present recovery without repeated writes. | Must | Core 1.0 | +| DIA-008 | Back up risky configuration | Before EDID/system override changes, the app shall export current state and require explicit restart/recovery acknowledgement. | A failed override install can be removed by rescue flow with documented commands. | Must | Labs | +| DIA-009 | Validate imports | Imported settings shall be schema-validated, show a diff, exclude secrets, and quarantine unknown experimental fields. | Malformed imports perform no partial write and produce line/item-level errors. | Must | Core 1.0 | +| DIA-010 | Support reproducible bug reports | The app shall create a correlation ID and optional minimal reproduction script from an activity segment. | Maintainers can attach logs and steps without requiring users to disclose full configuration. | Should | Core 1.x | +| DIA-011 | Protect secrets | HTTP tokens, network credentials, and signing material shall never enter plain settings or logs. | Static and dynamic scans find no plaintext secret in exported configuration or bundle. | Must | Core 1.0 | +| DIA-012 | Provide in-app health summary | A dashboard shall show unsafe pending states, managed-offline displays, circuit breakers, permissions, and update compatibility. | A user can reach all active remediation actions from the health summary. | Should | Core 1.0 | + +## User experience and accessibility + +Normative requirements: User experience and accessibility + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|--------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| UX-001 | Provide a fast menu-bar surface | The root menu shall show Reconnect All, scenes, and display cards without deep navigation. | Reconnect All is one menu level or less; common brightness and mode actions are reachable within two levels. | Must | Core 1.0 | +| UX-002 | Provide a topology workspace | The settings app shall visualize arrangement, main display, mirrors, active/offline state, and identity confidence. | Keyboard and VoiceOver users can inspect and change the same supported topology fields. | Must | Core 1.0 | +| UX-003 | Use risk-aware copy | Actions shall carry Normal, Hardware-dependent, Experimental, Restart-required, or Recovery-critical labels. | Usability test participants correctly predict topology impact of each lifecycle action at the target rate. | Must | Core 1.0 | +| UX-004 | Make dangerous actions reversible | The UI shall offer Undo where technically safe and always provide the next recovery action after failure. | Activity rows show Undo/Recover or explain why neither is possible. | Must | Core 1.0 | +| UX-005 | Meet accessibility baseline | The app shall support VoiceOver, full keyboard navigation, reduced motion, sufficient contrast, Dynamic Type-equivalent scaling, and non-color status cues. | Automated audit passes and manual VoiceOver workflow covers onboarding, disconnect, reconnect, scene apply, and diagnostics. | Must | Core 1.0 | +| UX-006 | Avoid trapping focus off-screen | After topology changes, key windows shall be moved to a verified active display when needed. | The main/recovery window remains reachable after disconnecting the display that previously contained it. | Must | Core 1.0 | +| UX-007 | Show concise OSD feedback | Brightness, volume, input, mode, and scene actions shall show target and result without obscuring critical UI. | OSD identifies the display/group and disappears or persists according to accessibility preference. | Should | Core 1.0 | +| UX-008 | Support localization | All user-facing strings shall use localization resources and layouts shall tolerate at least 40% expansion. | Pseudo-localization produces no clipping in all core screens. | Should | Core 1.0 | +| UX-009 | Explain permissions in context | Screen recording, accessibility, automation, network, and login-item permissions shall be requested only when a feature needs them. | Fresh install can use core topology/DDC features without granting unrelated capture permission. | Must | Core 1.0 | +| UX-010 | Provide contextual help | Each advanced feature shall link to a local help page with behavior, compatibility, risk, and recovery. | Help remains accessible offline and matches the installed app version. | Should | Core 1.0 | + +## Non-functional requirements + +Normative requirements: Non-functional requirements + +| **ID** | **Requirement** | **System shall…** | **Acceptance criterion** | **Priority** | **Release** | +|---------|-------------------------|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|--------------|-------------| +| NFR-001 | Startup performance | Menu-bar status and registry shall become usable quickly without blocking on slow DDC probes. | p95 time to usable is \<=2.0 seconds on supported Apple Silicon baseline; DDC probes continue asynchronously. | Must | Core 1.0 | +| NFR-002 | Interaction latency | Common UI actions shall acknowledge immediately and complete within provider-specific budgets. | UI feedback \<=100 ms; p95 native control \<=250 ms; DDC completion budget documented per route. | Should | Core 1.0 | +| NFR-003 | Topology convergence | After the last OS display event, registry state shall converge within a bounded stabilization window. | p95 stable snapshot \<=2 seconds after normal connect/wake event storms on certified fixtures. | Must | Core 1.0 | +| NFR-004 | Recovery objective | A failed lifecycle transaction shall restore a usable safe surface rapidly. | Automated fault tests achieve usable recovery within 10 seconds p95 where the OS/provider remains responsive. | Must | Core 1.0 | +| NFR-005 | Crash-free operation | Core paths shall meet a defined crash-free-session target before 1.0. | \>=99.9% crash-free sessions in opt-in beta telemetry or equivalent test evidence; zero known P0 recovery defects. | Must | Core 1.0 | +| NFR-006 | Resource use | Idle monitoring shall have low CPU, memory, wakeups, and energy impact. | Baseline idle \<=0.5% CPU average, \<=150 MB memory, and no busy polling on reference hardware. | Should | Core 1.0 | +| NFR-007 | Security | The project shall use least privilege, hardened runtime where compatible, Keychain for secrets, and dependency scanning. | Threat model reviewed; high/critical dependency findings block release; local endpoints require authentication. | Must | Core 1.0 | +| NFR-008 | Privacy | No analytics, display serial collection, capture, or network listener shall activate by default. | Fresh-install network/capture audit shows zero unexpected outbound traffic or capture session. | Must | Core 1.0 | +| NFR-009 | Accessibility quality | Accessibility is a release gate, not a post-1.0 enhancement. | Core workflows pass manual VoiceOver and keyboard testing on each supported major OS. | Must | Core 1.0 | +| NFR-010 | Compatibility isolation | Experimental code shall not be required for Core compilation or startup. | Public-API-only CI build passes all applicable tests with experimental modules absent. | Must | Core 1.0 | +| NFR-011 | Reproducible builds | Release artifacts shall be traceable to tagged source, dependency lockfiles, checksums, and SBOM. | Two clean build environments produce functionally equivalent artifacts; release publishes provenance and checksums. | Should | Core 1.0 | +| NFR-012 | Update safety | Updates shall preserve recovery paths and detect OS/build incompatibility before auto-enabling experimental providers. | On a new major macOS version, experimental auto-apply defaults off until compatibility is explicitly approved. | Must | Core 1.0 | +| NFR-013 | Testability | Core logic shall be dependency-injected and runnable against simulated topology/provider fixtures. | State machine, planner, identity, and rules achieve agreed coverage and deterministic replay tests. | Must | Core 1.0 | +| NFR-014 | Documentation | User, recovery, API, architecture, and contribution documentation shall ship with each release. | Release checklist blocks if docs or schema references are stale. | Must | Core 1.0 | +| NFR-015 | Maintainability | Provider boundaries and state transitions shall be explicit, logged, and reviewed through RFCs. | No UI component directly calls private/system provider APIs; architecture lint/tests enforce dependency direction. | Must | Core 1.0 | +| NFR-016 | License compliance | Every dependency and contribution shall have recorded provenance and compatible licensing. | Automated license scan and human review pass before release; unknown license blocks inclusion. | Must | Core 1.0 | + +# 12. Automation and integration contract + +## 12.1 Design requirements + +- Every external command enters through AutomationGateway and uses the same identity, capability, safety, transaction, verification, and audit path as the UI. + +- Selectors are stable and explicit. Ambiguity is an error for mutation; read-only queries may return a candidate set. + +- Commands are idempotent where the requested end state is already satisfied. + +- Machine-readable output is the default for scripting; human-readable output remains available. + +- Dry run is supported for scenes, multi-field updates, and lifecycle actions. + +- The API distinguishes unsupported, disabled-by-policy, denied, ambiguous, timeout, failed, partial, rolled-back, and unverified results. + +## 12.2 Proposed CLI grammar + +opendisplay list \[--state active\|offline\|all\] \[--json\] +opendisplay get \ \[field ...\] \[--json\] +opendisplay set \ \... \[--dry-run\] \[--json\] +opendisplay connect \ \[--dry-run\] \[--json\] +opendisplay disconnect \ \[--confirm-policy interactive\|preapproved\] \[--dry-run\] +opendisplay blackout \ on\|off\|toggle +opendisplay power \ on\|off\|sleep +opendisplay scene list\|show\|apply\|export\|import \ \[--dry-run\] +opendisplay recover all\|checkpoint\|safe-mode \[--json\] +opendisplay diagnose display\|route\|provider\|bundle \[selector\] + +## 12.3 Selector contract + +| **Selector** | **Example** | **Mutation rule** | +|--------------------|-----------------------------------|----------------------------------------------------------------------| +| Stable internal ID | id:disp_01J… | Preferred exact selector. | +| Alias | alias:DeskLeft | Must resolve uniquely. | +| Tag | tag:studio | May target a set; destructive set operations require explicit --all. | +| Fingerprint fields | vendor:610 product:12345 serial:… | Evidence is normalized; sensitive values may be hashed. | +| Name/model | name:"LG HDR 4K" | Ambiguity returns candidates. | +| Role | main, builtin, pointer, focus | Resolved at transaction start and recorded. | +| State | state:managedOffline | Set selector; explicit confirmation for lifecycle mutations. | +| Topology | leftOf:alias:Center | Read/query aid; not sole persistent identity. | + +## 12.4 Result envelope + +{ +"schemaVersion": "1.0", +"transactionId": "A4D0…", +"status": "committed \| partial \| rolledBack \| failed \| noOp", +"actor": "cli", +"requestedAt": "2026-06-21T12:00:00Z", +"topologyGeneration": 419, +"targets": \[{ +"displayId": "disp_01J…", +"alias": "DeskLeft", +"identityConfidence": 0.98, +"operations": \[{ +"field": "lifecycle.connected", +"requested": false, +"observed": false, +"verification": "verified", +"provider": "experimentalLifecycle.v1", +"warnings": \[\] +}\] +}\], +"recovery": {"checkpointId": "cp\_…", "available": true}, +"errors": \[\] +} + +## 12.5 App Intents baseline + +| **Intent** | **Parameters** | **Result** | +|--------------------|-----------------------------------------|-----------------------------------------------| +| Apply Scene | Scene; optional dry run | Applied / warnings / failed. | +| Set Brightness | Display/group; value; relative/absolute | Provider and verified/unverified state. | +| Set Volume / Mute | Display/group; value/action | Per-target outcome. | +| Switch Input | Display; named input | Sent/verified/unverified. | +| Set Favorite Mode | Display; favorite | Applied or unavailable with alternatives. | +| Black Out | Display/group; on/off/toggle | Current overlay state. | +| Logical Disconnect | Display; confirmation policy | May require foreground confirmation. | +| Reconnect | Display | Verified active or endpoint-specific failure. | +| Reconnect All | None | Per-target recovery result. | +| Get Display State | Display selector | Structured display summary. | + +## 12.6 Local HTTP and event API + +Core 1.x may expose a loopback-only HTTP API using the same command/result schemas. It is disabled by default, requires a randomly generated bearer token stored in Keychain, supports token rotation, binds to 127.0.0.1/::1 unless the user explicitly enables LAN access, and never permits last-safe-display disconnect without a preapproved safety policy. Server-sent events or WebSocket events may publish normalized state and transaction updates with sequence numbers and schema versions. + +## 12.7 API stability + +- Semantic version the external schema separately from the application. + +- Additive fields are permitted in minor versions; clients must ignore unknown fields. + +- Breaking field/semantic changes require a major schema version and migration guide. + +- Document deprecation at least two minor releases before removal where security does not require immediate change. + +- Golden fixtures for the prior two minor versions run in CI. + +# 13. Data, configuration, and migration + +## 13.1 Primary entities + +| **Entity** | **Key fields** | **Notes** | +|----------------------|-------------------------------------------------------------------------------|-------------------------------------------------| +| DisplayRecord | stableID, alias, tags, fingerprints, route history, pairing, lastSeen | Persists across active/offline observations. | +| DisplayObservation | CG IDs/UUID, IO path, active/bounds/mode, mirror/main, HDR/profile, timestamp | Immutable snapshot tied to topology generation. | +| CapabilitySnapshot | capability, status, reasons, provider, verification, generation | Invalidated by route/OS/provider changes. | +| Scene | ID, name, member selectors, required/optional flags, desired fields, policy | Fields are optional and independently applied. | +| Rule | event, conditions, priority, cooldown, scene/actions, enabled | Deterministic conflict handling. | +| ManagedOfflineRecord | displayID, actor, reason, time, provider, persistence policy | Distinct from system absence. | +| Checkpoint | topology, modes, roles, managed-offline set, transaction metadata | Minimal and rescue-readable. | +| Transaction | ID, actor, plan, stages, results, checkpoint, timestamps | Append-only activity record. | +| ProviderHealth | provider, environment key, status, failures, breaker, last probe | Controls capability decisions. | + +## 13.2 Scene document example + +{ +"schemaVersion": "1.0", +"id": "scene_studio", +"name": "Studio", +"members": \[ +{"selector": "alias:Center", "required": true}, +{"selector": "alias:Left", "required": true}, +{"selector": "builtin", "required": false} +\], +"desired": { +"Center": { +"connected": true, +"main": true, +"position": {"x": 0, "y": 0}, +"mode": {"width": 3008, "height": 1692, "hiDPI": true, "refreshHz": 60}, +"brightness": 62, +"profile": "Studio SDR" +}, +"Left": { +"connected": true, +"position": {"relativeTo": "Center", "edge": "left", "gap": 0}, +"rotation": 90, +"brightness": 54 +}, +"builtin": {"connected": false} +}, +"policy": { +"missingOptional": "continue", +"unsupportedField": "warn", +"windowPlacement": "unchanged", +"rollbackOnRequiredFailure": true +} +} + +## 13.3 Configuration principles + +- Use versioned, human-reviewable formats for scenes, rules, display aliases, and export bundles. + +- Use atomic replace, fsync-equivalent durability where practical, and rotating backups for settings and checkpoints. + +- Store secrets only in Keychain and reference them by opaque ID. + +- Keep display serials and network identifiers out of default exports; use salted hashes when correlation is needed. + +- Do not persist transient display IDs as the sole selector. + +- Validate imported documents before any write and show a semantic diff. + +## 13.4 Migration strategy + +1\. Read the current schema version and create an immutable backup. + +2\. Run pure, deterministic migration steps in order; each step produces a validation report. + +3\. Resolve deprecated selector forms to stable display records where confidence is sufficient. + +4\. Quarantine unknown experimental fields rather than silently discarding them. + +5\. Write the new document atomically and retain the previous version for rollback. + +6\. On failure, start with Core defaults and present an import/recovery screen; do not apply automatic display policies. + +## 13.5 Export profiles + +| **Profile** | **Included** | **Excluded/default redaction** | +|-------------------|-----------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| Portable settings | Preferences, scenes, rules, aliases/tags, safe feature flags. | Secrets, raw serials, logs, crash data. | +| Support bundle | Versions, capability matrix, redacted topology timeline, logs, transaction state. | Tokens, credentials, usernames, window titles, captures. | +| Developer bundle | Support bundle plus raw provider diagnostics with explicit preview. | Still excludes secrets; sensitive identifiers require separate consent. | +| Recovery snapshot | Checkpoint and lifecycle policy needed by rescue utility. | No general settings or credentials. | + +# 14. Security, privacy, and permissions + +## 14.1 Threat model summary + +| **Threat** | **Asset / consequence** | **Control** | +|------------------------------------|--------------------------------------------|-----------------------------------------------------------------------------------------| +| Malicious local automation request | Disconnect/alter user's displays. | Authenticated gateway, loopback default, safety checks, rate limits, audit. | +| Compromised provider/dependency | Arbitrary code or unstable display writes. | Minimal dependencies, sandbox where feasible, SBOM, review, signing, isolation. | +| Leaked display/network identifiers | Device/user fingerprinting. | Local-only storage, hash/redact exports, no analytics by default. | +| Capture without clear consent | Screen content exposure. | On-demand permission, active indicator, session controls, stop on lock/logout. | +| Update incompatibility | Black screen/startup loop. | Signed updates, OS compatibility flags, safe-mode migration, experimental defaults off. | +| Corrupt settings/import | Unsafe auto-apply. | Schema validation, atomic writes, backup, quarantine, recovery-first startup. | +| Stolen API token | Local/remote control. | Keychain, scoped/rotatable token, LAN off by default, audit and revoke. | +| Supply-chain tampering | Malicious release. | Protected branches, reproducible metadata, checksums, notarization, provenance/SBOM. | + +## 14.2 Permission model + +| **Permission / capability** | **When requested** | **Features affected** | **Behavior when denied** | +|-----------------------------|-----------------------------------------------------------------------------------|----------------------------------------|----------------------------------------------------------| +| Screen Recording | Only when starting PIP/zoom/screenshot/stream. | Capture features. | Topology, controls, scenes, lifecycle remain functional. | +| Accessibility | Only for optional window placement or advanced key routing if required. | Window movement / selected automation. | No window movement; display features remain functional. | +| Automation / App Intents | When user enables Shortcuts/integrations. | External workflows. | In-app and CLI remain available according to platform. | +| Local Network | Only for explicitly enabled network providers or LAN API. | TV/receiver plugins, LAN control. | Providers unavailable with reason. | +| Login item / helper | When enabling startup policies or recovery service. | Persistent policy, early recovery. | No auto-apply; manual app functions remain. | +| Administrator privilege | Avoid in Core; request only if a specific Labs override cannot operate otherwise. | System overrides. | Feature remains unavailable; no blanket privilege. | + +## 14.3 Privacy defaults + +- No analytics, crash upload, network discovery, HTTP listener, screen capture, or LAN access on fresh install. + +- Opt-in diagnostics show exactly what will be sent and allow local save instead of upload. + +- Display serials, EDID, topology, connected-device names, and network addresses are treated as potentially identifying. + +- Logs record pseudonymous stable IDs; raw identifiers are available only in an advanced local diagnostic view. + +- The application does not collect screen contents for ordinary display management. + +- Credentials live in Keychain and are never included in exported settings or support bundles. + +## 14.4 Secure development requirements + +- Threat model and security review for lifecycle provider, rescue IPC, update channel, and local API before 1.0. + +- Dependency pinning, automated vulnerability/license scanning, secret scanning, signed commits/tags where practical, protected release workflow. + +- Hardened runtime and least entitlements for each binary, balanced against documented compatibility needs. + +- Fuzz/schema tests for imports and API payloads; strict bounds and timeouts for DDC/network protocol parsing. + +- Security advisory process, private reporting channel, supported-version policy, and coordinated disclosure. + +| | **Experimental API warning** Use of undocumented system interfaces increases compatibility and review risk. Such code must be narrowly isolated, auditable, kill-switchable, and excluded from the public-API-only build. It must not bypass macOS security controls. | +|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + +# 15. Quality, test strategy, and hardware matrix + +## 15.1 Quality strategy + +Display management cannot be validated by unit tests alone. The test program combines deterministic model/state-machine tests, provider contract tests, simulated OS event replay, integration tests on real hardware, fault injection, sleep/wake/reboot endurance, accessibility testing, and release-ring evidence. Every high-risk lifecycle change must be tested against recovery, not only success. + +## 15.2 Test layers + +| **Layer** | **Scope** | **Examples** | +|------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------| +| Unit | Pure identity, capability, planner, rules, schema, range mapping. | Ambiguity, scene diff, ordering, migrations, DDC normalization. | +| State-machine/model | Lifecycle and transaction invariants under generated events/failures. | No last-safe loss; rollback; circuit breaker; idempotency. | +| Provider contract | Mock and real provider behavior against typed semantics. | Timeouts, cancellation, unsupported, partial, read-back. | +| Integration simulation | Recorded Core Graphics/IO events and virtual fixtures. | Wake storms, reorder, route changes, mode invalidation. | +| Real hardware | Certified Mac/display/dock/KVM matrix. | Disconnect, DDC, modes, HDR, identical displays, sleep/wake. | +| Endurance | Repeated connect/disconnect, wake, reboot, scene cycles. | 1,000-cycle lab runs; memory/handle leaks; state drift. | +| Fault injection | Crash/kill/hang/corrupt storage at every transaction stage. | Recovery objective and startup bypass. | +| Accessibility/UX | VoiceOver, keyboard, reduced motion, pseudo-localization. | Complete disconnect/reconnect and recovery workflows. | +| Security/privacy | Threat tests, endpoint auth, secret/redaction scans. | Unauthorized HTTP, bundle redaction, capture session lifecycle. | + +## 15.3 Critical scenarios + +| **ID** | **Scenario** | **Fixture** | **Expected result** | +|--------|--------------------------------------|----------------------------------------------------|------------------------------------------------------------------------------------| +| T-001 | First logical disconnect | Apple Silicon laptop + one external; built-in open | Disconnect external; countdown; verify built-in remains; reconnect. | +| T-002 | Disconnect built-in safely | Laptop + verified external | Move recovery UI, disconnect built-in, reconnect on external loss. | +| T-003 | Block last safe display | Single active local display | Attempt logical disconnect; default path is blocked. | +| T-004 | Disconnect current main | Two active displays | Main role/recovery UI moves before target is removed. | +| T-005 | Multi-target scene | Three displays | Connect destination, apply layout/modes, disconnect retiring target in safe order. | +| T-006 | Failure after checkpoint | Injected provider error | Automatic rollback restores usable surface. | +| T-007 | App kill at every state | Fault injection | Next launch detects unclean marker and enters recovery-first mode. | +| T-008 | Provider hang | Injected non-returning call | Deadline/watchdog fires; recovery command preempts queue. | +| T-009 | Wake reconnect storm | Scripted event burst | One stabilized reconciliation plan; no oscillation. | +| T-010 | OS reconnects managed-offline target | Wake/reconnect fixture | Cooldown prevents loop; policy conflict is logged. | +| T-011 | Identical monitors swap ports | Two same-model displays | No destructive action until confidence/pairing is sufficient. | +| T-012 | DDC direct vs hub | Same monitor, two routes | Capability changes per route; identity remains stable. | +| T-013 | DDC write unverified | Monitor without read-back | Result is unverified, never verified success. | +| T-014 | Rapid brightness key repeat | 100 events | Writes coalesce and converge to final value within rate limit. | +| T-015 | Mode list changes | Reconnect with different available modes | Favorite resolves by properties or returns closest alternatives. | +| T-016 | Sidecar reconnect | Sidecar endpoint | Per-target unsupported/delegated result; no blanket success. | +| T-017 | Safe-mode startup | Startup modifier / rescue utility | No auto-rules, filters, virtual recreation, or experimental provider calls. | +| T-018 | Persistent policy after clean reboot | Approved display policy | Runs only after health and stable topology; bypass key suppresses. | +| T-019 | Persistent policy after crash | Unclean marker | Policy does not run; recovery summary appears. | +| T-020 | Normal quit | Managed-offline display | Default quit reconnects; failures are reported. | +| T-021 | Filter/Black Out recovery | Overlay active | Recovery hotkey bypasses/removes overlay and remains usable. | +| T-022 | Capture permission denied | PIP requested | Capture fails contextually; topology/control features remain usable. | +| T-023 | HTTP unauthorized | Local server enabled | Missing/invalid token rejected; destructive action cannot run. | +| T-024 | Import malformed scene | Invalid schema/selector | No partial write; precise errors and diff. | +| T-025 | New major macOS build | Uncertified OS fixture | Experimental persistent providers default off. | +| T-026 | Public-API-only build | Experimental modules absent | App compiles, starts, and passes all applicable Core tests. | +| T-027 | VoiceOver disconnect/reconnect | Keyboard-only + VoiceOver | Complete workflow and recovery without pointer or visual color cue. | +| T-028 | Pseudo-localization | 40% expanded strings | No clipping or inaccessible controls. | +| T-029 | Support bundle redaction | Fixture with usernames/serials/tokens | Export contains no raw sensitive fields. | +| T-030 | Route disappears mid-scene | Unplug dock during apply | Abort, reconcile, recover, and log degraded outcome. | + +## 15.4 Hardware lab matrix + +| **Class** | **Representative hardware** | **Coverage** | **Cadence** | +|-----------------------------|----------------------------------------------------------|-------------------------------------------------------|-------------------------| +| Apple Silicon baseline | MacBook Air/Pro M1-M4 class | Built-in + direct USB-C/DP external | Every stable release | +| Apple Silicon multi-display | Mac mini/Studio and Pro/Max/Ultra class | 2-6 displays, mixed direct/dock | Every stable release | +| HDMI path | Mac with built-in HDMI | HDMI monitor/TV; DDC where available | Every stable release | +| Thunderbolt dock | At least two mainstream dock chipsets | Dual external displays; route changes | Every stable release | +| USB-C dock / hub | At least two non-Thunderbolt hubs | DDC blocked/partial fixtures | Every stable release | +| KVM | At least one DDC-pass and one DDC-blocking route | Identity and capability changes | Beta/stable | +| Identical monitors | Two identical serial-capable and serial-missing fixtures | Identity confidence and pairing | Every stable release | +| HDR/XDR | Apple XDR-capable built-in plus external HDR | Core read; Labs writes | Labs-certified releases | +| TV/receiver | HDMI TV and optional network receiver | Input, range, Night Shift-like/filter tests | Core 1.x | +| Sidecar/AirPlay | Supported iPad / receiver | Endpoint-specific lifecycle results | Beta/stable | +| Headless | No physical display or headless adapter | Safe startup/recovery; Labs virtual | Labs | +| Intel regression | One Intel laptop and one Intel desktop where available | Core public controls; lifecycle disabled/experimental | Best-effort | + +## 15.5 OS matrix + +| **OS** | **Core public APIs** | **Lifecycle provider** | **Labs** | **Release policy** | +|------------------|--------------------------------|-------------------------------------------------------|---------------------------------|--------------------------------------------------| +| macOS 13 Ventura | Full targeted Core subset. | Certify selected Apple Silicon combinations. | Limited; provider-specific. | Regression on every stable release. | +| macOS 14 Sonoma | Full targeted Core subset. | Certify selected Apple Silicon combinations. | Provider-specific. | Regression on every stable release. | +| macOS 15 Sequoia | Full targeted Core subset. | Primary certification. | Provider-specific. | Regression on every stable release. | +| macOS 26 Tahoe | Current primary certification. | Enable only after explicit evidence per build family. | Off by default until certified. | Fast compatibility response. | +| Future major | Public-API discovery mode. | Persistent/experimental auto-enable off. | Off by default. | Preview ring first; compatibility flag required. | + +## 15.6 Release defect policy + +| **Severity** | **Definition** | **Release rule** | +|--------------|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +| P0 | Unrecoverable/no-visible-display state, data/security compromise, startup loop on supported default. | Blocks every release; revoke/kill-switch if discovered. | +| P1 | Major topology corruption, repeated crash/wake loop, wrong-display destructive action, recovery failure with workaround. | Blocks stable; must have owner and verified fix. | +| P2 | Incorrect control/mode, partial scene failure, degraded route diagnostics. | May ship with documented workaround and bounded impact. | +| P3 | Cosmetic, copy, minor performance, noncritical compatibility issue. | Triaged normally. | + +## 15.7 Evidence required for lifecycle certification + +- Mac model/chip, OS build, display model/firmware, route, adapter/dock/KVM, lid/power state, and mirror/main topology recorded. + +- Successful first-use, repeat, wake, reboot, normal quit, crash, provider hang, route loss, and Reconnect All tests. + +- At least one accessibility recovery run without pointer/visual dependency. + +- No open P0/P1 defect and no unexplained topology drift after endurance run. + +- Provider version and kill-switch entry published in compatibility data. + +# 16. Success metrics and release gates + +## 16.1 North-star outcome + +A multi-display workspace reaches and stays in the user's intended state, and any failed high-risk transition returns to a usable state without physical intervention. + +## 16.2 Product and reliability metrics + +| **Metric** | **Definition** | **Core 1.0 target** | +|---------------------------------|-----------------------------------------------------------------------------------|------------------------------------------------------------------| +| Verified lifecycle success | Logical disconnect/reconnect transactions committed with verified postconditions. | \>=99.5% in certified lab combinations; publish per environment. | +| Automatic recovery success | Failed lifecycle tests restored to usable safe surface within objective. | 100% in release-gate fault suite. | +| Wrong-target destructive action | Lifecycle operation applied to unintended display. | Zero known occurrences; P0. | +| Topology convergence | Time from last OS event to stable registry generation. | p95 \<=2 seconds in certified normal scenarios. | +| Scene idempotency | Second apply produces no unnecessary topology writes. | \>=99% of stable-scene fixtures. | +| DDC truthfulness | UI/API verification label matches read-back capability/outcome. | 100% contract tests. | +| Crash-free sessions | Sessions without unexpected termination. | \>=99.9% beta evidence or equivalent test confidence. | +| Recovery discoverability | Users can identify and invoke Reconnect All in study. | \>=95% after onboarding; \>=90% without reminder. | +| Disabled-feature explanation | Unavailable controls with specific reason/remediation. | \>=95% across hardware matrix. | +| Accessibility completion | Core workflow completion via keyboard/VoiceOver. | 100% scripted/manual release checklist. | + +## 16.3 Go/no-go gates for Core 1.0 + +1\. All Must / Core 1.0 requirements are implemented or explicitly removed through an approved scope change. + +2\. No open P0 or P1 defect in certified configurations. + +3\. Reconnect All, safe mode, rescue utility, checkpoint rollback, and unclean-startup recovery pass the full fault suite. + +4\. Public-API-only build compiles and passes applicable Core tests. + +5\. Signed/notarized artifacts, update path, checksums, SBOM, privacy/security documentation, and source tag are ready. + +6\. Hardware/OS compatibility table identifies certified, experimental, and unsupported combinations. + +7\. VoiceOver/keyboard, pseudo-localization, and support-bundle redaction release checks pass. + +8\. Legal review of project name, license, dependencies, contribution terms, and experimental distribution language is complete. + +## 16.4 Telemetry policy for measuring targets + +Targets should be measured primarily through the hardware lab and opt-in beta diagnostics. Any telemetry must be disabled by default, documented in source, previewable, and designed without raw display serials, screen content, usernames, or network credentials. A local metrics dashboard should work even when the user never shares data. + +# 17. Distribution and update strategy + +## 17.1 Baseline distribution + +The full build should be distributed directly as a Developer ID signed and notarized application. The website/repository should publish checksums, source tag, SBOM, release notes, compatibility changes, and recovery instructions. The Mac App Store is not the baseline because advanced lifecycle/system features may rely on behavior incompatible with store review or sandbox constraints; this must be validated rather than assumed. + +## 17.2 Artifact set + +| **Artifact** | **Purpose** | **Release requirement** | +|------------------------------|-----------------------------------------------------------------------|----------------------------------------------------------------------------| +| OpenDisplay.app | Menu-bar and settings application. | Signed, notarized, hardened/runtime reviewed. | +| OpenDisplay Rescue.app / CLI | Independent reconnect, safe mode, policy disable, checkpoint restore. | Minimal dependencies; signed/notarized; included and separately invocable. | +| opendisplay CLI | Automation and diagnostics. | Stable schema; codesigned; optional symlink/install helper. | +| Public-API-only build | Reduced-risk/community distribution flavor. | Same source tag; explicit capability differences. | +| Source archive/tag | Reproducible source for release. | Signed tag, dependency locks, license notices. | +| SBOM/provenance/checksums | Supply-chain verification. | Published with every stable release. | + +## 17.3 Update behavior + +- Verify signature and update manifest; never replace the rescue path without a successful staged health check. + +- Before migration, write backup and checkpoint; after update, first launch suppresses persistent experimental policy until compatibility and health pass. + +- On a newly detected major macOS build, experimental persistent providers default off unless explicitly certified. + +- Support rollback to the prior application version and configuration schema where practical. + +- Release notes call out display-lifecycle provider changes prominently and include recovery instructions. + +## 17.4 Compatibility kill switches + +The app should ship a signed compatibility dataset keyed by OS build family, architecture, provider version, and known route/display constraints. A remote update may disable a dangerous provider only if the user opted into compatibility updates; the payload must be transparent, signed, cached, and auditable. Core offline operation remains available. A kill switch may disable auto-apply but should preserve manual Reconnect All/recovery where safe. + +# 18. Open-source governance and licensing + +## 18.1 License recommendation + +The user's stated goal is to keep the product open source. The working recommendation is GPL-3.0-or-later for the application, lifecycle coordinator, and recovery stack so distributed derivatives of those components remain open. A separately packaged provider/automation SDK may use Apache-2.0 or MIT to encourage integrations, provided the boundary does not undermine the project's goals. This is a product recommendation, not legal advice; dependency compatibility, contributor expectations, app distribution, and any use of private APIs require counsel and community review. + +## 18.2 Governance baseline + +| **Mechanism** | **Baseline** | +|-----------------------|---------------------------------------------------------------------------------------------------------------------------------| +| Maintainer council | At least two maintainers for release/security decisions; documented succession and inactive-maintainer policy. | +| RFC process | Required for provider interfaces, lifecycle invariants, schema/API breaking changes, telemetry, licensing, and Labs graduation. | +| DCO or CLA | Choose before external contributions; document rationale and contribution provenance expectations. | +| Code of conduct | Adopt and enforce with named response team. | +| Security policy | Private reporting channel, supported versions, severity policy, coordinated disclosure. | +| Release policy | Protected tags/branches, two-person review for recovery-critical code, signed artifacts and provenance. | +| Compatibility reports | Template captures Mac/OS/display/route and redacts identifying data. | +| Decision log | Public ADRs/RFC outcomes; link code changes to requirements and tests. | + +## 18.3 Repository structure + +/ +Apps/OpenDisplay +Apps/OpenDisplayRescue +Tools/opendisplay +Packages/DisplayDomain +Packages/DisplayRegistry +Packages/TopologyCoordinator +Packages/SceneEngine +Packages/AutomationSchema +Providers/CoreGraphicsProvider +Providers/DDCProvider +Providers/NativeControlProvider +Providers/CaptureProvider +Providers/ExperimentalLifecycleProvider \# optional target +Providers/VirtualDisplayProvider \# Labs target +Docs/Architecture +Docs/Recovery +Docs/Compatibility +Docs/RFCs +Tests/Fixtures +Tests/HardwareLab + +## 18.4 Contribution gates + +- Original-work/provenance attestation and license scan. + +- No proprietary assets, copied interface text, or reverse-engineered code of unclear legality. + +- Unit/state-machine tests for logic; hardware evidence for provider changes. + +- Threat/recovery review for any lifecycle, startup, IPC, capture, update, or network change. + +- Public-API-only build remains green unless an RFC intentionally changes scope. + +- Documentation and compatibility data updated with behavior changes. + +# 19. Delivery roadmap + +## 19.1 Milestones + +| **Milestone** | **Indicative duration** | **Exit outcome** | +|-----------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| M0: Technical spike | 4-6 weeks | Enumerate topology; prove safe logical connect/disconnect on supported Apple Silicon; DDC probe; recovery hotkey; public/private API boundary memo. | +| M1: Developer preview | 8-10 weeks | Registry, identities, basic menu-bar UI, connect/disconnect transaction, layout/mode controls, CLI, diagnostics. | +| M2: Alpha | 8 weeks | Scenes, groups, DDC/software controls, wake reconciliation, rescue utility, signed/notarized builds, migration tests. | +| M3: Beta / Core 1.0 | 8-12 weeks | App Intents, stable APIs, accessibility pass, hardware lab matrix, localization foundation, contributor docs. | +| M4: Core 1.x | Ongoing | Color/profile automation, sync, ScreenCaptureKit zoom/PIP, richer network controls. | +| Labs | Parallel, gated | HiDPI overrides, EDID/system overrides, HDR/XDR upscaling, virtual displays, streaming. Never block Core stability. | + +## 19.2 Technical spike deliverables + +1\. Public/private API boundary memo with prototypes for enumeration, modes/layout, logical connect/disconnect, and virtual endpoints. + +2\. Lifecycle provider protocol plus a simulator provider that exercises every result and fault state. + +3\. Recovery proof: independent Reconnect All, startup bypass, checkpoint format, and kill-at-every-stage tests. + +4\. Identity proof for identical monitors, port changes, and transient display IDs. + +5\. DDC route probe on direct, dock, and KVM fixtures with verified/unverified semantics. + +6\. Signed/notarized prototype and entitlement/distribution assessment. + +7\. Initial hardware/OS certification table and explicit unsupported cases. + +## 19.3 Suggested workstreams + +| **Workstream** | **First deliverables** | **Dependencies** | +|--------------------|----------------------------------------------------------------------------------|---------------------------| +| Domain/state | Display models, identity, capability, transaction state machine, scene schema. | None; starts first. | +| Public platform | Core Graphics provider, event normalization, mode/layout operations. | Domain/state. | +| Lifecycle/recovery | Experimental provider spike, safety engine, checkpoints, rescue utility. | Domain + public platform. | +| Controls | Native/DDC/software providers, rate limiting, keyboard/OSD. | Registry/capability. | +| Product/design | Menu-bar, topology workspace, onboarding, risk language, accessibility. | Domain snapshots. | +| Automation | CLI schema, App Intents, dry run, typed results. | Coordinator/planner. | +| Quality/lab | Simulator, event replay, hardware fixtures, fault injection, compatibility data. | Begins with domain. | +| Security/release | Threat model, signing/notarization, SBOM, update and governance. | Cross-cutting. | + +## 19.4 Staffing assumption + +A credible Core 1.0 requires at least one senior macOS/platform engineer, one additional Swift engineer, product/design capacity with accessibility expertise, and dedicated QA/hardware-lab ownership. Security/release/legal support can be fractional but must be scheduled before architecture lock and public beta. A smaller volunteer team should reduce scope rather than compress recovery and test work. + +# 20. Risk register + +| **ID** | **Severity** | **Risk** | **Mitigation** | +|--------|--------------|---------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| R-01 | Critical | Logical disconnect leaves the user with no visible display. | Preflight safe-surface rule, countdown confirmation, atomic checkpoint, auto rollback, Reconnect All hotkey and rescue utility. | +| R-02 | Critical | Private or undocumented macOS behavior breaks after an OS update. | Provider abstraction, runtime probing, feature flags, staged releases, public-API-only fallback. | +| R-03 | High | Display identity mismatch applies a scene to the wrong identical monitor. | Confidence score, topology context, user-confirmed aliases, destructive-action threshold, visible dry run. | +| R-04 | High | Wake/reconnect loops cause flicker or WindowServer instability. | Debounced topology stabilizer, single-owner transaction queue, bounded retries, circuit breaker. | +| R-05 | High | DDC commands fail through a hub or KVM and appear to succeed. | Read-back verification where available, route-specific capability cache, explicit unverified status. | +| R-06 | High | Rapid HDR/XDR or brightness writes produce washed-out or unsafe output. | Rate limiting, coalescing, safe ranges, profile rollback, confirmation for high-risk modes. | +| R-07 | High | App crash while displays are managed offline prevents recovery. | Independent launch agent/rescue binary, startup health marker, restore-on-unclean-exit policy. | +| R-08 | Medium | Scene application moves windows unexpectedly or disrupts presentations. | Preview diff, window-move opt-in, per-app exclusions, transactional ordering. | +| R-09 | Medium | Open-source contributors accidentally introduce copied assets or code. | DCO/CLA policy, clean-room contribution guide, provenance review, trademark and license checks. | +| R-10 | Medium | Automation endpoint is abused by another local process. | Loopback only by default, random bearer token, opt-in server, origin restrictions, audit log. | +| R-11 | Medium | Configuration migration corrupts settings. | Versioned schema, atomic writes, backups, import validation, downgrade-safe export. | +| R-12 | Medium | Capability labels promise physical unplug or additional GPU pipelines. | Precise UX wording; never claim hardware link removal or increased display-count limits. | + +## 20.1 Risk review cadence + +- Review P0/P1 risks at every lifecycle/provider change and before each release ring promotion. + +- Link each mitigation to an owner, test, compatibility flag, and recovery action. + +- Treat new macOS major versions, new experimental provider mechanisms, and update-system changes as automatic risk reviews. + +- Public issue reports should be triaged into reproducible environments rather than counted as prevalence. + +# 21. Decision log and open questions + +## 21.1 Decision log + +| **ID** | **Decision** | **Status** | **Rationale** | +|--------|-------------------------------------------------------------|------------|------------------------------------------------------------------------------------------------| +| D-001 | Use a Core/Labs product split. | Accepted | Prevents experimental system mechanisms from becoming a dependency of normal startup/recovery. | +| D-002 | Apple Silicon is the certified lifecycle baseline. | Accepted | Public evidence and failure reports indicate materially different Intel behavior. | +| D-003 | Logical disconnect is a transaction, not a direct command. | Accepted | Required for preflight, checkpoint, verification, rollback, and audit. | +| D-004 | Ship a standalone rescue utility. | Accepted | The main app/UI may be unavailable or displayed on the target being removed. | +| D-005 | Normal quit reconnects managed-offline displays by default. | Accepted | Conservative recovery expectation; persistence remains explicit. | +| D-006 | No analytics by default. | Accepted | Consistent with open-source trust and display/capture sensitivity. | +| D-007 | Direct signed/notarized distribution is the baseline. | Accepted | Advanced lifecycle capabilities may not be compatible with App Store constraints. | +| D-008 | Maintain a public-API-only build path. | Accepted | Reduces platform/legal risk and preserves a stable subset. | +| D-009 | Use stable internal IDs and scored fingerprint evidence. | Accepted | Transient display IDs and identical hardware make single-key identity unsafe. | +| D-010 | Provider call success is not product success. | Accepted | All applicable operations require observation/read-back or explicit unverified status. | +| D-011 | Working license direction is GPL-3.0-or-later for the app. | Proposed | Strong copyleft supports the user's open-source goal; counsel/community approval required. | +| D-012 | Working project name is OpenDisplay. | Proposed | Useful internal label only; trademark/package clearance required. | + +## 21.2 Open questions + +| **ID** | **Question** | **Resolution method** | **Owner** | +|--------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------|-------------------------| +| Q-001 | Which exact macOS versions and Mac models can be certified for logical disconnect in Core 1.0? | Technical spike + hardware lab evidence | Architecture lead | +| Q-002 | What undocumented interfaces, entitlements, or signing constraints are required by each lifecycle/virtual provider? | Legal/technical boundary memo | Platform lead + counsel | +| Q-003 | Should the rescue component be a separate app, launch agent, login item, privileged helper, or combination? | Threat model and failure injection | Security + platform | +| Q-004 | What is the safest default recovery shortcut with minimal conflict across layouts/accessibility tools? | User study and system conflict scan | Design/accessibility | +| Q-005 | Should strong copyleft apply to all modules, or should providers/SDK have separate licenses? | Community and legal review | Maintainer council | +| Q-006 | What compatibility data, if any, may be collected opt-in without exposing display serials or personal topology? | Privacy design | Security/privacy | +| Q-007 | Can a public-API-only flavor share one bundle or must it be a separate distribution/package ID? | Build and signing spike | Release engineering | +| Q-008 | What is the minimum supported Intel scope, and how prominently should unavailable lifecycle behavior be shown? | Regression evidence | Product + QA | +| Q-009 | Which scene fields are atomic requirements versus best-effort controls? | Planner RFC | Product + architecture | +| Q-010 | How should window placement integrate without requiring Accessibility permission for users who do not need it? | UX/API spike | Design + platform | +| Q-011 | Which network display vendors are maintainable as first-party providers versus community plugins? | Provider SDK RFC | Maintainers | +| Q-012 | What criteria graduate a Labs feature to Core? | Governance RFC | Maintainer council | + +## 21.3 Decisions required before architecture lock + +- Certifiable lifecycle provider scope by OS/architecture and whether it can ship in the main process. + +- Rescue process topology, IPC authentication, startup order, and login-item behavior. + +- Final license, contributor agreement/DCO, project name, bundle identifiers, and trademark position. + +- Scene atomicity policy and which control failures are warnings versus rollback triggers. + +- Public-API-only build packaging and shared source boundaries. + +## 21.4 Decisions required before public beta + +- Default recovery hotkey, onboarding test, and accessibility evidence. + +- Compatibility dataset publication format and emergency kill-switch policy. + +- Opt-in diagnostics data model and support upload mechanism, if any. + +- Supported Intel scope and Labs graduation criteria. + +- Update framework, rollback behavior, and minimum supported-version policy. + +# 22. Sources and research notes + +Sources were accessed on 21 June 2026. Product and issue sources are used to identify publicly described outcomes and representative failure modes. They do not authorize copying proprietary implementation or establish defect prevalence. Apple sources define public platform and distribution guidance. Adjacent open-source projects are references only; code reuse requires a separate license and provenance review. + +| **ID** | **Source** | **Research use** | **Link** | +|--------|------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| S01 | BetterDisplay product website | Official feature overview and positioning | [Open](https://betterdisplay.pro/) | +| S02 | BetterDisplay GitHub repository | Compatibility notes, release history, feature documentation index | [Open](https://github.com/waydabber/BetterDisplay) | +| S03 | BetterDisplay free and Pro feature matrix | Detailed public feature inventory | [Open](https://github.com/waydabber/BetterDisplay/wiki/List-of-free-and-Pro-features) | +| S04 | BetterDisplay integration features and CLI | CLI, URL, HTTP, notifications, selectors, actions, and display addressing | [Open](https://github.com/waydabber/BetterDisplay/wiki/Integration-features%2C-CLI) | +| S05 | Fully scalable HiDPI desktop | Flexible scaling behavior and compatibility | [Open](https://github.com/waydabber/BetterDisplay/wiki/Fully-scalable-HiDPI-desktop) | +| S06 | XDR and HDR brightness upscaling | HDR/XDR brightness behavior and presets | [Open](https://github.com/waydabber/BetterDisplay/wiki/XDR-and-HDR-brightness-upscaling) | +| S07 | Safe mode, app reset, and removal | Recovery paths and emergency startup | [Open](https://github.com/waydabber/BetterDisplay/wiki/Safe-mode%2C-app-reset%2C-app-removal) | +| S08 | Export and import app settings | Configuration portability | [Open](https://github.com/waydabber/BetterDisplay/wiki/Export-and-import-app-settings) | +| S09 | Eye care: prevent PWM and/or temporal dithering | Accessibility and eye-care controls | [Open](https://github.com/waydabber/BetterDisplay/wiki/Eye-care%3A-prevent-PWM-and-or-temporal-dithering) | +| S10 | MonitorControl | Open-source DDC, software control, keyboard, OSD, and sync reference | [Open](https://github.com/MonitorControl/MonitorControl) | +| S11 | m1ddc | Open-source Apple Silicon DDC control reference | [Open](https://github.com/waydabber/m1ddc) | +| S12 | displayplacer | Open-source display layout and mode automation reference | [Open](https://github.com/jakehilborn/displayplacer) | +| S13 | InternalDisplayOff | Public implementation report for logical display enable/disable and recovery concepts; license must be verified before reuse | [Open](https://github.com/RonaldPark89/InternalDisplayOff) | +| S14 | Apple Quartz Display Services | Public Core Graphics APIs for display enumeration and configuration | [Open](https://developer.apple.com/documentation/coregraphics/quartz-display-services) | +| S15 | Apple ScreenCaptureKit | Public capture framework for screen preview, zoom, and picture-in-picture features | [Open](https://developer.apple.com/documentation/screencapturekit) | +| S16 | Apple App Review Guidelines | Public API and copycat restrictions; distribution implications | [Open](https://developer.apple.com/app-store/review/guidelines/) | +| S17 | Apple: Notarizing macOS software before distribution | Notarization requirements for direct distribution | [Open](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) | +| S18 | Apple: Distributing your app for beta testing and releases | Distribution, signing, and release guidance | [Open](https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases) | +| S19 | BetterDisplay issue \#1396 | User report: logical disconnect safety and last-display concerns | [Open](https://github.com/waydabber/BetterDisplay/issues/1396) | +| S20 | BetterDisplay issue \#1413 | User report: macOS reconnecting displays after sleep and main-display protection | [Open](https://github.com/waydabber/BetterDisplay/issues/1413) | +| S21 | BetterDisplay issue \#1809 | User report: Intel blank screens after disconnect and wake recovery | [Open](https://github.com/waydabber/BetterDisplay/issues/1809) | +| S22 | BetterDisplay issue \#5227 | User report: persistent/aggressive disconnect behavior across reboot | [Open](https://github.com/waydabber/BetterDisplay/issues/5227) | +| S23 | BetterDisplay issue \#4909 | User report: DDC succeeds directly but fails through a hub | [Open](https://github.com/waydabber/BetterDisplay/issues/4909) | +| S24 | BetterDisplay issue \#2046 | User report: mode availability changes after reconnect | [Open](https://github.com/waydabber/BetterDisplay/issues/2046) | +| S25 | BetterDisplay issue \#2362 | User report: crash or instability after wake | [Open](https://github.com/waydabber/BetterDisplay/issues/2362) | +| S26 | BetterDisplay issue \#4737 | User report: reconnect-all semantics do not necessarily wake Sidecar | [Open](https://github.com/waydabber/BetterDisplay/issues/4737) | +| S27 | BetterDisplay issue \#1372 | User report: need faster access to DDC power control | [Open](https://github.com/waydabber/BetterDisplay/issues/1372) | +| S28 | BetterDisplay issue \#5234 | User report: rapid XDR/brightness changes can produce visual corruption | [Open](https://github.com/waydabber/BetterDisplay/issues/5234) | +| S29 | BetterDisplay issue \#76 | User report: virtual display sleep and window movement behavior | [Open](https://github.com/waydabber/BetterDisplay/issues/76) | +| S30 | BetterDisplay issue \#13 | User report: reconnecting virtual displays after sleep | [Open](https://github.com/waydabber/BetterDisplay/issues/13) | +| S31 | BetterDisplay issue \#2627 | User report: severe startup/WindowServer recovery scenario | [Open](https://github.com/waydabber/BetterDisplay/issues/2627) | +| S32 | BetterDisplay releases | Current release cadence and compatibility evidence | [Open](https://github.com/waydabber/BetterDisplay/releases) | + +## 22.1 Source interpretation rules + +- Reference product sources support the feature inventory and compatibility hypotheses, not implementation claims. + +- Issue reports are cited as examples that a failure can occur; engineering must reproduce and characterize behavior independently. + +- Apple documentation is authoritative for documented APIs and distribution guidance, but actual OS behavior still requires testing. + +- Open-source repositories may be studied and, where licenses permit, reused with attribution and compliance; unknown-license code is not reused. + +- All source-dependent statements should be refreshed before implementation planning if material time has passed or macOS has changed. + +# 23. Glossary + +| **Term** | **Definition** | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| Active display | An endpoint currently participating in the macOS display topology. | +| Black Out | A reversible presentation state that displays black while the endpoint normally remains active. | +| Capability decision | A contextual supported/unsupported/degraded result with provider, reasons, risk, and verification metadata. | +| Checkpoint | An atomic last-known-safe record used to restore topology and lifecycle state. | +| Clean-room implementation | Independent design based on lawful public observations without copying proprietary implementation or assets. | +| Core | Stable product scope whose startup and recovery do not depend on Labs modules. | +| DDC/CI | Display Data Channel Command Interface, commonly used to control monitor brightness, input, contrast, and audio. | +| Desired state | The topology, controls, modes, or policies the user or scene intends. | +| Display fingerprint | A scored set of identity signals used to associate observations with a persistent display record. | +| Display pipeline | Hardware/OS capacity used to drive displays; logical disconnect does not necessarily free it. | +| EDID | Extended Display Identification Data provided by many displays or adapters. | +| Experimental provider | An isolated implementation that uses unstable or undocumented behavior and is feature-flagged. | +| HiDPI | A scaled mode where multiple physical pixels represent a logical UI pixel for sharper rendering. | +| Identity confidence | The score/evidence indicating how reliably an observed endpoint maps to a persistent display. | +| Labs | Opt-in modules for unstable, system-sensitive, or evidence-limited features. | +| Logical disconnect | Removing a supported display from active macOS topology without physically unplugging it. | +| Managed offline | A remembered display that OpenDisplay intentionally placed offline and can attempt to reconnect. | +| Mirror set | A source and one or more displays presenting equivalent desktop content. | +| Monitor Sleep/Power | A hardware/network request to sleep or power a monitor; topology may remain active. | +| Observed state | What macOS and providers currently report, independent of the user's desired state. | +| Provider | A module that implements a capability through public APIs, native controls, DDC, network protocols, or experimental mechanisms. | +| Public-API-only build | A product flavor compiled without undocumented/private system providers. | +| Reconnect All | Emergency action that attempts every app-managed offline display and reports per-target results. | +| Recovery surface | A verified endpoint or control channel from which the user can see feedback and invoke recovery. | +| Route | The physical/logical path from Mac to display, including port, adapter, dock, KVM, and protocol. | +| Scene | A named partial desired state for displays, topology, modes, controls, profiles, and lifecycle. | +| Selector | A stable expression used by automation to resolve one or more display records. | +| System absent | A display not currently observed by macOS and not necessarily disconnected by OpenDisplay. | +| Topology generation | A stable version number for the normalized set and relationships of observed displays. | +| Transaction coordinator | The single serialized owner of display mutations, verification, rollback, and audit. | +| Unverified result | A provider request was sent but the resulting hardware/system state could not be read back conclusively. | +| Virtual display | A software-created display endpoint used for headless, capture, streaming, or workspace workflows. | +| VRR | Variable refresh rate. | +| XDR/HDR | Extended/high dynamic range display modes that can expose higher luminance and wider range. | + +## PRD completion checklist + +| **Area** | **Baseline in this document** | +|----------------|-----------------------------------------------------------------------------------------------------------| +| Product intent | Problem, personas, jobs, principles, goals, non-goals, and success definition. | +| Scope | Core 1.0, Core 1.x, Labs, compatibility, and release rings. | +| Feature map | 108 public/reference-derived capability items with disposition. | +| Requirements | 124 normative functional and non-functional requirements with acceptance criteria. | +| Safety | Disconnect semantics, invariants, transaction, recovery hierarchy, edge cases, and provider contract. | +| Architecture | Components, state ownership, identity, capabilities, planning, storage, and isolation. | +| Quality | 30 critical scenarios, hardware/OS matrix, fault testing, accessibility, security, and release gates. | +| Delivery | Distribution, updates, governance, licensing direction, milestones, risks, decisions, and open questions. | +| Research | 32 public sources with interpretation limits and traceability markers. | + +| | **Next governance action** Convert accepted Core 1.0 requirements into tracked epics and test cases, then run the M0 technical spike before committing to a public release date. The spike must prove recovery and provider isolation before broad feature work. | +|-----|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| diff --git a/Docs/RFCs/0000-template.md b/Docs/RFCs/0000-template.md new file mode 100644 index 0000000..84a26d0 --- /dev/null +++ b/Docs/RFCs/0000-template.md @@ -0,0 +1,47 @@ +# RFC 0000: + +> RFCs are **required** for: provider interfaces, lifecycle invariants, schema/API breaking +> changes, telemetry, licensing, and Labs → Core graduation. Copy this file to +> `NNNN-short-title.md`, open a PR, and link the discussion. + +- **Status:** Draft | Proposed | Accepted | Rejected | Superseded +- **Author(s):** +- **Created:** +- **Tracking issue / PR:** + +## Summary + +One paragraph: what is being proposed. + +## Motivation + +What problem does this solve? Who is affected? What is the expected outcome? + +## Guide-level explanation + +Explain the proposal as if teaching it to a user/contributor. Examples, copy, CLI/API usage. + +## Detailed design + +The concrete design: types, protocols, state transitions, storage, ordering. Reference the +affected packages/files. + +## Safety & recovery impact + +Does this touch lifecycle, the transaction coordinator, checkpoints, the rescue path, +startup, IPC, capture, update, or network? Describe new failure modes and how recovery +remains guaranteed. (Required if any answer is "yes".) + +## Compatibility & build-flavor impact + +- Core / full: +- Public-API-only build (must remain green — NFR-010): +- Labs: +- macOS version / architecture considerations: +- Schema/API versioning (additive vs breaking): + +## Drawbacks + +## Alternatives considered + +## Unresolved questions diff --git a/Docs/Recovery/recovery.md b/Docs/Recovery/recovery.md new file mode 100644 index 0000000..5fb80d5 --- /dev/null +++ b/Docs/Recovery/recovery.md @@ -0,0 +1,57 @@ +# Recovery model + +Recovery is a first-class product feature, not an afterthought. A display tool can remove +the surface that contains its own recovery UI, so recovery must work **independently of the +main app**. Normative source: [PRD](../PRD.md) §9. + +## Disconnect transaction stages + +Every logical disconnect runs through these staged steps (PRD §9.4), serialized by the +`TopologyCoordinator`: + +1. **Resolve target** — persistent identity; refresh route & topology generation. +2. **Reconcile** — wait for prior topology events to stabilize. +3. **Preflight safety** — safe surface, identity threshold, OS/provider compatibility, + recovery-service health (non-bypassable; see `SafetyEngine`). +4. **Checkpoint** — atomic snapshot of topology, modes, main/mirror, managed-offline set, + recovery metadata, written **before** any provider call. +5. **Confirm** — first-use / elevated-risk countdown on a safe display, showing the target + and the recovery shortcut. +6. **Apply** — invoke the provider with a transaction ID and deadline. +7. **Observe** — collect normalized OS events for this transaction. +8. **Verify** — target inactive/managed AND a safe surface remains active AND registry + stable; otherwise roll back or mark degraded. +9. **Commit** — persist the managed-offline record, actor, reason, policy, verified state, + and a new checkpoint. + +## Recovery hierarchy + +Ordered from least to most drastic (PRD §9.11). Earlier options are always preferred: + +1. **Cancel** during the confirmation countdown. +2. **Undo** from the activity log while still reversible. +3. **Reconnect All** from the menu bar or a global hotkey. +4. **Automatic rollback** from the last-known-safe checkpoint. +5. **Standalone rescue utility / rescue CLI** (independent process). +6. **Safe-mode startup** via a modifier key or command. +7. **Selective reset** of lifecycle policies / provider cache. +8. **Documented manual removal** of login item / configuration (last resort). + +> **P0 release rule:** any known path that can leave a supported default configuration +> without a usable recovery surface **blocks release**. A Labs label does not waive this. + +## Safe surface + +A safe surface is an active endpoint on which you can receive recovery feedback and invoke +recovery. The default rule requires a local active display that is **not** in the target +set, **not** expected to vanish from lid/power policy, **not** mirrored or blacked-out, and +has a stable identity. The current main being the target is a special case: the main / +recovery role must move to a verified safe display before the target is removed. + +## States are never conflated + +`Black Out`, `Monitor Sleep/Power`, `Logical Disconnect`, `Reconnect`, and physical unplug +are distinct concepts throughout the code, copy, and APIs. A display's full state is +`reachability × presentation overlay × monitor power` (see +`DisplayDomain/LifecycleState.swift`). The product never claims that logical disconnect +frees a hardware pipeline, bypasses display-count limits, or emulates cable removal. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6661bd0 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +# OpenDisplay — local developer entry points. +# +# Local-first: build and test the platform-independent packages with a Swift 6 +# toolchain. On macOS that's Xcode 16+ (Swift 6); on Linux use `make bootstrap` +# to install the toolchain, then `make test`. +# +# The macOS app, providers, rescue utility, CLI, and SwiftUI design system are +# built from the Xcode project on a Mac (see Apps/OpenDisplay). + +SWIFT ?= swift + +.DEFAULT_GOAL := test + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +.PHONY: bootstrap +bootstrap: ## Install a Swift 6 toolchain (Linux); on macOS just checks for Xcode/Swift + @./scripts/bootstrap-swift.sh + +.PHONY: build +build: ## Build the cross-platform packages (debug) + $(SWIFT) build + +.PHONY: test +test: ## Build and run the full unit/state-machine test suite + $(SWIFT) test --parallel + +.PHONY: release +release: ## Build the packages in release configuration + $(SWIFT) build -c release + +.PHONY: lint +lint: ## Run SwiftLint if available + @if command -v swiftlint >/dev/null 2>&1; then swiftlint lint; \ + else echo "swiftlint not installed (brew install swiftlint / apt). Skipping."; fi + +.PHONY: format +format: ## Run swift-format in place if available + @if command -v swift-format >/dev/null 2>&1; then \ + swift-format format -i -r Packages Providers Apps Tools; \ + else echo "swift-format not installed. Skipping."; fi + +.PHONY: xcode +xcode: ## Generate OpenDisplay.xcodeproj (XcodeGen) for the macOS app/providers/CLI + @./scripts/generate-xcodeproj.sh + +.PHONY: clean +clean: ## Remove build artifacts + $(SWIFT) package clean || true + rm -rf .build + +bundle-helper: ## Bundle the opendisplay CLI into OpenDisplay.app/Contents/Helpers (for experimental rotation) + @./scripts/bundle-helper.sh $(CONFIG) diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7ae938c --- /dev/null +++ b/Package.swift @@ -0,0 +1,97 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// OpenDisplay monorepo — Swift Package Manager manifest. +// +// This manifest declares the PLATFORM-INDEPENDENT core of OpenDisplay so it can be +// built and unit-tested with `swift test` on any platform (including Linux CI), with +// no dependency on macOS frameworks (CoreGraphics, AppKit, SwiftUI, ScreenCaptureKit). +// +// The macOS-specific targets — concrete providers, the menu-bar/settings app, the +// rescue app, and the SwiftUI design-system package — live under Providers/, Apps/, and +// Packages/OpenDisplayDesignSystem and are wired into the Xcode project on a Mac. They +// depend on the libraries declared here through the protocols in `ProviderInterfaces`. +let package = Package( + name: "OpenDisplay", + platforms: [ + .macOS(.v13) + ], + // Cross-platform `swift test` / `swift build` consume these products. The Xcode build does NOT: + // the macOS app links these modules into multiple Mach-O images (every provider framework AND + // the app/CLI/rescue), so a *static* copy of e.g. `ProviderInterfaces.ProviderFailure` would end + // up in each image with distinct runtime metadata, breaking `as?`/`catch as` across the framework + // boundary. The fix lives in the Xcode build (project.yml), which compiles these same source dirs + // as explicit *dynamic* frameworks so there is exactly one copy of each type at runtime. SwiftPM's + // own `.dynamic` products can't express that here — Xcode 16/26 can't build a package target + // dynamically when it's also an internal package dependency (diamond), so the dynamic frameworks + // are declared natively in project.yml instead. These product types stay as the default (static); + // they only affect the single-image `swift test`/`swift build` binaries, where duplication is moot. + products: [ + .library(name: "DisplayDomain", targets: ["DisplayDomain"]), + .library(name: "ProviderInterfaces", targets: ["ProviderInterfaces"]), + .library(name: "SceneEngine", targets: ["SceneEngine"]), + .library(name: "AutomationSchema", targets: ["AutomationSchema"]), + .library(name: "TopologyCore", targets: ["TopologyCore"]), + .library(name: "SimulatorProvider", targets: ["SimulatorProvider"]) + ], + targets: [ + // Pure value types, identity scoring, and the lifecycle/transaction state machines. + .target( + name: "DisplayDomain", + path: "Packages/DisplayDomain/Sources/DisplayDomain" + ), + // Provider protocols + typed results/failures (no concrete provider logic). + .target( + name: "ProviderInterfaces", + dependencies: ["DisplayDomain"], + path: "Packages/ProviderInterfaces/Sources/ProviderInterfaces" + ), + // Desired-state scene model: diff, deterministic ordering, idempotent planning, dry-run. + .target( + name: "SceneEngine", + dependencies: ["DisplayDomain"], + path: "Packages/SceneEngine/Sources/SceneEngine" + ), + // Stable Codable schemas for the CLI / JSON result envelope and selectors. + .target( + name: "AutomationSchema", + dependencies: ["DisplayDomain"], + path: "Packages/AutomationSchema/Sources/AutomationSchema" + ), + // SafetyEngine + the serialized transaction coordinator (protocol-driven, platform-independent). + .target( + name: "TopologyCore", + dependencies: ["DisplayDomain", "ProviderInterfaces", "SceneEngine", "AutomationSchema"], + path: "Packages/TopologyCore/Sources/TopologyCore" + ), + // A fully in-memory provider that exercises every result and fault state. Used by tests + // and developer previews; ships in no release build. + .target( + name: "SimulatorProvider", + dependencies: ["DisplayDomain", "ProviderInterfaces"], + path: "Packages/SimulatorProvider/Sources/SimulatorProvider" + ), + + // MARK: - Tests + .testTarget( + name: "DisplayDomainTests", + dependencies: ["DisplayDomain"], + path: "Packages/DisplayDomain/Tests/DisplayDomainTests" + ), + .testTarget( + name: "SceneEngineTests", + dependencies: ["SceneEngine", "DisplayDomain"], + path: "Packages/SceneEngine/Tests/SceneEngineTests" + ), + .testTarget( + name: "AutomationSchemaTests", + dependencies: ["AutomationSchema", "DisplayDomain"], + path: "Packages/AutomationSchema/Tests/AutomationSchemaTests" + ), + .testTarget( + name: "TopologyCoreTests", + dependencies: ["TopologyCore", "SimulatorProvider", "DisplayDomain", "ProviderInterfaces", "AutomationSchema"], + path: "Packages/TopologyCore/Tests/TopologyCoreTests" + ) + ] +) diff --git a/Packages/AutomationSchema/Sources/AutomationSchema/ResultEnvelope.swift b/Packages/AutomationSchema/Sources/AutomationSchema/ResultEnvelope.swift new file mode 100644 index 0000000..5ba736f --- /dev/null +++ b/Packages/AutomationSchema/Sources/AutomationSchema/ResultEnvelope.swift @@ -0,0 +1,151 @@ +import DisplayDomain +import Foundation + +/// The stable, versioned result envelope returned by every automation surface (CLI, App Intents, +/// HTTP API). Schema is versioned independently of the app; clients must ignore unknown fields +/// (PRD §12.4, §12.7, AUT-003/004/012). +public struct ResultEnvelope: Hashable, Sendable, Codable { + public static let currentSchemaVersion = "1.0" + + public enum Status: String, Hashable, Sendable, Codable { + case committed + case partial + case rolledBack + case failed + case noOp + } + + public var schemaVersion: String + public var transactionId: String + public var status: Status + public var actor: Actor + public var requestedAt: Date + public var topologyGeneration: UInt64 + public var targets: [TargetResult] + public var recovery: RecoveryInfo? + public var errors: [ErrorInfo] + + public init( + schemaVersion: String = ResultEnvelope.currentSchemaVersion, + transactionId: String, + status: Status, + actor: Actor, + requestedAt: Date, + topologyGeneration: UInt64, + targets: [TargetResult] = [], + recovery: RecoveryInfo? = nil, + errors: [ErrorInfo] = [] + ) { + self.schemaVersion = schemaVersion + self.transactionId = transactionId + self.status = status + self.actor = actor + self.requestedAt = requestedAt + self.topologyGeneration = topologyGeneration + self.targets = targets + self.recovery = recovery + self.errors = errors + } + + public struct TargetResult: Hashable, Sendable, Codable { + public var displayId: String + public var alias: String? + public var identityConfidence: Double + public var operations: [OperationResult] + + public init(displayId: String, alias: String?, identityConfidence: Double, operations: [OperationResult]) { + self.displayId = displayId + self.alias = alias + self.identityConfidence = identityConfidence + self.operations = operations + } + } + + public struct OperationResult: Hashable, Sendable, Codable { + public var field: String + public var requested: AnyCodableValue? + public var observed: AnyCodableValue? + public var verification: VerificationState + public var provider: String? + public var warnings: [String] + + public init( + field: String, + requested: AnyCodableValue? = nil, + observed: AnyCodableValue? = nil, + verification: VerificationState, + provider: String? = nil, + warnings: [String] = [] + ) { + self.field = field + self.requested = requested + self.observed = observed + self.verification = verification + self.provider = provider + self.warnings = warnings + } + } + + public struct RecoveryInfo: Hashable, Sendable, Codable { + public var checkpointId: String + public var available: Bool + + public init(checkpointId: String, available: Bool) { + self.checkpointId = checkpointId + self.available = available + } + } + + public struct ErrorInfo: Hashable, Sendable, Codable { + public var code: String + public var message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } + } +} + +/// A minimal JSON value wrapper so operation `requested`/`observed` can carry bool/number/string +/// without leaking concrete Swift types into the wire schema. +public enum AnyCodableValue: Hashable, Sendable, Codable { + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode(Int.self) { self = .int(value) } + else if let value = try? container.decode(Double.self) { self = .double(value) } + else { self = .string(try container.decode(String.self)) } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + } + } +} + +public extension ResultEnvelope { + /// Canonical encoder used across all automation surfaces: stable key order + ISO-8601 dates. + static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Packages/AutomationSchema/Tests/AutomationSchemaTests/ResultEnvelopeTests.swift b/Packages/AutomationSchema/Tests/AutomationSchemaTests/ResultEnvelopeTests.swift new file mode 100644 index 0000000..b26991e --- /dev/null +++ b/Packages/AutomationSchema/Tests/AutomationSchemaTests/ResultEnvelopeTests.swift @@ -0,0 +1,65 @@ +import XCTest +import DisplayDomain +@testable import AutomationSchema + +final class ResultEnvelopeTests: XCTestCase { + func testRoundTripPreservesAllFields() throws { + let envelope = ResultEnvelope( + transactionId: "txn_1", + status: .committed, + actor: .cli, + requestedAt: Date(timeIntervalSince1970: 1_700_000_000), + topologyGeneration: 419, + targets: [ + .init(displayId: "disp_1", alias: "DeskLeft", identityConfidence: 0.98, operations: [ + .init(field: "lifecycle.connected", + requested: .bool(false), + observed: .bool(false), + verification: .verified, + provider: "experimentalLifecycle.v1") + ]) + ], + recovery: .init(checkpointId: "cp_1", available: true) + ) + + let data = try ResultEnvelope.makeEncoder().encode(envelope) + let decoded = try ResultEnvelope.makeDecoder().decode(ResultEnvelope.self, from: data) + XCTAssertEqual(decoded, envelope) + } + + func testUnknownFieldsAreIgnored() throws { + // Forward compatibility (AUT-012): a client on schema 1.0 must ignore unknown fields. + let json = """ + { + "schemaVersion": "1.1", + "transactionId": "txn_9", + "status": "noOp", + "actor": "ui", + "requestedAt": "2026-06-22T00:00:00Z", + "topologyGeneration": 1, + "targets": [], + "errors": [], + "futureOnlyField": { "nested": true } + } + """ + let data = Data(json.utf8) + let decoded = try ResultEnvelope.makeDecoder().decode(ResultEnvelope.self, from: data) + XCTAssertEqual(decoded.status, .noOp) + XCTAssertEqual(decoded.transactionId, "txn_9") + } + + func testAnyCodableValueVariants() throws { + let values: [AnyCodableValue] = [.bool(true), .int(42), .double(3.5), .string("hi")] + for value in values { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodableValue.self, from: data) + XCTAssertEqual(decoded, value) + } + } + + func testCurrentSchemaVersionDefault() { + let envelope = ResultEnvelope(transactionId: "t", status: .failed, actor: .ui, + requestedAt: Date(), topologyGeneration: 0) + XCTAssertEqual(envelope.schemaVersion, "1.0") + } +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/Capability.swift b/Packages/DisplayDomain/Sources/DisplayDomain/Capability.swift new file mode 100644 index 0000000..82c9f72 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/Capability.swift @@ -0,0 +1,49 @@ +import Foundation + +public enum CapabilityStatus: String, Hashable, Sendable, Codable { + case supported + case unsupported + case unknown + case degraded + case disabledByPolicy +} + +/// Whether an applied change could actually be confirmed. A provider call is not success +/// (PRD §2.1 "Verify, do not assume"; D-010). +public enum VerificationState: String, Hashable, Sendable, Codable { + case verified + case readBackUnavailable + case notApplicable +} + +public enum RiskLevel: String, Hashable, Sendable, Codable, Comparable { + case normal + case hardwareDependent + case experimental + case recoveryCritical + + private var order: Int { + switch self { + case .normal: return 0 + case .hardwareDependent: return 1 + case .experimental: return 2 + case .recoveryCritical: return 3 + } + } + + public static func < (lhs: RiskLevel, rhs: RiskLevel) -> Bool { lhs.order < rhs.order } +} + +/// Why a capability is in its current state. Every unavailable feature must carry at least one +/// reason so the UI/API can explain it (PRD DIA-004, DIA-006). +public enum CapabilityReason: String, Hashable, Sendable, Codable { + case osVersion + case architecture + case displayClass + case route + case permission + case buildFlavor + case providerHealth + case userPolicy + case safetyPolicy +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/DisplayModels.swift b/Packages/DisplayDomain/Sources/DisplayDomain/DisplayModels.swift new file mode 100644 index 0000000..40cefa4 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/DisplayModels.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Broad classification of a display endpoint. Drives capability gating and copy. +public enum DisplayClass: String, Hashable, Sendable, Codable { + case builtIn + case external + case projector + case television + case sidecar + case airplay + case virtual + case headlessAdapter + case unknown +} + +/// The physical/logical path from the Mac to the display. Capability detection is per-route +/// because a monitor may support DDC directly but not through a dock or KVM (PRD §6.2, REG-008). +public enum ConnectionTransport: String, Hashable, Sendable, Codable { + case internalPanel + case usbCDisplayPort + case thunderbolt + case hdmi + case displayPort + case dock + case kvm + case wireless + case virtual + case unknown +} + +/// A concrete display mode. Modes are resolved by *properties*, never by transient mode +/// handles, because the available mode list can change after reconnect (PRD TOP-005, S24). +public struct DisplayMode: Hashable, Sendable, Codable { + public var pixelWidth: Int + public var pixelHeight: Int + public var pointWidth: Int + public var pointHeight: Int + public var refreshHz: Double + public var isHiDPI: Bool + public var bitDepth: Int? + + public init( + pixelWidth: Int, + pixelHeight: Int, + pointWidth: Int, + pointHeight: Int, + refreshHz: Double, + isHiDPI: Bool, + bitDepth: Int? = nil + ) { + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.pointWidth = pointWidth + self.pointHeight = pointHeight + self.refreshHz = refreshHz + self.isHiDPI = isHiDPI + self.bitDepth = bitDepth + } +} + +public enum Rotation: Int, Hashable, Sendable, Codable, CaseIterable { + case degrees0 = 0 + case degrees90 = 90 + case degrees180 = 180 + case degrees270 = 270 +} + +/// A point in the global desktop coordinate space (top-left origin), in points. +public struct DisplayOrigin: Hashable, Sendable, Codable { + public var x: Int + public var y: Int + + public init(x: Int, y: Int) { + self.x = x + self.y = y + } + + public static let zero = DisplayOrigin(x: 0, y: 0) +} + +/// An immutable snapshot of what macOS/providers currently report for one endpoint, tied to +/// the topology generation in which it was observed (PRD §13.1 DisplayObservation). Observed +/// state is deliberately kept separate from desired state (REG-009). +public struct DisplayObservation: Hashable, Sendable, Codable { + public var recordID: DisplayRecordID + public var cgDisplayID: UInt32? + public var cgUUID: String? + public var ioServicePath: String? + public var isActive: Bool + public var overlay: PresentationOverlay + public var origin: DisplayOrigin + public var mode: DisplayMode? + public var rotation: Rotation + public var isMain: Bool + public var mirrorSourceID: DisplayRecordID? + public var hdrEnabled: Bool + public var colorProfileName: String? + public var transport: ConnectionTransport + public var displayClass: DisplayClass + public var generation: TopologyGeneration + public var observedAt: Date + + public init( + recordID: DisplayRecordID, + cgDisplayID: UInt32? = nil, + cgUUID: String? = nil, + ioServicePath: String? = nil, + isActive: Bool, + overlay: PresentationOverlay = .visible, + origin: DisplayOrigin = .zero, + mode: DisplayMode? = nil, + rotation: Rotation = .degrees0, + isMain: Bool = false, + mirrorSourceID: DisplayRecordID? = nil, + hdrEnabled: Bool = false, + colorProfileName: String? = nil, + transport: ConnectionTransport = .unknown, + displayClass: DisplayClass = .unknown, + generation: TopologyGeneration, + observedAt: Date = Date() + ) { + self.recordID = recordID + self.cgDisplayID = cgDisplayID + self.cgUUID = cgUUID + self.ioServicePath = ioServicePath + self.isActive = isActive + self.overlay = overlay + self.origin = origin + self.mode = mode + self.rotation = rotation + self.isMain = isMain + self.mirrorSourceID = mirrorSourceID + self.hdrEnabled = hdrEnabled + self.colorProfileName = colorProfileName + self.transport = transport + self.displayClass = displayClass + self.generation = generation + self.observedAt = observedAt + } + + /// `true` when this display is mirroring another endpoint. + public var isMirrored: Bool { mirrorSourceID != nil } +} + +/// The persistent record that user intent (alias, tags, pairing, policies) attaches to. It is +/// linked to observations through scored identity evidence (PRD §10.5). Persists across the +/// active/offline lifecycle (REG-006). +public struct DisplayRecord: Hashable, Sendable, Codable, Identifiable { + public var id: DisplayRecordID + public var alias: String? + public var tags: Set<String> + public var fingerprint: DisplayFingerprint + public var displayClass: DisplayClass + public var lastSeen: Date? + public var pairingConfirmed: Bool + + public init( + id: DisplayRecordID, + alias: String? = nil, + tags: Set<String> = [], + fingerprint: DisplayFingerprint, + displayClass: DisplayClass = .unknown, + lastSeen: Date? = nil, + pairingConfirmed: Bool = false + ) { + self.id = id + self.alias = alias + self.tags = tags + self.fingerprint = fingerprint + self.displayClass = displayClass + self.lastSeen = lastSeen + self.pairingConfirmed = pairingConfirmed + } + + /// A user-facing name: the explicit alias if set, otherwise the model name, otherwise the ID. + public var displayName: String { + alias ?? fingerprint.modelName ?? id.rawValue + } +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/Identifiers.swift b/Packages/DisplayDomain/Sources/DisplayDomain/Identifiers.swift new file mode 100644 index 0000000..50134e0 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/Identifiers.swift @@ -0,0 +1,87 @@ +import Foundation + +/// A stable, app-owned identifier for a physical/logical display, independent of any +/// transient OS display ID. Persistent behavior (aliases, policies, scenes) is keyed on +/// this value, never on a Core Graphics display ID (PRD D-009, REG-003). +public struct DisplayRecordID: Hashable, Sendable, Codable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Mints a fresh, sortable record ID (`disp_<ulid-like>`). + public static func generate(now: Date = Date()) -> DisplayRecordID { + let stamp = UInt64(max(0, now.timeIntervalSince1970 * 1000)).description + let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12) + return DisplayRecordID(rawValue: "disp_\(stamp)_\(suffix)") + } + + public var description: String { rawValue } +} + +/// Identifies a single serialized topology/lifecycle transaction. Every mutation carries +/// one of these for correlation across the coordinator, providers, logs, and the result +/// envelope (PRD §10.4). +public struct TransactionID: Hashable, Sendable, Codable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static func generate() -> TransactionID { + TransactionID(rawValue: "txn_\(UUID().uuidString)") + } + + public var description: String { rawValue } +} + +/// Identifies a last-known-safe checkpoint (PRD §9.4, DIA-008). +public struct CheckpointID: Hashable, Sendable, Codable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static func generate() -> CheckpointID { + CheckpointID(rawValue: "cp_\(UUID().uuidString)") + } + + public var description: String { rawValue } +} + +/// A monotonically increasing version of the normalized set of observed displays. Bumped +/// only after the registry has stabilized following OS events, so capability snapshots and +/// transactions can be invalidated when the world changes underneath them (PRD §10.4). +public struct TopologyGeneration: Hashable, Sendable, Codable, Comparable, CustomStringConvertible { + public let value: UInt64 + + public init(_ value: UInt64) { + self.value = value + } + + public static let initial = TopologyGeneration(0) + + public func next() -> TopologyGeneration { + TopologyGeneration(value &+ 1) + } + + public static func < (lhs: TopologyGeneration, rhs: TopologyGeneration) -> Bool { + lhs.value < rhs.value + } + + public var description: String { "gen:\(value)" } +} + +/// The actor that requested a change, recorded on every transaction and audit entry (AUT-010). +public enum Actor: String, Hashable, Sendable, Codable { + case ui + case cli + case appIntent + case rule + case httpAPI + case recovery + case system +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/Identity.swift b/Packages/DisplayDomain/Sources/DisplayDomain/Identity.swift new file mode 100644 index 0000000..40f56a0 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/Identity.swift @@ -0,0 +1,146 @@ +import Foundation + +/// The multi-signal identity evidence for a display. A display ID is an observation, not an +/// identity (PRD principle 2.1); persistent behavior uses this scored fingerprint plus user +/// confirmation rather than any single signal (REG-003). +public struct DisplayFingerprint: Hashable, Sendable, Codable { + public var vendorID: Int? + public var productID: Int? + public var serialNumber: String? + /// Salted hash of the serial used for export/correlation without leaking the raw value + /// (PRD §13.3, DIA-011). + public var serialHash: String? + public var modelName: String? + public var manufactureYear: Int? + public var manufactureWeek: Int? + public var physicalWidthMM: Int? + public var physicalHeightMM: Int? + public var edidHash: String? + + public init( + vendorID: Int? = nil, + productID: Int? = nil, + serialNumber: String? = nil, + serialHash: String? = nil, + modelName: String? = nil, + manufactureYear: Int? = nil, + manufactureWeek: Int? = nil, + physicalWidthMM: Int? = nil, + physicalHeightMM: Int? = nil, + edidHash: String? = nil + ) { + self.vendorID = vendorID + self.productID = productID + self.serialNumber = serialNumber + self.serialHash = serialHash + self.modelName = modelName + self.manufactureYear = manufactureYear + self.manufactureWeek = manufactureWeek + self.physicalWidthMM = physicalWidthMM + self.physicalHeightMM = physicalHeightMM + self.edidHash = edidHash + } +} + +/// One piece of evidence linking an observation to a record, with the weight it contributes. +public struct IdentityEvidence: Hashable, Sendable, Codable { + public enum Signal: String, Hashable, Sendable, Codable { + case userAlias + case explicitPairing + case edidSerial + case modelFamily + case ioRegistryPath + case physicalSize + case topologyPosition + case cgUUID + } + + public var signal: Signal + public var matched: Bool + public var weight: Double + + public init(signal: Signal, matched: Bool, weight: Double) { + self.signal = signal + self.matched = matched + self.weight = weight + } +} + +/// The result of matching an observation against a candidate record: a 0...1 confidence score +/// plus the evidence that produced it, so the UI/API can explain *why* (REG-005). +public struct IdentityConfidence: Hashable, Sendable, Codable { + public var score: Double + public var evidence: [IdentityEvidence] + + public init(score: Double, evidence: [IdentityEvidence]) { + self.score = min(1, max(0, score)) + self.evidence = evidence + } + + /// Default threshold below which a destructive (lifecycle) operation must not proceed + /// without explicit user confirmation (LIF-004, §9.2 invariant 4). + public static let destructiveThreshold = 0.85 +} + +/// Pure, deterministic identity scoring. Higher-trust signals dominate; identical monitors that +/// share model family but differ only by route/topology stay below the destructive threshold +/// until the user confirms an explicit pairing (REG-004). +public enum IdentityScorer { + /// Canonical signal weights. They intentionally sum so that a confirmed serial OR an + /// explicit user pairing alone clears the destructive threshold, while model-family + + /// topology evidence alone does not. + public static let weights: [IdentityEvidence.Signal: Double] = [ + .explicitPairing: 0.90, + .userAlias: 0.45, + .edidSerial: 0.85, + .modelFamily: 0.25, + .ioRegistryPath: 0.30, + .physicalSize: 0.10, + .topologyPosition: 0.20, + .cgUUID: 0.15 + ] + + public static func score(observed: DisplayFingerprint, + candidate: DisplayRecord, + explicitPairing: Bool = false, + aliasMatches: Bool = false, + ioPathMatches: Bool = false, + topologyMatches: Bool = false, + cgUUIDMatches: Bool = false) -> IdentityConfidence { + var evidence: [IdentityEvidence] = [] + + func add(_ signal: IdentityEvidence.Signal, _ matched: Bool) { + evidence.append(IdentityEvidence(signal: signal, matched: matched, weight: weights[signal] ?? 0)) + } + + add(.explicitPairing, explicitPairing || candidate.pairingConfirmed) + add(.userAlias, aliasMatches) + + let serialMatches: Bool = { + guard let a = observed.serialNumber ?? observed.serialHash, + let b = candidate.fingerprint.serialNumber ?? candidate.fingerprint.serialHash + else { return false } + return a == b + }() + add(.edidSerial, serialMatches) + + let modelMatches = observed.vendorID != nil + && observed.vendorID == candidate.fingerprint.vendorID + && observed.productID == candidate.fingerprint.productID + add(.modelFamily, modelMatches) + + add(.ioRegistryPath, ioPathMatches) + add(.physicalSize, observed.physicalWidthMM != nil + && observed.physicalWidthMM == candidate.fingerprint.physicalWidthMM + && observed.physicalHeightMM == candidate.fingerprint.physicalHeightMM) + add(.topologyPosition, topologyMatches) + add(.cgUUID, cgUUIDMatches) + + // Combine matched weights with diminishing returns so multiple weak signals never + // silently exceed a single strong one. score = 1 - Π(1 - weightᵢ) over matched signals. + let product = evidence.reduce(1.0) { acc, item in + item.matched ? acc * (1 - item.weight) : acc + } + return IdentityConfidence(score: 1 - product, evidence: evidence) + } +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/LifecycleState.swift b/Packages/DisplayDomain/Sources/DisplayDomain/LifecycleState.swift new file mode 100644 index 0000000..7f05765 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/LifecycleState.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Where a display sits in the topology lifecycle. These states are never conflated with +/// presentation overlays or monitor power (PRD §9.2 invariant 11; §9.3 reachability model). +public enum Reachability: String, Hashable, Sendable, Codable { + /// Not currently observed by macOS, and not necessarily disconnected by OpenDisplay. + case systemAbsent + /// Known to exist but not part of the active topology. + case discoveredInactive + /// Participating in the active macOS topology. + case active + /// Mid-flight removal from the active topology. + case disconnecting + /// Intentionally placed offline by OpenDisplay; can be reconnected. + case managedOffline + /// Mid-flight return to the active topology. + case reconnecting +} + +/// Presentation overlays are orthogonal to reachability — a display can be `active` *and* +/// `blackedOut` at once (PRD §9.3). Black Out is never the same concept as logical disconnect. +public enum PresentationOverlay: String, Hashable, Sendable, Codable { + case visible + case blackedOut + case dimmed + case filtered +} + +/// What we know about the monitor's own power state. A DDC/network sleep command's outcome may +/// be unverifiable — that is its own state, never reported as success (PRD LIF-019, §9.2). +/// NOTE: part of the documented lifecycle vocabulary but not yet wired to a consumer. +public enum MonitorPower: String, Hashable, Sendable, Codable { + case unknown + case awake + case sleepRequested + case asleepVerified + case powerFailed +} + +extension Reachability { + /// Legal reachability transitions. Any transition not listed here is a programming error + /// and is rejected by the coordinator rather than written to hardware (PRD §9.3). + public func canTransition(to next: Reachability) -> Bool { + switch (self, next) { + case (.systemAbsent, .discoveredInactive), + (.systemAbsent, .active), + (.discoveredInactive, .active), + (.discoveredInactive, .systemAbsent), + (.active, .disconnecting), + (.active, .systemAbsent), + (.disconnecting, .managedOffline), + (.disconnecting, .active), // rollback path + (.managedOffline, .reconnecting), + (.managedOffline, .systemAbsent), // endpoint physically removed while offline + (.reconnecting, .active), + (.reconnecting, .managedOffline): // reconnect failed; remain offline + return true + case let (a, b) where a == b: + return true // idempotent no-op + default: + return false + } + } +} + +/// The serialized transaction state machine that governs every topology/lifecycle mutation +/// (PRD §9.3). At most one transaction may be in a non-terminal state at any time (§9.2 inv. 1). +public enum TransactionState: String, Hashable, Sendable, Codable { + case idle + case resolving + case preflight + case checkpointed + case applying + case observing + case verifying + case committed + case rollingBack + case recovered + case degraded + case failed + + /// Terminal states end a transaction and release the coordinator's exclusivity. + public var isTerminal: Bool { + switch self { + case .committed, .recovered, .degraded, .failed: return true + default: return false + } + } + + public func canTransition(to next: TransactionState) -> Bool { + switch (self, next) { + case (.idle, .resolving), + (.resolving, .preflight), + (.resolving, .failed), + (.preflight, .checkpointed), + (.preflight, .failed), // preflight blocked (e.g. no safe surface) + (.checkpointed, .applying), + (.checkpointed, .failed), // user cancelled at confirmation + (.applying, .observing), + (.applying, .rollingBack), + (.observing, .verifying), + (.observing, .rollingBack), + (.verifying, .committed), + (.verifying, .rollingBack), + (.verifying, .degraded), + (.rollingBack, .recovered), + (.rollingBack, .degraded), + (.rollingBack, .failed): + return true + default: + return false + } + } +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/Records.swift b/Packages/DisplayDomain/Sources/DisplayDomain/Records.swift new file mode 100644 index 0000000..ebb6d07 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/Records.swift @@ -0,0 +1,163 @@ +import Foundation + +/// A remembered display that OpenDisplay intentionally placed offline. Distinct from system +/// absence (PRD §13.1, LIF-022). Carries who/when/why and the desired reconnect policy. +public struct ManagedOfflineRecord: Hashable, Sendable, Codable, Identifiable { + public var id: DisplayRecordID { displayID } + public var displayID: DisplayRecordID + public var actor: Actor + public var reason: String + public var disconnectedAt: Date + public var providerID: String + public var persistencePolicy: PersistencePolicy + public var lastFailure: String? + + public init( + displayID: DisplayRecordID, + actor: Actor, + reason: String, + disconnectedAt: Date = Date(), + providerID: String, + persistencePolicy: PersistencePolicy = .reconnectOnQuit, + lastFailure: String? = nil + ) { + self.displayID = displayID + self.actor = actor + self.reason = reason + self.disconnectedAt = disconnectedAt + self.providerID = providerID + self.persistencePolicy = persistencePolicy + self.lastFailure = lastFailure + } +} + +/// Persistence is a desired-state policy the app reapplies — never an OS guarantee (PRD §9.8, +/// LIF-015). Persistent disconnect is off by default. +public enum PersistencePolicy: String, Hashable, Sendable, Codable { + /// Reconnect on normal quit, wake, and reboot (the safe default, D-005). + case reconnectOnQuit + /// Reconnect only on wake, otherwise stay offline. + case reconnectOnWake + /// Opt-in: try to stay offline across login/reboot once health checks pass. + case persistentOffline +} + +/// The minimal, rescue-readable snapshot written before any risky transaction (PRD §9.4, +/// DIA-008). Kept small and free of secrets so the standalone rescue utility can restore it. +public struct Checkpoint: Hashable, Sendable, Codable, Identifiable { + public var id: CheckpointID + public var transactionID: TransactionID + public var generation: TopologyGeneration + public var observations: [DisplayObservation] + public var mainDisplayID: DisplayRecordID? + public var managedOffline: [ManagedOfflineRecord] + public var createdAt: Date + + public init( + id: CheckpointID = .generate(), + transactionID: TransactionID, + generation: TopologyGeneration, + observations: [DisplayObservation], + mainDisplayID: DisplayRecordID? = nil, + managedOffline: [ManagedOfflineRecord] = [], + createdAt: Date = Date() + ) { + self.id = id + self.transactionID = transactionID + self.generation = generation + self.observations = observations + self.mainDisplayID = mainDisplayID + self.managedOffline = managedOffline + self.createdAt = createdAt + } +} + +/// Health of a provider for a given environment key. Three bounded failures trip the circuit +/// breaker and disable the provider (PRD DIA-007, §9.2 invariant 10). +// TODO(DIA-007): implemented but not yet wired — no capability gate or health store consumes it. +// Tracks PRD DIA-007 / §9.2 invariant 10 (disable a failing provider after 3 bounded failures +// and stop destabilizing writes). +public struct ProviderHealth: Hashable, Sendable, Codable { + public enum Status: String, Hashable, Sendable, Codable { + case ok + case degraded + case circuitOpen + case unknown + } + + public var providerID: String + public var environmentKey: String + public var status: Status + public var consecutiveFailures: Int + public var lastProbe: Date? + + public static let failureThreshold = 3 + + public init( + providerID: String, + environmentKey: String, + status: Status = .unknown, + consecutiveFailures: Int = 0, + lastProbe: Date? = nil + ) { + self.providerID = providerID + self.environmentKey = environmentKey + self.status = status + self.consecutiveFailures = consecutiveFailures + self.lastProbe = lastProbe + } + + /// Returns a copy reflecting one more failure, tripping the breaker at the threshold. + public func recordingFailure(now: Date = Date()) -> ProviderHealth { + let failures = consecutiveFailures + 1 + return ProviderHealth( + providerID: providerID, + environmentKey: environmentKey, + status: failures >= Self.failureThreshold ? .circuitOpen : .degraded, + consecutiveFailures: failures, + lastProbe: now + ) + } + + /// Returns a copy reset to healthy after a success. + public func recordingSuccess(now: Date = Date()) -> ProviderHealth { + ProviderHealth( + providerID: providerID, + environmentKey: environmentKey, + status: .ok, + consecutiveFailures: 0, + lastProbe: now + ) + } + + public var isUsable: Bool { status != .circuitOpen } +} + +/// An immutable snapshot of the whole normalized topology at a generation. This is what the UI +/// consumes and what the planner/safety engine reason over (PRD §10.4). +public struct TopologySnapshot: Hashable, Sendable, Codable { + public var generation: TopologyGeneration + public var observations: [DisplayObservation] + public var managedOffline: [ManagedOfflineRecord] + public var capturedAt: Date + + public init( + generation: TopologyGeneration, + observations: [DisplayObservation], + managedOffline: [ManagedOfflineRecord] = [], + capturedAt: Date = Date() + ) { + self.generation = generation + self.observations = observations + self.managedOffline = managedOffline + self.capturedAt = capturedAt + } + + public var activeDisplays: [DisplayObservation] { + observations.filter { $0.isActive } + } + + public func observation(for id: DisplayRecordID) -> DisplayObservation? { + observations.first { $0.recordID == id } + } +} diff --git a/Packages/DisplayDomain/Sources/DisplayDomain/Selector.swift b/Packages/DisplayDomain/Sources/DisplayDomain/Selector.swift new file mode 100644 index 0000000..9b82ce7 --- /dev/null +++ b/Packages/DisplayDomain/Sources/DisplayDomain/Selector.swift @@ -0,0 +1,131 @@ +import Foundation + +/// A stable expression that resolves to one or more display records (PRD §12.3, AUT-002). +/// Ambiguity is an error for destructive mutations; read-only queries may return many. +public enum DisplaySelector: Hashable, Sendable, Codable { + case id(DisplayRecordID) + case alias(String) + case tag(String) + case name(String) + case fingerprint(vendor: Int?, product: Int?, serial: String?) + case role(Role) + case state(Reachability) + case topology(edge: TopologyEdge, of: String) // relativeTo another selector's alias/name + + public enum Role: String, Hashable, Sendable, Codable { + case main + case builtin + case pointer + case focus + } + + public enum TopologyEdge: String, Hashable, Sendable, Codable { + case leftOf + case rightOf + case above + case below + } + + /// Whether resolving this selector for a destructive operation requires a unique match. + /// Set/role/state selectors may resolve to multiple displays and need explicit `--all`. + public var isSetSelector: Bool { + switch self { + case .tag, .state: return true + case .name, .fingerprint: return true // may be ambiguous → treated as a candidate set + default: return false + } + } +} + +public enum SelectorParseError: Error, Equatable, Sendable { + case empty + case unknownScheme(String) + case malformed(String) +} + +extension DisplaySelector { + /// Parses the CLI/automation selector grammar, e.g. `id:disp_…`, `alias:DeskLeft`, + /// `tag:studio`, `name:"LG HDR 4K"`, `vendor:610 product:12345`, `main`, `state:managedOffline`, + /// `leftOf:alias:Center` (PRD §12.3). + public static func parse(_ raw: String) throws -> DisplaySelector { + let text = raw.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { throw SelectorParseError.empty } + + // Bare roles. + switch text.lowercased() { + case "main": return .role(.main) + case "builtin", "built-in": return .role(.builtin) + case "pointer": return .role(.pointer) + case "focus", "focused": return .role(.focus) + default: break + } + + // Compound fingerprint form: "vendor:610 product:12345 serial:ABC". + if text.contains("vendor:") || text.contains("product:") || text.contains("serial:") { + return try parseFingerprint(text) + } + + guard let colon = text.firstIndex(of: ":") else { + throw SelectorParseError.malformed(text) + } + let scheme = String(text[text.startIndex..<colon]).lowercased() + let value = unquote(String(text[text.index(after: colon)...])) + + switch scheme { + case "id": return .id(DisplayRecordID(rawValue: value)) + case "alias": return .alias(value) + case "tag": return .tag(value) + case "name": return .name(value) + case "state": + guard let reach = Reachability(rawValue: value) else { + throw SelectorParseError.malformed("state:\(value)") + } + return .state(reach) + case "leftof": return .topology(edge: .leftOf, of: stripRelativeScheme(value)) + case "rightof": return .topology(edge: .rightOf, of: stripRelativeScheme(value)) + case "above": return .topology(edge: .above, of: stripRelativeScheme(value)) + case "below": return .topology(edge: .below, of: stripRelativeScheme(value)) + default: + throw SelectorParseError.unknownScheme(scheme) + } + } + + private static func parseFingerprint(_ text: String) throws -> DisplaySelector { + var vendor: Int? + var product: Int? + var serial: String? + for token in text.split(separator: " ") { + let parts = token.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = parts[0].lowercased() + let value = unquote(String(parts[1])) + switch key { + case "vendor": vendor = Int(value) + case "product": product = Int(value) + case "serial": serial = value + default: break + } + } + if vendor == nil && product == nil && serial == nil { + throw SelectorParseError.malformed(text) + } + return .fingerprint(vendor: vendor, product: product, serial: serial) + } + + private static func stripRelativeScheme(_ value: String) -> String { + // Accept either "alias:Center" or "Center" as the anchor reference. + if let colon = value.firstIndex(of: ":") { + return unquote(String(value[value.index(after: colon)...])) + } + return value + } + + private static func unquote(_ value: String) -> String { + var trimmed = value.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") && trimmed.count >= 2 { + trimmed.removeFirst() + trimmed.removeLast() + } + return trimmed + } +} diff --git a/Packages/DisplayDomain/Tests/DisplayDomainTests/IdentityScorerTests.swift b/Packages/DisplayDomain/Tests/DisplayDomainTests/IdentityScorerTests.swift new file mode 100644 index 0000000..7372f99 --- /dev/null +++ b/Packages/DisplayDomain/Tests/DisplayDomainTests/IdentityScorerTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import DisplayDomain + +final class IdentityScorerTests: XCTestCase { + private func record(serial: String?, vendor: Int? = 610, product: Int? = 123, paired: Bool = false) -> DisplayRecord { + DisplayRecord( + id: .generate(), + fingerprint: DisplayFingerprint(vendorID: vendor, productID: product, serialNumber: serial, modelName: "Test 4K"), + pairingConfirmed: paired + ) + } + + func testMatchingSerialClearsDestructiveThreshold() { + let candidate = record(serial: "ABC123") + let observed = DisplayFingerprint(vendorID: 610, productID: 123, serialNumber: "ABC123") + let confidence = IdentityScorer.score(observed: observed, candidate: candidate) + XCTAssertGreaterThanOrEqual(confidence.score, IdentityConfidence.destructiveThreshold, + "A matching EDID serial must be enough to act destructively.") + } + + func testIdenticalModelWithoutSerialStaysBelowThreshold() { + // Two identical monitors: same vendor/product, no serial, only topology evidence. This must + // NOT clear the destructive threshold without explicit pairing (REG-004, §9.2 invariant 4). + let candidate = record(serial: nil) + let observed = DisplayFingerprint(vendorID: 610, productID: 123, serialNumber: nil) + let confidence = IdentityScorer.score(observed: observed, candidate: candidate, topologyMatches: true) + XCTAssertLessThan(confidence.score, IdentityConfidence.destructiveThreshold) + } + + func testExplicitPairingClearsThreshold() { + let candidate = record(serial: nil, paired: true) + let observed = DisplayFingerprint(vendorID: 610, productID: 123, serialNumber: nil) + let confidence = IdentityScorer.score(observed: observed, candidate: candidate, explicitPairing: true) + XCTAssertGreaterThanOrEqual(confidence.score, IdentityConfidence.destructiveThreshold) + } + + func testScoreIsClampedToUnitInterval() { + let candidate = record(serial: "ABC123", paired: true) + let observed = DisplayFingerprint(vendorID: 610, productID: 123, serialNumber: "ABC123") + let confidence = IdentityScorer.score(observed: observed, candidate: candidate, + explicitPairing: true, aliasMatches: true, + ioPathMatches: true, topologyMatches: true, cgUUIDMatches: true) + XCTAssertLessThanOrEqual(confidence.score, 1.0) + XCTAssertGreaterThanOrEqual(confidence.score, 0.0) + } +} diff --git a/Packages/DisplayDomain/Tests/DisplayDomainTests/SelectorTests.swift b/Packages/DisplayDomain/Tests/DisplayDomainTests/SelectorTests.swift new file mode 100644 index 0000000..2fe78b1 --- /dev/null +++ b/Packages/DisplayDomain/Tests/DisplayDomainTests/SelectorTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import DisplayDomain + +final class SelectorTests: XCTestCase { + func testParsesSchemes() throws { + XCTAssertEqual(try DisplaySelector.parse("id:disp_42"), .id(DisplayRecordID(rawValue: "disp_42"))) + XCTAssertEqual(try DisplaySelector.parse("alias:DeskLeft"), .alias("DeskLeft")) + XCTAssertEqual(try DisplaySelector.parse("tag:studio"), .tag("studio")) + XCTAssertEqual(try DisplaySelector.parse("name:\"LG HDR 4K\""), .name("LG HDR 4K")) + XCTAssertEqual(try DisplaySelector.parse("state:managedOffline"), .state(.managedOffline)) + } + + func testParsesBareRoles() throws { + XCTAssertEqual(try DisplaySelector.parse("main"), .role(.main)) + XCTAssertEqual(try DisplaySelector.parse("builtin"), .role(.builtin)) + XCTAssertEqual(try DisplaySelector.parse("pointer"), .role(.pointer)) + XCTAssertEqual(try DisplaySelector.parse("focus"), .role(.focus)) + } + + func testParsesFingerprint() throws { + let selector = try DisplaySelector.parse("vendor:610 product:12345 serial:ABC") + XCTAssertEqual(selector, .fingerprint(vendor: 610, product: 12345, serial: "ABC")) + } + + func testParsesTopologyRelative() throws { + XCTAssertEqual(try DisplaySelector.parse("leftOf:alias:Center"), .topology(edge: .leftOf, of: "Center")) + XCTAssertEqual(try DisplaySelector.parse("rightOf:Center"), .topology(edge: .rightOf, of: "Center")) + } + + func testSetSelectorClassification() { + XCTAssertTrue(DisplaySelector.tag("studio").isSetSelector) + XCTAssertTrue(DisplaySelector.state(.active).isSetSelector) + XCTAssertFalse(DisplaySelector.id(DisplayRecordID(rawValue: "x")).isSetSelector) + XCTAssertFalse(DisplaySelector.role(.main).isSetSelector) + } + + func testErrors() { + XCTAssertThrowsError(try DisplaySelector.parse("")) { error in + XCTAssertEqual(error as? SelectorParseError, .empty) + } + XCTAssertThrowsError(try DisplaySelector.parse("bogus:value")) { error in + XCTAssertEqual(error as? SelectorParseError, .unknownScheme("bogus")) + } + XCTAssertThrowsError(try DisplaySelector.parse("noscheme")) { error in + XCTAssertEqual(error as? SelectorParseError, .malformed("noscheme")) + } + } +} diff --git a/Packages/DisplayDomain/Tests/DisplayDomainTests/StateMachineTests.swift b/Packages/DisplayDomain/Tests/DisplayDomainTests/StateMachineTests.swift new file mode 100644 index 0000000..2d66348 --- /dev/null +++ b/Packages/DisplayDomain/Tests/DisplayDomainTests/StateMachineTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import DisplayDomain + +final class StateMachineTests: XCTestCase { + func testLegalReachabilityPath() { + XCTAssertTrue(Reachability.active.canTransition(to: .disconnecting)) + XCTAssertTrue(Reachability.disconnecting.canTransition(to: .managedOffline)) + XCTAssertTrue(Reachability.managedOffline.canTransition(to: .reconnecting)) + XCTAssertTrue(Reachability.reconnecting.canTransition(to: .active)) + } + + func testRollbackPathIsLegal() { + // disconnecting → active is the rollback edge. + XCTAssertTrue(Reachability.disconnecting.canTransition(to: .active)) + } + + func testIllegalReachabilityTransitionsRejected() { + XCTAssertFalse(Reachability.active.canTransition(to: .reconnecting)) + XCTAssertFalse(Reachability.systemAbsent.canTransition(to: .managedOffline)) + XCTAssertFalse(Reachability.managedOffline.canTransition(to: .active)) + } + + func testIdempotentReachabilityIsLegal() { + for state in [Reachability.active, .managedOffline, .systemAbsent] { + XCTAssertTrue(state.canTransition(to: state)) + } + } + + func testTransactionHappyPath() { + let path: [TransactionState] = [.idle, .resolving, .preflight, .checkpointed, .applying, + .observing, .verifying, .committed] + for (current, next) in zip(path, path.dropFirst()) { + XCTAssertTrue(current.canTransition(to: next), "\(current) → \(next) should be legal") + } + XCTAssertTrue(TransactionState.committed.isTerminal) + } + + func testTransactionRollbackPath() { + XCTAssertTrue(TransactionState.applying.canTransition(to: .rollingBack)) + XCTAssertTrue(TransactionState.verifying.canTransition(to: .rollingBack)) + XCTAssertTrue(TransactionState.rollingBack.canTransition(to: .recovered)) + XCTAssertTrue(TransactionState.rollingBack.canTransition(to: .degraded)) + XCTAssertTrue(TransactionState.degraded.isTerminal) + XCTAssertTrue(TransactionState.recovered.isTerminal) + } + + func testTransactionIllegalTransitionsRejected() { + XCTAssertFalse(TransactionState.idle.canTransition(to: .committed)) + XCTAssertFalse(TransactionState.committed.canTransition(to: .applying)) + XCTAssertFalse(TransactionState.preflight.canTransition(to: .committed)) + } + + func testTopologyGenerationOrdering() { + let g0 = TopologyGeneration.initial + let g1 = g0.next() + XCTAssertLessThan(g0, g1) + XCTAssertEqual(g1.value, 1) + } +} diff --git a/Packages/OpenDisplayDesignSystem/README.md b/Packages/OpenDisplayDesignSystem/README.md new file mode 100644 index 0000000..ab5482e --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/README.md @@ -0,0 +1,44 @@ +# OpenDisplayDesignSystem + +The SwiftUI port of the OpenDisplay design kit. **macOS target** (Xcode), built in M0–M1. + +The original web design system (tokens, components, screens, icon inventory, and the screen +& icon plan) is preserved verbatim under [`reference/`](reference/) as the **source of +truth**. This package re-expresses it natively, matching macOS HIG. + +## Port plan + +### Tokens → `Sources/.../Tokens` +Light + dark semantic tokens from `reference/ds/tokens/*.css`: + +- **Color** — accent `#007AFF` (light) / `#0A84FF` (dark); status green/orange/red; label + hierarchy (primary/secondary/tertiary/quaternary); window/sidebar/content/card/panel + surfaces. Implement as semantic `Color` extensions backed by an asset catalog. +- **Type** — Apple system stack; scale 10→26pt (13pt body, tabular figures for metrics). +- **Spacing** — 4px base scale; rows ≈28px (menu-bar) / ≈38px (settings). +- **Radii** — 4/6/8/10/12/16/pill. **Elevation** — control/card/popover/window shadows. +- **Materials** — vibrancy via `NSVisualEffectView` only for the popover & menu bar. + +### Components (14) → `Sources/.../Components` +`Button`, `IconButton`, `Switch`, `Slider`, `SegmentedControl`, `Select`, `Stepper`, +`Checkbox`, `Card`, `Row`, `Divider`, `Badge`, `InlineBanner`, `DisplayTile` — plus kit +composites `Popover`, settings `Window`/sidebar, `MBDisplay`, `MBSliderRow`, `QuickAction`, +`GlyphTile`. Each ships SwiftUI `#Preview`s mirroring the states in the web `@dsCard` demos. + +### Icons → `Sources/.../Icons` +Use **SF Symbols** in production. `reference/ds/od-icons.js` + `reference/od-icons-ext.js` +ship hand-built line substitutes only and document the SF Symbol mapping for all ~60 glyphs; +replace the substitutes with the mapped SF Symbol names. + +### Screens (consumed by `Apps/OpenDisplay`) +- **Menu-bar popover** (11 states): default, collapsed, built-in-only, scanning, + managed-offline + Reconnect All, reconnecting, disconnect countdown, black out, degraded, + ambiguous identity. Source: `reference/screens-menubar.jsx`. +- **Settings window**: per-display Detail (resolution/appearance/use-as/lifecycle/degraded/ + offline), Arrange canvas (+mirror/identify), Scenes (empty/list/dry-run), Automation, + Health & Recovery, Labs, Add Virtual Display, the disconnect confirmation sheet, and the + full-screen Recovery OSD. Sources: `reference/screens-settings-a.jsx`, + `reference/screens-settings-b.jsx`, `reference/screens-shared.jsx`. + +All views consume immutable domain snapshots and emit commands; they never mutate domain +state. No emoji; status via SF Symbol glyphs, color dots, and pills. diff --git a/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Badge.swift b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Badge.swift new file mode 100644 index 0000000..8eaeaa8 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Badge.swift @@ -0,0 +1,85 @@ +#if os(macOS) +import SwiftUI + +/// Semantic tone for badges, glyph tiles, and chips. +public enum ODTone: Sendable { + case neutral, accent, green, orange, red + + /// The solid status color for this tone (`.neutral` has none — callers fall back to a label color). + public var color: Color? { + switch self { + case .neutral: return nil + case .accent: return ODColor.accent + case .green: return ODColor.connected + case .orange: return ODColor.caution + case .red: return ODColor.danger + } + } +} + +/// A compact status pill (`reference` `Badge`): "Main", "Offline", "Degraded", "Labs", etc. Tinted at +/// 14% for the soft variant, or filled solid for emphasis (e.g. the accent "Main" badge). +public struct ODBadge: View { + private let text: String + private let tone: ODTone + private let solid: Bool + + public init(_ text: String, tone: ODTone = .neutral, solid: Bool = false) { + self.text = text + self.tone = tone + self.solid = solid + } + + public var body: some View { + Text(text) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .foregroundStyle(foreground) + .padding(.horizontal, 6) + .frame(height: 16) + .background(background, in: RoundedRectangle(cornerRadius: ODRadius.badge)) + } + + private var foreground: Color { + if solid { return ODColor.accentForeground } + return tone.color ?? Color.secondary + } + + private var background: Color { + if solid { return tone.color ?? Color.secondary } + return tone.color?.opacity(0.14) ?? ODColor.fillTertiary + } +} + +/// A small filled status dot (`reference` `Dot`), used inline where a full badge would be too heavy. +public struct ODDot: View { + private let color: Color + + public init(_ color: Color) { self.color = color } + + public var body: some View { + Circle().fill(color).frame(width: 7, height: 7) + } +} + +#Preview("Badges") { + VStack(alignment: .leading, spacing: 8) { + HStack { + ODBadge("Main", tone: .accent, solid: true) + ODBadge("Mirrored") + ODBadge("Offline") + } + HStack { + ODBadge("Reconnecting…", tone: .accent) + ODBadge("Degraded", tone: .orange) + ODBadge("Healthy", tone: .green) + } + HStack { + ODBadge("Labs", tone: .orange) + ODDot(ODColor.connected) + ODDot(ODColor.caution) + } + } + .padding() +} +#endif diff --git a/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/InlineBanner.swift b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/InlineBanner.swift new file mode 100644 index 0000000..ef6b9be --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/InlineBanner.swift @@ -0,0 +1,82 @@ +#if os(macOS) +import SwiftUI + +/// An inline confirmation / recovery banner (`reference` `InlineBanner`) for potentially disruptive +/// actions — resolution change, disconnect countdown, degraded providers. A colored left rail keys the +/// tone; an optional countdown and trailing actions support "keep / revert" flows. +public struct ODInlineBanner<Actions: View>: View { + private let tone: ODTone + private let systemImage: String? + private let title: String + private let message: String? + private let countdown: Int? + private let actions: Actions + + public init(tone: ODTone = .accent, + systemImage: String? = nil, + title: String, + message: String? = nil, + countdown: Int? = nil, + @ViewBuilder actions: () -> Actions) { + self.tone = tone + self.systemImage = systemImage + self.title = title + self.message = message + self.countdown = countdown + self.actions = actions() + } + + public var body: some View { + HStack(alignment: .top, spacing: 9) { + Rectangle().fill(rail).frame(width: 2.5).clipShape(Capsule()) + if let systemImage { + Image(systemName: systemImage).foregroundStyle(rail).padding(.top, 1) + } + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(.primary) + if let message { + Text(message).font(.system(size: 11)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if !(actions is EmptyView) { + HStack(spacing: 6) { actions }.padding(.top, 4) + } + } + Spacer(minLength: 0) + if let countdown { + Text("\(countdown)s").font(.system(size: 11, weight: .medium)) + .monospacedDigit().foregroundStyle(.secondary) + } + } + .padding(10) + .background(ODColor.cardBackground, in: RoundedRectangle(cornerRadius: ODRadius.card)) + .overlay(RoundedRectangle(cornerRadius: ODRadius.card).strokeBorder(ODColor.separator, lineWidth: 0.5)) + } + + private var rail: Color { tone.color ?? ODColor.accent } +} + +public extension ODInlineBanner where Actions == EmptyView { + init(tone: ODTone = .accent, systemImage: String? = nil, title: String, + message: String? = nil, countdown: Int? = nil) { + self.init(tone: tone, systemImage: systemImage, title: title, message: message, + countdown: countdown) { EmptyView() } + } +} + +#Preview("Inline banners") { + VStack(spacing: 10) { + ODInlineBanner(tone: .orange, systemImage: "exclamationmark.triangle.fill", + title: "Some providers are unavailable", + message: "Hardware brightness control is degraded on this display.") + ODInlineBanner(tone: .accent, systemImage: "rectangle.on.rectangle", + title: "Resolution → 2304 × 1496", + message: "Reverting automatically if not confirmed.", countdown: 12) { + Button("Keep") {} + Button("Revert") {} + } + } + .padding() + .frame(width: 320) +} +#endif diff --git a/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Layout.swift b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Layout.swift new file mode 100644 index 0000000..127d897 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Layout.swift @@ -0,0 +1,223 @@ +#if os(macOS) +import SwiftUI + +/// A 28×28 rounded tile holding an SF Symbol (`reference` `GlyphTile`) — the leading identity glyph on +/// every display row. Accent tone fills solid; status tones tint softly; neutral uses a fill wash. +public struct ODGlyphTile: View { + private let systemImage: String + private let tone: ODTone + private let glyphSize: CGFloat + + public init(_ systemImage: String, tone: ODTone = .neutral, glyphSize: CGFloat = 17) { + self.systemImage = systemImage + self.tone = tone + self.glyphSize = glyphSize + } + + public var body: some View { + Image(systemName: systemImage) + .font(.system(size: glyphSize)) + .foregroundStyle(foreground) + .frame(width: 28, height: 28) + .background(background, in: RoundedRectangle(cornerRadius: 7)) + } + + private var foreground: Color { + switch tone { + case .accent: return ODColor.accentForeground + case .neutral: return Color.secondary + default: return tone.color ?? Color.secondary + } + } + + private var background: Color { + switch tone { + case .accent: return ODColor.accent + case .neutral: return ODColor.fillSecondary + default: return tone.color?.opacity(0.16) ?? ODColor.fillSecondary + } + } +} + +/// An uppercase section header (`reference` `SectionLabel`): "DISPLAYS", "TOOLS", with an optional +/// trailing accessory (e.g. a count or a small button). +public struct ODSectionLabel<Trailing: View>: View { + private let title: String + private let trailing: Trailing + + public init(_ title: String, @ViewBuilder trailing: () -> Trailing) { + self.title = title + self.trailing = trailing() + } + + public var body: some View { + HStack(spacing: 4) { + Text(title.uppercased()) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(.tertiary) + Spacer(minLength: 0) + trailing + } + .padding(.horizontal, 8) + .padding(.top, 2) + .padding(.bottom, 6) + } +} + +public extension ODSectionLabel where Trailing == EmptyView { + init(_ title: String) { self.init(title, trailing: { EmptyView() }) } +} + +/// A hairline separator (`reference` `Divider`), inset from the left to clear leading glyphs. +public struct ODDivider: View { + private let inset: CGFloat + + public init(inset: CGFloat = 11) { self.inset = inset } + + public var body: some View { + Rectangle() + .fill(ODColor.separator) + .frame(height: 0.5) + .padding(.leading, inset) + } +} + +/// A grouped "inset" card (`reference` `Card`) — the rounded surface that holds a list of setting +/// rows, with an optional group title above and footnote below. +public struct ODCard<Content: View>: View { + private let title: String? + private let footnote: String? + private let padded: Bool + private let content: Content + + public init(title: String? = nil, footnote: String? = nil, padded: Bool = false, + @ViewBuilder content: () -> Content) { + self.title = title + self.footnote = footnote + self.padded = padded + self.content = content() + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let title { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.bottom, 6) + } + VStack(alignment: .leading, spacing: 0) { content } + .padding(padded ? 12 : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ODColor.cardBackground, in: RoundedRectangle(cornerRadius: ODRadius.card)) + .overlay(RoundedRectangle(cornerRadius: ODRadius.card).strokeBorder(ODColor.separator, lineWidth: 0.5)) + if let footnote { + Text(footnote) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.top, 6) + } + } + } +} + +/// A settings row (`reference` `Row`): leading accessory, a label (+ optional secondary line), and a +/// trailing control. Hoverable + selectable when given an `action`. +public struct ODRow<Leading: View, Trailing: View>: View { + private let label: String + private let secondary: String? + private let selected: Bool + private let leading: Leading + private let trailing: Trailing + private let action: (() -> Void)? + @State private var hovering = false + + public init(_ label: String, secondary: String? = nil, selected: Bool = false, + action: (() -> Void)? = nil, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing) { + self.label = label + self.secondary = secondary + self.selected = selected + self.action = action + self.leading = leading() + self.trailing = trailing() + } + + public var body: some View { + let row = HStack(spacing: 9) { + leading + VStack(alignment: .leading, spacing: 1) { + Text(label).font(.system(size: 13)).foregroundStyle(.primary).lineLimit(1) + if let secondary { + Text(secondary).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer(minLength: 6) + trailing + } + .padding(.horizontal, 11) + .frame(minHeight: 38) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background, in: RoundedRectangle(cornerRadius: ODRadius.card)) + .contentShape(Rectangle()) + + if let action { + Button(action: action) { row } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } else { + row + } + } + + private var background: Color { + if selected { return ODColor.accentTint } + if action != nil && hovering { return ODColor.rowHover } + return .clear + } +} + +// Convenience initializers for the common row shapes (no leading glyph, or no trailing control). +public extension ODRow where Leading == EmptyView { + init(_ label: String, secondary: String? = nil, selected: Bool = false, + action: (() -> Void)? = nil, @ViewBuilder trailing: () -> Trailing) { + self.init(label, secondary: secondary, selected: selected, action: action, + leading: { EmptyView() }, trailing: trailing) + } +} + +// Note: there is intentionally no single-trailing-closure "leading only" convenience — it would be +// ambiguous with the trailing-only init above. A leading glyph with no trailing control uses the main +// init with an explicit `trailing: { EmptyView() }`. + +public extension ODRow where Leading == EmptyView, Trailing == EmptyView { + init(_ label: String, secondary: String? = nil, selected: Bool = false, action: (() -> Void)? = nil) { + self.init(label, secondary: secondary, selected: selected, action: action, + leading: { EmptyView() }, trailing: { EmptyView() }) + } +} + +#Preview("Layout") { + VStack(alignment: .leading, spacing: 12) { + ODSectionLabel("Displays") { ODBadge("3") } + HStack { ODGlyphTile("display", tone: .accent); ODGlyphTile("laptopcomputer"); ODGlyphTile("display.trianglebadge.exclamationmark", tone: .orange) } + ODCard(title: "Resolution", footnote: "Scaled resolutions use HiDPI rendering.") { + ODRow("Resolution") { Text("2560 × 1440").font(.system(size: 11)).foregroundStyle(.secondary) } + ODDivider() + ODRow("Refresh rate") { Text("60 Hz").font(.system(size: 11)).foregroundStyle(.secondary) } + } + ODRow("Studio Display", secondary: "5120 × 2880 · 60 Hz", action: {}) { + ODGlyphTile("display", tone: .accent) + } trailing: { + ODBadge("Main", tone: .accent, solid: true) + } + } + .padding() + .frame(width: 360) +} +#endif diff --git a/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/MenuBarControls.swift b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/MenuBarControls.swift new file mode 100644 index 0000000..9606f09 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/MenuBarControls.swift @@ -0,0 +1,187 @@ +#if os(macOS) +import SwiftUI + +/// A labelled slider row (`reference` `MBSliderRow`) — leading icon, the track, an optional trailing +/// "max" icon, and a right-aligned value readout. Used for brightness and volume in the popover. +/// Supports both continuous live-set sliders and stepped commit-on-release sliders (resolution) via +/// `step` + `onEditingChanged`. +public struct ODSliderRow: View { + private let systemImage: String + private let trailingSystemImage: String? + @Binding private var value: Double + private let range: ClosedRange<Double> + private let step: Double? + private let valueText: String? + private let disabled: Bool + private let accessibilityLabel: String? + private let onEditingChanged: (Bool) -> Void + + public init(systemImage: String, + trailingSystemImage: String? = nil, + value: Binding<Double>, + in range: ClosedRange<Double> = 0...1, + step: Double? = nil, + valueText: String? = nil, + disabled: Bool = false, + accessibilityLabel: String? = nil, + onEditingChanged: @escaping (Bool) -> Void = { _ in }) { + self.systemImage = systemImage + self.trailingSystemImage = trailingSystemImage + self._value = value + self.range = range + self.step = step + self.valueText = valueText + self.disabled = disabled + self.accessibilityLabel = accessibilityLabel + self.onEditingChanged = onEditingChanged + } + + public var body: some View { + HStack(spacing: 9) { + Image(systemName: systemImage).font(.system(size: 15)).foregroundStyle(.secondary) + slider + if let trailingSystemImage { + Image(systemName: trailingSystemImage).font(.system(size: 15)).foregroundStyle(.tertiary) + } + if let valueText { + Text(valueText) + .font(.system(size: 11)).foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + } + } + .padding(.horizontal, 8).padding(.vertical, 5) + .opacity(disabled ? 0.4 : 1) + .disabled(disabled) + } + + @ViewBuilder private var slider: some View { + Group { + if let step { + Slider(value: $value, in: range, step: step, onEditingChanged: onEditingChanged) + } else { + Slider(value: $value, in: range, onEditingChanged: onEditingChanged) + } + } + .accessibilityLabel(accessibilityLabel ?? "") + } +} + +/// A compact status/toggle chip (`reference` `MBChip`): "HDR", "True Tone", "2560 × 1440", "60 Hz". +/// `on` lights it in accent; an optional `action` makes it tappable. +public struct ODChip: View { + private let text: String + private let systemImage: String? + private let on: Bool + private let tone: ODTone + private let action: (() -> Void)? + @State private var hovering = false + + public init(_ text: String, systemImage: String? = nil, on: Bool = false, + tone: ODTone = .neutral, action: (() -> Void)? = nil) { + self.text = text + self.systemImage = systemImage + self.on = on + self.tone = tone + self.action = action + } + + public var body: some View { + let chip = HStack(spacing: 4) { + if let systemImage { Image(systemName: systemImage).font(.system(size: 10)) } + Text(text) + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(foreground) + .padding(.horizontal, 8) + .frame(height: 22) + .background(background, in: RoundedRectangle(cornerRadius: ODRadius.control)) + .contentShape(Rectangle()) + + if let action { + Button(action: action) { chip } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } else { + chip + } + } + + private var foreground: Color { + if on { return ODColor.accent } + if tone == .orange { return ODColor.caution } + return Color.secondary + } + + private var background: Color { + if on { return ODColor.accentTint } + if tone == .orange { return ODColor.caution.opacity(0.14) } + return ODColor.fillTertiary.opacity(hovering && action != nil ? 1.6 : 1) + } +} + +/// One of the equal-width quick actions at the foot of an expanded display card (`reference` +/// `QuickAction`): Black Out, Sleep, Set as Main, Disconnect. Destructive actions use the red tone. +public struct ODQuickAction: View { + private let systemImage: String + private let label: String + private let tone: ODTone + private let enabled: Bool + private let action: () -> Void + @State private var hovering = false + + public init(_ label: String, systemImage: String, tone: ODTone = .neutral, + enabled: Bool = true, action: @escaping () -> Void) { + self.label = label + self.systemImage = systemImage + self.tone = tone + self.enabled = enabled + self.action = action + } + + public var body: some View { + Button { if enabled { action() } } label: { + HStack(spacing: 5) { + Image(systemName: systemImage).font(.system(size: 13)) + Text(label).font(.system(size: 11, weight: .medium)).lineLimit(1) + } + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: 26) + .background(ODColor.fillTertiary.opacity(hovering && enabled ? 1.7 : 1), + in: RoundedRectangle(cornerRadius: 7)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .onHover { hovering = $0 } + } + + private var foreground: Color { + guard enabled else { return Color.secondary.opacity(0.6) } + return tone == .red ? ODColor.danger : Color.primary + } +} + +#Preview("Menu-bar controls") { + VStack(alignment: .leading, spacing: 8) { + ODSliderRow(systemImage: "sun.min", trailingSystemImage: "sun.max", + value: .constant(0.7), valueText: "70%") + ODSliderRow(systemImage: "speaker.fill", trailingSystemImage: "speaker.wave.3.fill", + value: .constant(0.4), valueText: "40%") + HStack(spacing: 6) { + ODChip("HDR", systemImage: "bolt.fill", on: true) + ODChip("True Tone") + ODChip("2560 × 1440") + ODChip("60 Hz") + } + HStack(spacing: 6) { + ODQuickAction("Black Out", systemImage: "moon.stars") {} + ODQuickAction("Sleep", systemImage: "moon") {} + ODQuickAction("Disconnect", systemImage: "rectangle.portrait.slash", tone: .red) {} + } + } + .padding() + .frame(width: 306) +} +#endif diff --git a/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Tokens.swift b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Tokens.swift new file mode 100644 index 0000000..c6ceccb --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/Sources/OpenDisplayDesignSystem/Tokens.swift @@ -0,0 +1,67 @@ +#if os(macOS) +import AppKit +import SwiftUI + +/// Builds an appearance-adaptive `Color` from explicit sRGB light/dark components. We resolve via +/// `NSColor`'s dynamic provider (rather than an asset catalog) because the design system ships as a +/// framework target with no catalog, and `NSColor(_: Color)` is unavailable on the macOS 13 floor. +private func odDynamic(_ light: (Double, Double, Double, Double), + _ dark: (Double, Double, Double, Double)) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let (r, g, b, a) = isDark ? dark : light + return NSColor(srgbRed: r, green: g, blue: b, alpha: a) + }) +} + +/// Semantic color tokens, ported from `reference/ds/tokens/colors.css` (light `:root` + `.theme-dark`). +/// Status/accent colors differ per appearance, so each is an adaptive dynamic color. Label colors are +/// intentionally *not* here — views use SwiftUI's native `.primary`/`.secondary`/`.tertiary`/ +/// `.quaternary` hierarchy, which is the adaptive equivalent of the kit's `--label-*` ramp. +public enum ODColor { + /// System blue accent (#007AFF light / #0A84FF dark). + public static let accent = odDynamic((0.0, 122.0/255, 1.0, 1), (10.0/255, 132.0/255, 1.0, 1)) + /// Selected-row wash behind the accent (`--accent-tint`). + public static let accentTint = odDynamic((0.0, 122.0/255, 1.0, 0.12), (10.0/255, 132.0/255, 1.0, 0.22)) + /// Text/glyph on top of a solid accent fill. + public static let accentForeground = Color.white + + /// Status: connected / on / success (#34C759 / #30D158). + public static let connected = odDynamic((52.0/255, 199.0/255, 89.0/255, 1), (48.0/255, 209.0/255, 88.0/255, 1)) + /// Status: caution / unsupported (#FF9500 / #FF9F0A). + public static let caution = odDynamic((1.0, 149.0/255, 0.0, 1), (1.0, 159.0/255, 10.0/255, 1)) + /// Status: destructive / disconnect (#FF3B30 / #FF453A). + public static let danger = odDynamic((1.0, 59.0/255, 48.0/255, 1), (1.0, 69.0/255, 58.0/255, 1)) + + // ---- Control fills (unselected), `rgba(120,120,128, a)` per appearance ---- + public static let fillPrimary = odDynamic((120.0/255, 120.0/255, 128.0/255, 0.20), (120.0/255, 120.0/255, 128.0/255, 0.36)) + public static let fillSecondary = odDynamic((120.0/255, 120.0/255, 128.0/255, 0.16), (120.0/255, 120.0/255, 128.0/255, 0.30)) + public static let fillTertiary = odDynamic((120.0/255, 120.0/255, 128.0/255, 0.12), (120.0/255, 120.0/255, 128.0/255, 0.24)) + public static let fillQuaternary = odDynamic((120.0/255, 120.0/255, 128.0/255, 0.08), (120.0/255, 120.0/255, 128.0/255, 0.18)) + + /// Hairline separator (`--separator`). + public static let separator = odDynamic((0, 0, 0, 0.10), (1, 1, 1, 0.12)) + /// Hover wash on neutral interactive rows (`--row-hover`). + public static let rowHover = odDynamic((0, 0, 0, 0.04), (1, 1, 1, 0.06)) + /// Grouped/inset list card surface (`--card-bg`). + public static let cardBackground = odDynamic((1, 1, 1, 1), (47.0/255, 47.0/255, 49.0/255, 1)) +} + +/// 4px-based spacing scale (`reference/ds/tokens/spacing.css`). +public enum ODSpacing { + public static let xs: CGFloat = 4 + public static let sm: CGFloat = 8 + public static let md: CGFloat = 12 + public static let lg: CGFloat = 16 + public static let xl: CGFloat = 24 +} + +/// Corner radii (`reference/ds/tokens/...`): badges → controls → cards → popover → window. +public enum ODRadius { + public static let badge: CGFloat = 4 + public static let control: CGFloat = 6 + public static let card: CGFloat = 8 + public static let popover: CGFloat = 12 + public static let window: CGFloat = 16 +} +#endif diff --git a/Packages/OpenDisplayDesignSystem/reference/design-canvas.jsx b/Packages/OpenDisplayDesignSystem/reference/design-canvas.jsx new file mode 100644 index 0000000..85efe98 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/reference/design-canvas.jsx @@ -0,0 +1,1034 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) + +/* BEGIN USAGE */ +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// <DesignCanvas> +// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants"> +// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard> +// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard> +// </DCSection> +// </DesignCanvas> +// +// Artboards are static design frames, not scroll regions — never use +// height: 100% + overflow: auto/scroll on inner elements; size each artboard +// to fit its content (explicit pixel height, or let it grow). +/* END USAGE */ + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide +// DCSection/DCArtboard children from the type-based walks below. +function dcFlatten(children) { + const out = []; + React.Children.forEach(children, (c) => { + if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children)); + else out.push(c); + }); + return out; +} + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Fragments are flattened; wrapping in other + // elements still opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + dcFlatten(children).forEach((sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + dcFlatten(sec.props.children).forEach((ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + <DCCtx.Provider value={api}> + <DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport> + {state.focus && registry[state.focus] && ( + <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} /> + )} + </DCCtx.Provider> + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + let restored = false; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + restored = true; + } + } catch {} + // Visibility backstop (one-shot): a persisted pan is only meaningful + // relative to content that may have changed since it was saved. If the + // restored transform leaves every section/artboard off-screen, restoring + // it faithfully just strands the user — reset to origin instead. + // Content renders after the sidecar read settles, so poll briefly until + // real boxes exist; any user input cancels (they may be mid-pan). + let checks = 0; + let checkT = 0; + let sawInput = false; + let hiddenStreak = 0; + const onInput = () => { sawInput = true; }; + const cleanupCheck = () => { + window.removeEventListener('wheel', onInput, true); + window.removeEventListener('pointerdown', onInput, true); + }; + const checkVisible = () => { + const vp = vpRef.current, world = worldRef.current; + checks += 1; + if (!vp || !world || sawInput || checks > 10) { cleanupCheck(); return; } + const vr = vp.getBoundingClientRect(); + let sized = 0, visible = false; + // Slots plus section-head titles: the [data-dc-section] wrapper (and + // .dc-sectionhead) are full-width blocks whose boxes can stay + // on-screen while everything real is stranded; the inline-block title + // is text-sized and covers sections whose artboards were all deleted. + world.querySelectorAll('[data-dc-slot], .dc-sectionhead .dc-editable').forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return; + sized += 1; + if (r.right > vr.left && r.left < vr.right && r.bottom > vr.top && r.top < vr.bottom) visible = true; + }); + if (visible) { cleanupCheck(); return; } + if (sized === 0) { hiddenStreak = 0; checkT = setTimeout(checkVisible, 400); return; } // not rendered yet + // Two consecutive hidden reads before resetting — the sidecar read can + // reorder/hide sections after first paint, transiently moving every + // box; a single sample must not discard a healthy deliberate pan. + hiddenStreak += 1; + if (hiddenStreak < 2) { checkT = setTimeout(checkVisible, 400); return; } + tf.current = { x: 0, y: 0, scale: 1 }; + apply(); + cleanupCheck(); + }; + if (restored) { + window.addEventListener('wheel', onInput, true); + window.addEventListener('pointerdown', onInput, true); + checkT = setTimeout(checkVisible, 250); + } + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { + clearTimeout(checkT); + cleanupCheck(); + window.removeEventListener('pagehide', flush); + flush(); + }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's + // marginBottom) reflow on every scale change, vertically shifting the + // world layout — so a world point mathematically pinned under the cursor + // drifts as you zoom (content creeps up on zoom-in, down on zoom-out). + // Anchor the DOM element under the cursor instead: record its screen Y, + // apply the transform + --dc-inv-zoom, then cancel whatever vertical + // drift the reflow introduced so it stays put on screen. + let marker = null, markerY0 = 0; + if (k !== 1) { + const hit = document.elementFromPoint(cx, cy); + marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null; + if (marker) markerY0 = marker.getBoundingClientRect().top; + } + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + if (marker) { + // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any + // departure after the --dc-inv-zoom reflow is the layout drift. + const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k); + if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); } + } + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + // A deck-stage nested on the canvas owns plain scrolling — its + // thumbnail rail must stay natively scrollable, and panning a + // full-viewport fixed deck only strands it. The shadow DOM retargets + // rail events to the deck-stage host, so closest() sees it. ctrl/meta + // pinch stays ours: unprevented it would browser-zoom the page. + if (!(e.ctrlKey || e.metaKey) && e.target && e.target.closest && e.target.closest('deck-stage')) return; + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( + <div + ref={vpRef} + className="design-canvas" + style={{ + height: '100vh', width: '100vw', + background: DC.bg, + overflow: 'hidden', + overscrollBehavior: 'none', + touchAction: 'none', + position: 'relative', + fontFamily: DC.font, + boxSizing: 'border-box', + ...style, + }} + > + <div + ref={worldRef} + style={{ + position: 'absolute', top: 0, left: 0, + transformOrigin: '0 0', + willChange: 'transform', + width: 'max-content', minWidth: '100%', + minHeight: '100%', + padding: '60px 0 80px', + }} + > + <div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} /> + {children} + </div> + </div> + ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(dcFlatten(children)); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( + <div data-dc-section={sid} + style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}> + <div style={{ padding: '0 60px' }}> + <div className="dc-sectionhead" style={{ paddingBottom: 36 }}> + <DCEditable tag="div" value={sec.title ?? title} + onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>} + </div> + </div> + <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}> + {order.map((k) => ( + <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order} + label={(sec.labels || {})[k] ?? byId[k].props.label} + onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} + </div> + {rest} + </div> + ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / <img> / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/Packages/OpenDisplayDesignSystem/reference/ds/_ds_bundle.js b/Packages/OpenDisplayDesignSystem/reference/ds/_ds_bundle.js new file mode 100644 index 0000000..a6d9e12 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/reference/ds/_ds_bundle.js @@ -0,0 +1,1939 @@ +/* @ds-bundle: {"format":3,"namespace":"OpenDisplayDesignSystem_1a53d9","components":[{"name":"Button","sourcePath":"components/controls/Button.jsx"},{"name":"Checkbox","sourcePath":"components/controls/Checkbox.jsx"},{"name":"IconButton","sourcePath":"components/controls/IconButton.jsx"},{"name":"SegmentedControl","sourcePath":"components/controls/SegmentedControl.jsx"},{"name":"Select","sourcePath":"components/controls/Select.jsx"},{"name":"Slider","sourcePath":"components/controls/Slider.jsx"},{"name":"Stepper","sourcePath":"components/controls/Stepper.jsx"},{"name":"Switch","sourcePath":"components/controls/Switch.jsx"},{"name":"DisplayTile","sourcePath":"components/display/DisplayTile.jsx"},{"name":"Badge","sourcePath":"components/feedback/Badge.jsx"},{"name":"InlineBanner","sourcePath":"components/feedback/InlineBanner.jsx"},{"name":"Card","sourcePath":"components/layout/Card.jsx"},{"name":"Divider","sourcePath":"components/layout/Divider.jsx"},{"name":"Row","sourcePath":"components/layout/Row.jsx"}],"sourceHashes":{"assets/od-icons.js":"878599194f1f","components/controls/Button.jsx":"04555a51340a","components/controls/Checkbox.jsx":"4606dfc7c282","components/controls/IconButton.jsx":"6fd7cc7056dc","components/controls/SegmentedControl.jsx":"506fd8018593","components/controls/Select.jsx":"e9d8b13d8b5f","components/controls/Slider.jsx":"4439314c1462","components/controls/Stepper.jsx":"7dbb66a6cab2","components/controls/Switch.jsx":"5a05f75b5f25","components/display/DisplayTile.jsx":"f830e2914228","components/feedback/Badge.jsx":"0f8fe5186ab0","components/feedback/InlineBanner.jsx":"9f1f0c743eb4","components/layout/Card.jsx":"67974f96a065","components/layout/Divider.jsx":"eb8a412a73fe","components/layout/Row.jsx":"48f264e34fa9","ui_kits/menubar/MenuBarPopover.js":"a344c74a26b4","ui_kits/settings/SettingsWindow.jsx":"6f393855f0f8"},"inlinedExternals":[],"unexposedExports":[]} */ + +(() => { + +const __ds_ns = (window.OpenDisplayDesignSystem_1a53d9 = window.OpenDisplayDesignSystem_1a53d9 || {}); + +const __ds_scope = {}; + +(__ds_ns.__errors = __ds_ns.__errors || []); + +// assets/od-icons.js +try { (() => { +/* OpenDisplay UI-kit glyphs. + NOTE: macOS ships SF Symbols, which cannot be redistributed. These are + minimal line substitutes (1.6px stroke, rounded) used only inside the UI + kits. In production, swap for the matching SF Symbol. Registered globally + as window.ODIcons so any kit screen can use them without bundling. */ +(function () { + var h = React.createElement; + function svg(paths, vb) { + return function Icon(props) { + props = props || {}; + var size = props.size || 16; + return h("svg", { + width: size, + height: size, + viewBox: vb || "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: props.weight || 1.6, + strokeLinecap: "round", + strokeLinejoin: "round", + style: props.style, + "aria-hidden": "true" + }, paths.map(function (d, i) { + if (typeof d === "string") return h("path", { + key: i, + d: d + }); + return h(d.t, Object.assign({ + key: i + }, d.a)); + })); + }; + } + window.ODIcons = { + monitor: svg(["M3 4.5h18v12H3z", "M9 20.5h6", "M12 16.5v4"]), + monitorLines: svg(["M3 4.5h18v12H3z", "M9 20.5h6", "M12 16.5v4", "M6.5 8h7", "M6.5 11h4"]), + sunDim: svg([{ + t: "circle", + a: { + cx: 12, + cy: 12, + r: 3 + } + }, "M12 5.5v1M12 17.5v1M5.5 12h1M17.5 12h1"]), + sunMax: svg([{ + t: "circle", + a: { + cx: 12, + cy: 12, + r: 4 + } + }, "M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"]), + speaker: svg(["M4 9.5v5h3l4 3.5V6L7 9.5z"]), + speakerWave: svg(["M4 9.5v5h3l4 3.5V6L7 9.5z", "M15 9.5a4 4 0 0 1 0 5", "M17.5 7.5a7 7 0 0 1 0 9"]), + gear: svg([{ + t: "circle", + a: { + cx: 12, + cy: 12, + r: 3 + } + }, "M19 12a7 7 0 0 0-.1-1.2l1.7-1.3-1.6-2.8-2 .8a7 7 0 0 0-2-1.2l-.3-2.1H9.3L9 4.3a7 7 0 0 0-2 1.2l-2-.8L3.4 7.5l1.7 1.3A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-1.7 1.3 1.6 2.8 2-.8a7 7 0 0 0 2 1.2l.3 2.1h3.4l.3-2.1a7 7 0 0 0 2-1.2l2 .8 1.6-2.8-1.7-1.3c.1-.4.1-.8.1-1.2z"]), + info: svg([{ + t: "circle", + a: { + cx: 12, + cy: 12, + r: 9 + } + }, "M12 11v5", { + t: "circle", + a: { + cx: 12, + cy: 8, + r: 0.6, + fill: "currentColor", + stroke: "none" + } + }]), + chevronRight: svg(["M9.5 6l6 6-6 6"]), + chevronDown: svg(["M6 9.5l6 6 6-6"]), + mirror: svg(["M12 3v18", "M9 7L4 12l5 5", "M15 7l5 5-5 5"]), + rotate: svg(["M4 12a8 8 0 1 1 2.3 5.6", "M4 19v-4h4"]), + plus: svg(["M12 5v14M5 12h14"]), + lock: svg([{ + t: "rect", + a: { + x: 5, + y: 11, + width: 14, + height: 9, + rx: 2 + } + }, "M8 11V8a4 4 0 0 1 8 0v3"]), + bolt: svg(["M13 3L5 14h6l-1 7 8-11h-6l1-7z"]), + eject: svg(["M6 14h12L12 6 6 14z", "M6 18h12"]), + arrows: svg(["M7 8L4 11l3 3", "M4 11h16", "M17 16l3-3-3-3", "M20 13H4"]), + sparkles: svg(["M12 4l1.4 4.6L18 10l-4.6 1.4L12 16l-1.4-4.6L6 10l4.6-1.4z", "M18 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"]), + display2: svg([{ + t: "rect", + a: { + x: 2.5, + y: 5, + width: 13, + height: 9, + rx: 1.5 + } + }, { + t: "rect", + a: { + x: 15, + y: 8, + width: 6.5, + height: 5, + rx: 1 + } + }, "M7 18h4"]), + check: svg(["M5 12.5l4.5 4.5L19 7"]) + }; +})(); +})(); } catch (e) { __ds_ns.__errors.push({ path: "assets/od-icons.js", error: String((e && e.message) || e) }); } + +// components/controls/Button.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** + * macOS-style push button. Default `accent` is the filled system-blue + * button; `secondary` is the neutral bezeled button; `plain` is borderless. + */ +function Button({ + variant = "secondary", + size = "md", + destructive = false, + disabled = false, + icon = null, + children, + style = {}, + ...rest +}) { + const heights = { + sm: 20, + md: 24, + lg: 28 + }; + const pads = { + sm: "0 8px", + md: "0 12px", + lg: "0 14px" + }; + const fontSizes = { + sm: 11, + md: 13, + lg: 13 + }; + const base = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 5, + height: heights[size], + padding: pads[size], + fontFamily: "var(--font-text)", + fontSize: fontSizes[size], + fontWeight: "var(--weight-regular)", + lineHeight: 1, + borderRadius: "var(--radius-sm)", + border: "none", + cursor: disabled ? "default" : "pointer", + opacity: disabled ? 0.4 : 1, + whiteSpace: "nowrap", + userSelect: "none", + transition: "filter var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out)", + WebkitFontSmoothing: "antialiased" + }; + const variants = { + accent: { + background: destructive ? "var(--red)" : "var(--accent)", + color: "var(--accent-fg)", + fontWeight: "var(--weight-medium)", + boxShadow: "0 0.5px 1px rgba(0,0,0,0.18), inset 0 0.5px 0 rgba(255,255,255,0.25)" + }, + secondary: { + background: "var(--card-bg)", + color: destructive ? "var(--red)" : "var(--label-primary)", + boxShadow: "var(--shadow-control)" + }, + plain: { + background: "transparent", + color: destructive ? "var(--red)" : "var(--accent)" + } + }; + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + disabled: disabled, + style: { + ...base, + ...variants[variant], + ...style + }, + onMouseDown: e => !disabled && (e.currentTarget.style.filter = "brightness(0.93)"), + onMouseUp: e => e.currentTarget.style.filter = "", + onMouseLeave: e => e.currentTarget.style.filter = "" + }, rest), icon, children); +} +Object.assign(__ds_scope, { Button }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/controls/Button.jsx", error: String((e && e.message) || e) }); } + +// components/controls/Checkbox.jsx +try { (() => { +/** + * macOS checkbox. Filled system-blue with a white check when on. + * Controlled via `checked` / `onChange`. Optional `label`. + */ +function Checkbox({ + checked = false, + disabled = false, + onChange, + label, + style = {} +}) { + return /*#__PURE__*/React.createElement("label", { + style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + fontFamily: "var(--font-text)", + fontSize: 13, + color: "var(--label-primary)", + cursor: disabled ? "default" : "pointer", + opacity: disabled ? 0.4 : 1, + userSelect: "none", + ...style + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + role: "checkbox", + "aria-checked": checked, + disabled: disabled, + onClick: () => !disabled && onChange && onChange(!checked), + style: { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 14, + height: 14, + flex: "none", + padding: 0, + borderRadius: "var(--radius-xs)", + border: checked ? "none" : "0.5px solid var(--border-control)", + background: checked ? "var(--accent)" : "var(--card-bg)", + boxShadow: checked ? "none" : "var(--shadow-control)", + color: "var(--accent-fg)", + cursor: disabled ? "default" : "pointer" + } + }, checked && /*#__PURE__*/React.createElement("svg", { + width: "10", + height: "10", + viewBox: "0 0 10 10", + fill: "none" + }, /*#__PURE__*/React.createElement("path", { + d: "M2 5.2L4 7.2L8 2.8", + stroke: "currentColor", + strokeWidth: "1.6", + strokeLinecap: "round", + strokeLinejoin: "round" + }))), label); +} +Object.assign(__ds_scope, { Checkbox }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/controls/Checkbox.jsx", error: String((e && e.message) || e) }); } + +// components/controls/IconButton.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** + * Borderless square glyph button — toolbar/header affordance (gear, info, + * add). Shows a soft fill on hover and a tinted state when `active`. + */ +function IconButton({ + size = 24, + active = false, + disabled = false, + label, + children, + style = {}, + ...rest +}) { + const [hover, setHover] = React.useState(false); + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + "aria-label": label, + disabled: disabled, + onMouseEnter: () => setHover(true), + onMouseLeave: () => setHover(false), + style: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: size, + height: size, + padding: 0, + border: "none", + borderRadius: "var(--radius-sm)", + background: active ? "var(--accent-tint)" : hover && !disabled ? "var(--fill-quaternary)" : "transparent", + color: active ? "var(--accent)" : "var(--label-secondary)", + cursor: disabled ? "default" : "pointer", + opacity: disabled ? 0.4 : 1, + transition: "background var(--dur-fast) var(--ease-out)", + ...style + } + }, rest), children); +} +Object.assign(__ds_scope, { IconButton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/controls/IconButton.jsx", error: String((e && e.message) || e) }); } + +// components/controls/SegmentedControl.jsx +try { (() => { +/** + * macOS segmented control. `options` is an array of { value, label } or + * strings; the selected segment gets a raised white pill. + */ +function SegmentedControl({ + options = [], + value, + onChange, + size = "md", + disabled = false, + style = {} +}) { + const opts = options.map(o => typeof o === "string" ? { + value: o, + label: o + } : o); + const h = size === "sm" ? 20 : 24; + return /*#__PURE__*/React.createElement("div", { + role: "tablist", + style: { + display: "inline-flex", + height: h, + padding: 2, + gap: 2, + background: "var(--fill-tertiary)", + borderRadius: "var(--radius-sm)", + opacity: disabled ? 0.4 : 1, + ...style + } + }, opts.map((o, i) => { + const selected = o.value === value; + return /*#__PURE__*/React.createElement("button", { + key: o.value, + type: "button", + role: "tab", + "aria-selected": selected, + disabled: disabled, + onClick: () => !disabled && onChange && onChange(o.value), + style: { + position: "relative", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 4, + padding: "0 10px", + height: h - 4, + border: "none", + borderRadius: "var(--radius-xs)", + fontFamily: "var(--font-text)", + fontSize: size === "sm" ? 11 : 12, + fontWeight: selected ? "var(--weight-medium)" : "var(--weight-regular)", + color: "var(--label-primary)", + background: selected ? "var(--card-bg)" : "transparent", + boxShadow: selected ? "0 0.5px 1.5px rgba(0,0,0,0.16), 0 0 0 0.5px rgba(0,0,0,0.04)" : "none", + cursor: disabled ? "default" : "pointer", + transition: "background var(--dur-base) var(--ease-standard)", + whiteSpace: "nowrap" + } + }, o.label); + })); +} +Object.assign(__ds_scope, { SegmentedControl }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/controls/SegmentedControl.jsx", error: String((e && e.message) || e) }); } + +// components/controls/Select.jsx +try { (() => { +/** + * macOS pop-up button (Select). Renders a native-feeling bezeled control + * with the up/down chevrons. Uses a real {}} + options={["1280 × 832", "1496 × 967", "2056 × 1329", "2304 × 1496", "5120 × 2880"]} /> + + } + + {}} options={["Standard", "90°", "180°", "270°"]} /> + + + ); + } + + function UseAsCard() { + return ( + + {}} /> + + {}} options={["Studio Display", "Built-in Retina", "LG UltraFine 4K"]} /> + +
+ + ); + } + + // 8 — identify overlay + function ArrangeIdentify() { + return ( + }> +
+ +
+ {h(I.info, { size: 15 })} A large number is shown on each screen. Use this to pair identical displays before any disconnect. +
+
+
+ ); + } + + // 9 — disconnect confirmation sheet (modal over window) + function DisconnectSheet() { + return ( +
+
+ Connected} />}> +
+ +
+
+
+
+
+
+ {h(I.countdown, { size: 26 })}
+
+
+ Disconnect LG UltraFine 4K?
+
+ This removes it from the active layout. Studio Display and + Built-in Retina stay active as safe surfaces. Reverting automatically in + 9s.
+
+
+ {h(I.shieldCheck, { size: 16 })} + Recovery key ⌃⌥⌘R reconnects everything at any time. +
+
+ + +
+
+
+
+ ); + } + const kbd = { font: "var(--weight-medium) 11px/1 var(--font-mono)", background: "var(--fill-secondary)", + borderRadius: 4, padding: "2px 5px", color: "var(--label-primary)" }; + + window.ODSettingsA = { + DetailDefault, DetailScaled, DetailCountdown, DetailDegraded, DetailOffline, + Arrange, ArrangeMirror, ArrangeIdentify, DisconnectSheet, + }; +})(); diff --git a/Packages/OpenDisplayDesignSystem/reference/screens-settings-b.jsx b/Packages/OpenDisplayDesignSystem/reference/screens-settings-b.jsx new file mode 100644 index 0000000..67483d0 --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/reference/screens-settings-b.jsx @@ -0,0 +1,316 @@ +/* OpenDisplay screen-plan — settings window, part B: + Scenes, Automation, Health & Recovery, Recovery OSD, Labs, Add Virtual Display. + Exposes window.ODSettingsB. */ +(function () { + const DS = window.OpenDisplayDesignSystem_1a53d9; + const K = window.ODKit; + const I = window.ODIcons; + const { Card, Row, Divider, Switch, Select, SegmentedControl, Button, Badge, + Slider, InlineBanner, Checkbox } = DS; + const h = React.createElement; + + const muted = { font: "var(--weight-regular) 13px/1 var(--font-text)", color: "var(--label-secondary)", fontVariantNumeric: "tabular-nums" }; + const kbd = { font: "var(--weight-medium) 11px/1 var(--font-mono)", background: "var(--fill-secondary)", borderRadius: 4, padding: "2px 6px", color: "var(--label-primary)" }; + + function ListRow({ icon, tone, title, sub, trailing, last }) { + return ( +
+ {icon && } +
+
{title}
+ {sub &&
{sub}
} +
+ {trailing} +
+ ); + } + + // ---------- Scenes: empty ---------- + function ScenesEmpty() { + return ( + }> +
+
+ {h(I.scenes, { size: 30 })}
+
+
No scenes yet
+
+ A scene is a desired-state snapshot — arrangement, modes, and controls you can re-apply on demand or by trigger. +
+
+ +
+
+ ); + } + + // ---------- Scenes: list ---------- + function ScenesList() { + const scenes = [ + { name: "Work", sub: "3 displays · Studio main · 60 Hz", trig: "On dock", icon: I.monitorLines, applied: true }, + { name: "Movie", sub: "Studio only · HDR · others asleep", trig: "Hotkey ⌃⌥1", icon: I.bolt }, + { name: "Presentation", sub: "Mirror all · 1080p", trig: "Manual", icon: I.mirror }, + { name: "Travel", sub: "Built-in only · others disconnected", trig: "On undock", icon: I.disconnect }, + ]; + return ( + New Scene} />}> +
+ + {scenes.map((s, i) => ( + + {s.applied && Applied} + {s.trig} + +
} /> + ))} + +
+ Applying a scene shows a diff preview first. Triggers fire only after topology stabilizes and a safe surface exists. +
+ +
+ ); + } + + // ---------- Scene dry-run / diff preview ---------- + function SceneDryRun() { + const Op = ({ icon, label, detail, status, last }) => { + const map = { ok: ["var(--green)", "Will apply"], skip: ["var(--label-tertiary)", "Already satisfied"], + warn: ["var(--orange)", "Hardware-dependent"], no: ["var(--red)", "Unsupported"], exp: ["var(--orange)", "Experimental"] }; + const [c, txt] = map[status]; + return ( +
+ {h(icon, { size: 15 })} +
+
{label}
+
{detail}
+
+ + {txt} +
+ ); + }; + return ( + Dry run} />}> +
+
+ Comparing the current state with the scene. Satisfied steps are skipped; only the changes below will run, in order. +
+ + + + + + + + + + +
+ + 1 step can’t be applied and will be reported, not silently skipped. + + +
+
+
+ ); + } + + // ---------- Automation ---------- + function Automation() { + return ( + }> +
+ + ⌃⌥⌘R + + ⌃⌥⌘S + + Not set + + + {}} /> + + Apply Scene · Reconnect All · Set Brightness + + +
+
$ opendisplay scene apply Work
+
$ opendisplay reconnect --all
+
$ opendisplay list --json
+
+
+ + Disabled + + None issued + + + +
+
+ ); + } + + // ---------- Health & Recovery ---------- + function Health() { + return ( + 1 issue} />}> +
+
+ + +
+ + Healthy} /> + + + Reconnect} /> + + + OK} /> + Degraded} /> + OK} last /> + + + + + + + + +
+
+ ); + } + + // ---------- Recovery OSD (full-screen emergency) ---------- + function RecoveryOSD() { + const Target = ({ name, state }) => ( +
+ {h(I.monitor, { size: 16 })} + {name} + {state === "done" + ? {h(I.check, { size: 15 })} Restored + : {h(I.reconnect, { size: 15 })}} +
+ ); + return ( +
+
+
+
+ {h(I.shield, { size: 24 })}
+
+
Recovering your displays
+
+ A safe display disappeared during a change. Rolling back to the last checkpoint.
+
+
+
+ + + +
+
+ {h(I.keyboard, { size: 16 })} + This runs independently of the app. Press ⌃⌥⌘R to force-reconnect everything now. +
+ +
+
+ ); + } + + // ---------- Labs ---------- + function Labs() { + return ( + Experimental} />}> +
+ + + + + + {}} />
} /> + {}} />} /> + {}} />} last /> + + + + + Probed OK + + +
+ ); + } + + // ---------- Add Virtual Display ---------- + function AddVirtual() { + return ( + Labs} />}> +
+ + Virtual 4K + + {}} options={["30 Hz", "60 Hz"]} /> + + + + {}} + options={[{ value: "headless", label: "Headless" }, { value: "sidecar", label: "Sidecar" }, { value: "capture", label: "Capture" }]} /> + +
+
+ + +
+
+ + ); + } + + window.ODSettingsB = { + ScenesEmpty, ScenesList, SceneDryRun, Automation, Health, RecoveryOSD, Labs, AddVirtual, + }; +})(); diff --git a/Packages/OpenDisplayDesignSystem/reference/screens-shared.jsx b/Packages/OpenDisplayDesignSystem/reference/screens-shared.jsx new file mode 100644 index 0000000..62fccef --- /dev/null +++ b/Packages/OpenDisplayDesignSystem/reference/screens-shared.jsx @@ -0,0 +1,311 @@ +/* OpenDisplay screen-plan — shared shells & helpers. + Pulls DS primitives + glyphs from window; exposes window.ODKit. */ +(function () { + const DS = window.OpenDisplayDesignSystem_1a53d9; + const I = window.ODIcons; + const { Badge } = DS; + const h = React.createElement; + + /* ---------- macOS desktop backdrop (for chrome that floats) ---------- */ + function Desktop({ children, style }) { + return ( +
{children}
+ ); + } + + /* ---------- translucent menu bar with the OpenDisplay glyph lit ---------- */ + function MenuBar({ active }) { + const item = { opacity: 0.95 }; + return ( +
+ + File + EditViewWindow +
+ {h(I.monitor, { size: 15 })} + Sat 9:41 AM +
+
+ ); + } + + /* ---------- popover frame (320px, blurred panel) ---------- */ + function Popover({ children, connectedLabel, health }) { + return ( +
+
+ +
+
Displays
+
{connectedLabel}
+
+ {health} + {h(I.plus, { size: 16 })} + {h(I.gear, { size: 16 })} +
+ {children} +
+ ); + } + + const SECTION = { + font: "var(--weight-semibold) 11px/1 var(--font-text)", letterSpacing: "0.03em", + textTransform: "uppercase", color: "var(--label-tertiary)", padding: "2px 8px 6px", + }; + function SectionLabel({ children, trailing }) { + return ( +
+ {children}{trailing} +
+ ); + } + function HairDivider() { + return
; + } + + function GlyphTile({ icon, tone, size }) { + const bg = tone === "accent" ? "var(--accent)" : tone === "red" ? "rgba(255,59,48,0.14)" + : tone === "orange" ? "rgba(255,149,0,0.16)" : "var(--fill-secondary)"; + const fg = tone === "accent" ? "var(--accent-fg)" : tone === "red" ? "var(--red)" + : tone === "orange" ? "var(--orange)" : "var(--label-secondary)"; + return ( +
{h(icon, { size: size || 17 })}
+ ); + } + + function Dot({ color }) { + return ; + } + + function MBChip({ children, on, tone }) { + const bg = on ? "var(--accent-tint)" : tone === "warn" ? "rgba(255,149,0,0.14)" : "var(--fill-tertiary)"; + const fg = on ? "var(--accent)" : tone === "warn" ? "var(--orange)" : "var(--label-secondary)"; + return ( + {children} + ); + } + + function MBSliderRow({ icon, hi, value, sub, disabled }) { + return ( +
+ {h(icon, { size: 15 })} +
+ {}} + trailing={hi ? {h(hi, { size: 15 })} : null} /> +
+ {sub} +
+ ); + } + + /* ---------- menu-bar display block (covers every reachability state) ---------- */ + function MBDisplay({ d, expanded }) { + const st = d.state || "active"; + const trailing = + st === "offline" ? Offline + : st === "reconnecting" ? Reconnecting… + : st === "blackout" ? Blacked Out + : st === "asleep" ? Asleep + : st === "degraded" ? Degraded + : st === "ambiguous" ? Ambiguous + : d.main ? Main + : d.mirrored ? Mirrored + : ; + const tileIcon = st === "offline" ? I.disconnect : st === "blackout" ? I.blackout + : st === "asleep" ? I.moon : d.main ? I.monitorLines : I.monitor; + const tileTone = st === "offline" ? "neutral" : st === "degraded" || st === "ambiguous" ? "orange" + : d.main ? "accent" : "neutral"; + const sub = st === "offline" ? "Managed offline · " + (d.actor || "you") + " · " + (d.ago || "2m ago") + : st === "reconnecting" ? "Requesting back into topology…" + : st === "ambiguous" ? "Identity unconfirmed — 2 identical panels" + : d.res + " · " + d.hz + " Hz"; + return ( +
+
+ +
+
{d.name}
+
{sub}
+
+ {trailing} + {st !== "offline" && st !== "reconnecting" && + {h(I.chevronRight, { size: 15 })}} + {st === "offline" && + Reconnect} +
+ {expanded && st === "active" && ( +
+ + {d.audio && } +
+ {h(I.bolt, { size: 12 })} HDR + True Tone + {d.res} + {d.hz + " Hz"} +
+
+ + + +
+
+ )} +
+ ); + } + + function QuickAction({ icon, label, tone }) { + return ( + {h(icon, { size: 13 })}{label} + ); + } + + /* ---------- settings window shell ---------- */ + function WinTitleBar({ title }) { + const dot = (c) => ({ width: 12, height: 12, borderRadius: 99, background: c }); + return ( +
+ + {title} +
+ ); + } + + function SidebarItem({ icon, label, sub, active, badge, danger }) { + return ( +
+ + {h(icon, { size: 14 })} + + {label} + {sub && {sub}} + + {badge} +
+ ); + } + + // Full Core-1.0 sidebar. `active` matches a nav id. + function Sidebar({ active }) { + const displays = [ + { id: "studio", name: "Studio Display", sub: "2056 × 1329", icon: I.monitor, main: true }, + { id: "builtin", name: "Built-in Retina", sub: "1800 × 1169", icon: I.monitorLines }, + { id: "lg", name: "LG UltraFine 4K", sub: "Managed offline", icon: I.disconnect, offline: true }, + ]; + return ( +
+
Connected
+ {displays.map((d) => ( + {d.sub} : d.sub} + active={active === d.id} + badge={d.main ? Main + : d.offline ? : null} /> + ))} +
+ + + + } /> + +
+ +
+ {h(I.sparkles, { size: 13 })} OpenDisplay 1.0 · open source +
+
+ ); + } + + function Window({ title, active, header, children, contentBg, height }) { + return ( +
+ +
+ +
+
+ {header} +
{children}
+
+
+
+
+ ); + } + + function WinHeader({ title, badge }) { + return ( +
+

{title}

+
+ {badge} +
+ ); + } + + window.ODKit = { + Desktop, MenuBar, Popover, SectionLabel, HairDivider, GlyphTile, Dot, + MBChip, MBSliderRow, MBDisplay, QuickAction, + Window, WinHeader, Sidebar, SidebarItem, + }; +})(); diff --git a/Packages/ProviderInterfaces/Sources/ProviderInterfaces/ProviderContracts.swift b/Packages/ProviderInterfaces/Sources/ProviderInterfaces/ProviderContracts.swift new file mode 100644 index 0000000..4b56acb --- /dev/null +++ b/Packages/ProviderInterfaces/Sources/ProviderInterfaces/ProviderContracts.swift @@ -0,0 +1,87 @@ +import DisplayDomain +import Foundation + +/// The typed failure vocabulary every provider shares (PRD §9.9 failure semantics). A provider +/// can never report success itself; the coordinator verifies postconditions (D-010). +public enum ProviderFailure: Error, Equatable, Sendable { + case unsupported(reason: [CapabilityReason]) + case denied + case ambiguous(candidates: [DisplayRecordID]) + case busy + case timeout + case osRejected(code: Int) + case providerError(message: String) + case partial(message: String) + case unknown +} + +/// The probe result a provider returns for an environment (PRD §9.9 `probe`). Must not mutate. +public struct ProviderProbe: Hashable, Sendable { + public var providerID: String + public var status: CapabilityStatus + public var risk: RiskLevel + public var reasons: [CapabilityReason] + public var supportedOSRange: String? + + public init( + providerID: String, + status: CapabilityStatus, + risk: RiskLevel, + reasons: [CapabilityReason] = [], + supportedOSRange: String? = nil + ) { + self.providerID = providerID + self.status = status + self.risk = risk + self.reasons = reasons + self.supportedOSRange = supportedOSRange + } +} + +/// The environment a provider is asked to evaluate itself against (OS build, architecture, route…). +public struct ProviderEnvironment: Hashable, Sendable { + public var osBuild: String + public var isAppleSilicon: Bool + public var transport: ConnectionTransport + public var displayClass: DisplayClass + + public init(osBuild: String, isAppleSilicon: Bool, transport: ConnectionTransport, displayClass: DisplayClass) { + self.osBuild = osBuild + self.isAppleSilicon = isAppleSilicon + self.transport = transport + self.displayClass = displayClass + } +} + +/// Base provider behavior shared across all provider kinds. +public protocol DisplayProvider: Sendable { + var providerID: String { get } + /// Whether this provider relies on undocumented/private behavior and must be Labs-gated and + /// kept out of the public-API-only build (PRD §2.3, OSS-02). + var isExperimental: Bool { get } + /// Pure capability probe — must not mutate any display state (PRD §9.9). + func probe(_ environment: ProviderEnvironment) async -> ProviderProbe +} + +/// The lifecycle provider that performs logical connect/disconnect. The most safety-sensitive +/// contract in the system; isolated behind this protocol so it can be compiled, tested, disabled, +/// or replaced independently (PRD §9.9, §10.9, LIF-003/004). +public protocol LifecycleProvider: DisplayProvider { + /// Requests logical removal of `target` from the active topology before `deadline`. Cancellation + /// aware. Throws `ProviderFailure`; success is decided by the coordinator's verifier, not here. + func disconnect(_ target: DisplayRecordID, deadline: Date) async throws + + /// Requests reactivation. Must tolerate an already-active target and be idempotent. + func reconnect(_ target: DisplayRecordID, deadline: Date) async throws + + /// Best-effort emergency restoration usable with minimal dependencies (PRD §9.9 `recover`). + func recover(to checkpoint: Checkpoint) async throws +} + +/// Reads the normalized observed topology. Implemented on macOS by the DisplayRegistry's event +/// source; the coordinator depends only on this protocol so its logic stays platform-independent. +public protocol TopologyObserving: Sendable { + func currentSnapshot() async -> TopologySnapshot + /// Awaits the next stabilized topology generation correlated with a transaction (PRD §9.5). + func awaitStableGeneration(after generation: TopologyGeneration) async -> TopologySnapshot +} diff --git a/Packages/SceneEngine/Sources/SceneEngine/Scene.swift b/Packages/SceneEngine/Sources/SceneEngine/Scene.swift new file mode 100644 index 0000000..4dad735 --- /dev/null +++ b/Packages/SceneEngine/Sources/SceneEngine/Scene.swift @@ -0,0 +1,119 @@ +import DisplayDomain +import Foundation + +/// A named, partial desired state for displays. Omitted fields are left unchanged — a scene only +/// asserts what it explicitly sets (PRD §13.2, TOP-010). +public struct Scene: Hashable, Sendable, Codable, Identifiable { + public var id: String + public var name: String + public var schemaVersion: String + public var members: [Member] + public var policy: Policy + + public init( + id: String, + name: String, + schemaVersion: String = "1.0", + members: [Member], + policy: Policy = Policy() + ) { + self.id = id + self.name = name + self.schemaVersion = schemaVersion + self.members = members + self.policy = policy + } + + /// One participant in a scene: a selector, whether it is required, and the fields to assert. + public struct Member: Hashable, Sendable, Codable { + public var selector: String + public var required: Bool + public var desired: DesiredState + + public init(selector: String, required: Bool, desired: DesiredState) { + self.selector = selector + self.required = required + self.desired = desired + } + } + + public struct Policy: Hashable, Sendable, Codable { + public enum MissingOptional: String, Hashable, Sendable, Codable { + case continueApplying + case skip + } + public enum UnsupportedField: String, Hashable, Sendable, Codable { + case warn + case fail + } + public enum WindowPlacement: String, Hashable, Sendable, Codable { + case unchanged + case restore + } + + public var missingOptional: MissingOptional + public var unsupportedField: UnsupportedField + public var windowPlacement: WindowPlacement + public var rollbackOnRequiredFailure: Bool + + public init( + missingOptional: MissingOptional = .continueApplying, + unsupportedField: UnsupportedField = .warn, + windowPlacement: WindowPlacement = .unchanged, + rollbackOnRequiredFailure: Bool = true + ) { + self.missingOptional = missingOptional + self.unsupportedField = unsupportedField + self.windowPlacement = windowPlacement + self.rollbackOnRequiredFailure = rollbackOnRequiredFailure + } + } +} + +/// The independently-applied fields a scene member may assert. Each is optional; `nil` means +/// "leave as-is" (PRD §13.2 desired-state). +public struct DesiredState: Hashable, Sendable, Codable { + public var connected: Bool? + public var main: Bool? + public var position: DisplayOrigin? + public var relativePosition: RelativePosition? + public var mode: DisplayMode? + public var rotation: Rotation? + public var brightness: Double? + public var colorProfile: String? + public var hdr: Bool? + + public init( + connected: Bool? = nil, + main: Bool? = nil, + position: DisplayOrigin? = nil, + relativePosition: RelativePosition? = nil, + mode: DisplayMode? = nil, + rotation: Rotation? = nil, + brightness: Double? = nil, + colorProfile: String? = nil, + hdr: Bool? = nil + ) { + self.connected = connected + self.main = main + self.position = position + self.relativePosition = relativePosition + self.mode = mode + self.rotation = rotation + self.brightness = brightness + self.colorProfile = colorProfile + self.hdr = hdr + } + + public struct RelativePosition: Hashable, Sendable, Codable { + public var relativeToSelector: String + public var edge: DisplaySelector.TopologyEdge + public var gap: Int + + public init(relativeToSelector: String, edge: DisplaySelector.TopologyEdge, gap: Int = 0) { + self.relativeToSelector = relativeToSelector + self.edge = edge + self.gap = gap + } + } +} diff --git a/Packages/SceneEngine/Sources/SceneEngine/ScenePlanner.swift b/Packages/SceneEngine/Sources/SceneEngine/ScenePlanner.swift new file mode 100644 index 0000000..65f7baf --- /dev/null +++ b/Packages/SceneEngine/Sources/SceneEngine/ScenePlanner.swift @@ -0,0 +1,207 @@ +import DisplayDomain +import Foundation + +/// A single planned change produced by diffing a scene's desired state against the observed +/// topology. Used both for the dry-run/diff preview (TOP-011, AUT-11) and for actual application. +public struct PlannedOperation: Hashable, Sendable, Codable { + public enum Kind: String, Hashable, Sendable, Codable { + case reconnect + case disconnect + case setMain + case setPosition + case setMode + case setRotation + case setBrightness + case setColorProfile + case setHDR + case createMirror + } + + /// Whether this op will run, is already satisfied (skipped for idempotency), or can't apply. + public enum Status: String, Hashable, Sendable, Codable { + case willApply + case alreadySatisfied + case unsupported + case experimental + case hardwareDependent + } + + public var kind: Kind + public var target: DisplayRecordID + public var detail: String + public var status: Status + public var risk: RiskLevel + + public init(kind: Kind, target: DisplayRecordID, detail: String, status: Status, risk: RiskLevel = .normal) { + self.kind = kind + self.target = target + self.detail = detail + self.status = status + self.risk = risk + } +} + +/// The full dry-run plan for applying a scene. +public struct ScenePlan: Hashable, Sendable, Codable { + public var sceneID: String + public var generation: TopologyGeneration + public var operations: [PlannedOperation] + public var missingRequired: [String] + public var missingOptional: [String] + + public init( + sceneID: String, + generation: TopologyGeneration, + operations: [PlannedOperation], + missingRequired: [String] = [], + missingOptional: [String] = [] + ) { + self.sceneID = sceneID + self.generation = generation + self.operations = operations + self.missingRequired = missingRequired + self.missingOptional = missingOptional + } + + /// A required member could not be resolved → application must be blocked (TOP-014). + public var isBlocked: Bool { !missingRequired.isEmpty } + + /// Idempotency check: a fully-satisfied scene produces no actionable operations (TOP-013). + public var hasWork: Bool { operations.contains { $0.status == .willApply } } +} + +/// Pure, deterministic scene planner. Given resolved member→record mappings and an observed +/// snapshot, it produces an ordered, idempotent plan. Ordering follows PRD §10.7: connect +/// destinations → main/position/mode/rotation → controls → disconnect retiring displays last. +public struct ScenePlanner: Sendable { + public init() {} + + /// Resolution of each member selector to a concrete record (or `nil` if unresolved/absent). + public typealias Resolution = [String: DisplayRecordID] + + public func plan(scene: Scene, snapshot: TopologySnapshot, resolution: Resolution) -> ScenePlan { + var operations: [PlannedOperation] = [] + var missingRequired: [String] = [] + var missingOptional: [String] = [] + + // Stable member order keeps the plan deterministic regardless of input ordering. + let orderedMembers = scene.members.sorted { $0.selector < $1.selector } + + for member in orderedMembers { + guard let recordID = resolution[member.selector] else { + if member.required { missingRequired.append(member.selector) } + else { missingOptional.append(member.selector) } + continue + } + let observed = snapshot.observation(for: recordID) + operations.append(contentsOf: plannedOperations(for: member.desired, target: recordID, observed: observed)) + } + + operations = ordered(operations) + return ScenePlan( + sceneID: scene.id, + generation: snapshot.generation, + operations: operations, + missingRequired: missingRequired, + missingOptional: missingOptional + ) + } + + private func plannedOperations(for desired: DesiredState, + target: DisplayRecordID, + observed: DisplayObservation?) -> [PlannedOperation] { + var ops: [PlannedOperation] = [] + + if let connected = desired.connected { + let isActive = observed?.isActive ?? false + if connected && !isActive { + ops.append(.init(kind: .reconnect, target: target, detail: "Reconnect display", + status: .willApply, risk: .recoveryCritical)) + } else if !connected && isActive { + ops.append(.init(kind: .disconnect, target: target, detail: "Logically disconnect", + status: .willApply, risk: .recoveryCritical)) + } else { + ops.append(.init(kind: connected ? .reconnect : .disconnect, target: target, + detail: connected ? "Already connected" : "Already offline", + status: .alreadySatisfied, + risk: .recoveryCritical)) + } + } + + if let main = desired.main, main { + let isMain = observed?.isMain ?? false + ops.append(.init(kind: .setMain, target: target, + detail: "Use as main display", + status: isMain ? .alreadySatisfied : .willApply)) + } + + if let position = desired.position { + let satisfied = observed?.origin == position + ops.append(.init(kind: .setPosition, target: target, + detail: "Move to (\(position.x), \(position.y))", + status: satisfied ? .alreadySatisfied : .willApply)) + } + + if let mode = desired.mode { + let satisfied = observed?.mode == mode + ops.append(.init(kind: .setMode, target: target, + detail: "\(mode.pointWidth) × \(mode.pointHeight) @ \(Int(mode.refreshHz)) Hz", + status: satisfied ? .alreadySatisfied : .willApply)) + } + + if let rotation = desired.rotation { + let satisfied = observed?.rotation == rotation + ops.append(.init(kind: .setRotation, target: target, + detail: "Rotate \(rotation.rawValue)°", + status: satisfied ? .alreadySatisfied : .willApply)) + } + + if let brightness = desired.brightness { + ops.append(.init(kind: .setBrightness, target: target, + detail: "Brightness \(Int(brightness))%", + status: .willApply, risk: .hardwareDependent)) + } + + if let profile = desired.colorProfile { + let satisfied = observed?.colorProfileName == profile + ops.append(.init(kind: .setColorProfile, target: target, + detail: "Color profile “\(profile)”", + status: satisfied ? .alreadySatisfied : .willApply)) + } + + if let hdr = desired.hdr { + let satisfied = observed?.hdrEnabled == hdr + ops.append(.init(kind: .setHDR, target: target, + detail: hdr ? "Enable HDR" : "Disable HDR", + status: satisfied ? .alreadySatisfied : .willApply, + risk: hdr ? .experimental : .normal)) + } + + return ops + } + + /// Safe operation ordering (PRD §10.7, TOP-012): reconnects first, then layout/mode/controls, + /// and disconnects strictly last so a safe surface is always established before any removal. + private func ordered(_ ops: [PlannedOperation]) -> [PlannedOperation] { + func rank(_ kind: PlannedOperation.Kind) -> Int { + switch kind { + case .reconnect: return 0 + case .createMirror: return 1 + case .setMain: return 2 + case .setPosition: return 3 + case .setMode: return 4 + case .setRotation: return 5 + case .setColorProfile: return 6 + case .setHDR: return 7 + case .setBrightness: return 8 + case .disconnect: return 9 + } + } + return ops.enumerated() + .sorted { lhs, rhs in + let lr = rank(lhs.element.kind), rr = rank(rhs.element.kind) + return lr == rr ? lhs.offset < rhs.offset : lr < rr + } + .map(\.element) + } +} diff --git a/Packages/SceneEngine/Sources/SceneEngine/SceneRecorder.swift b/Packages/SceneEngine/Sources/SceneEngine/SceneRecorder.swift new file mode 100644 index 0000000..c65b4b5 --- /dev/null +++ b/Packages/SceneEngine/Sources/SceneEngine/SceneRecorder.swift @@ -0,0 +1,51 @@ +import DisplayDomain +import Foundation + +/// Captures the live arrangement into a `Scene` and resolves a scene's member selectors back to +/// records for planning (PRD §13.2). Pure + deterministic, so it is covered by `make test`. +public enum SceneRecorder { + /// Snapshots the current arrangement as a scene: one member per observed display, selected by + /// its stable record id, asserting the current connected / main / position / mode / rotation. + /// Members are optional so a later apply skips (rather than blocks on) a now-absent display. + public static func capture(from snapshot: TopologySnapshot, name: String, id: String) -> Scene { + let members = snapshot.observations + .sorted { $0.recordID.rawValue < $1.recordID.rawValue } + .map { observation in + Scene.Member( + selector: "id:\(observation.recordID.rawValue)", + required: false, + desired: DesiredState( + connected: observation.isActive, + main: observation.isMain ? true : nil, + position: observation.origin, + mode: observation.mode, + rotation: observation.rotation + ) + ) + } + return Scene(id: id, name: name, members: members) + } + + /// Resolves `id:`/`main`/`builtin` member selectors against a snapshot. Registry-backed + /// selectors (`alias:`/`tag:`) are resolved by the caller that owns the registry (the CLI/app), + /// which can pass a richer resolution into `ScenePlanner`. + public static func resolution(for scene: Scene, in snapshot: TopologySnapshot) -> ScenePlanner.Resolution { + var resolution: ScenePlanner.Resolution = [:] + for member in scene.members { + let selector = member.selector + if selector.hasPrefix("id:") { + let recordID = DisplayRecordID(rawValue: String(selector.dropFirst("id:".count))) + if snapshot.observation(for: recordID) != nil { resolution[selector] = recordID } + } else if selector == "main" { + if let observation = snapshot.observations.first(where: { $0.isMain }) { + resolution[selector] = observation.recordID + } + } else if selector == "builtin" { + if let observation = snapshot.observations.first(where: { $0.displayClass == .builtIn }) { + resolution[selector] = observation.recordID + } + } + } + return resolution + } +} diff --git a/Packages/SceneEngine/Sources/SceneEngine/SceneStore.swift b/Packages/SceneEngine/Sources/SceneEngine/SceneStore.swift new file mode 100644 index 0000000..e764017 --- /dev/null +++ b/Packages/SceneEngine/Sources/SceneEngine/SceneStore.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Persistence backend for saved scenes. +public protocol SceneStoring: Sendable { + func load() async -> [Scene] + func save(_ scenes: [Scene]) async +} + +/// In-memory scene store for tests and previews. +public actor InMemorySceneStore: SceneStoring { + private var scenes: [Scene] + public init(_ scenes: [Scene] = []) { self.scenes = scenes } + public func load() -> [Scene] { scenes } + public func save(_ scenes: [Scene]) { self.scenes = scenes } +} + +/// Atomic JSON store at `/scenes.json` (pure Foundation; covered by `make test`). +public struct DiskSceneStore: SceneStoring { + private let fileURL: URL + + public init(directory: URL) { + self.fileURL = directory.appendingPathComponent("scenes.json") + } + + public static func defaultDirectory( + appName: String = "OpenDisplay", + fileManager: FileManager = .default + ) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return base.appendingPathComponent(appName, isDirectory: true) + } + + public func load() async -> [Scene] { + guard let data = try? Data(contentsOf: fileURL), + let scenes = try? JSONDecoder().decode([Scene].self, from: data) else { return [] } + return scenes + } + + public func save(_ scenes: [Scene]) async { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? encoder.encode(scenes).write(to: fileURL, options: .atomic) + } +} + +/// CRUD over a scene store, upserting by scene id. The single owner of saved scenes for the app/CLI. +public actor SceneLibrary { + private var scenes: [Scene] + private let store: any SceneStoring + + public init(store: any SceneStoring) async { + self.store = store + self.scenes = await store.load() + } + + public func all() -> [Scene] { scenes.sorted { $0.name < $1.name } } + public func scene(named name: String) -> Scene? { scenes.first { $0.name == name } } + public func scene(id: String) -> Scene? { scenes.first { $0.id == id } } + + public func save(_ scene: Scene) async { + if let index = scenes.firstIndex(where: { $0.id == scene.id }) { + scenes[index] = scene + } else { + scenes.append(scene) + } + await store.save(scenes) + } + + public func delete(id: String) async { + scenes.removeAll { $0.id == id } + await store.save(scenes) + } +} diff --git a/Packages/SceneEngine/Tests/SceneEngineTests/ScenePlannerTests.swift b/Packages/SceneEngine/Tests/SceneEngineTests/ScenePlannerTests.swift new file mode 100644 index 0000000..ad19fd6 --- /dev/null +++ b/Packages/SceneEngine/Tests/SceneEngineTests/ScenePlannerTests.swift @@ -0,0 +1,88 @@ +import XCTest +import DisplayDomain +@testable import SceneEngine + +final class ScenePlannerTests: XCTestCase { + private let center = DisplayRecordID(rawValue: "disp_center") + private let left = DisplayRecordID(rawValue: "disp_left") + private let builtin = DisplayRecordID(rawValue: "disp_builtin") + + private func observation(_ id: DisplayRecordID, active: Bool, main: Bool = false, origin: DisplayOrigin = .zero) -> DisplayObservation { + DisplayObservation(recordID: id, isActive: active, origin: origin, isMain: main, generation: .initial) + } + + func testFullySatisfiedSceneHasNoWork() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + observation(center, active: true, main: true), + observation(left, active: true, origin: DisplayOrigin(x: -1920, y: 0)) + ]) + let scene = Scene(id: "studio", name: "Studio", members: [ + .init(selector: "alias:Center", required: true, desired: DesiredState(connected: true, main: true)), + .init(selector: "alias:Left", required: true, + desired: DesiredState(connected: true, position: DisplayOrigin(x: -1920, y: 0))) + ]) + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, + resolution: ["alias:Center": center, "alias:Left": left]) + XCTAssertFalse(plan.hasWork, "An already-satisfied scene must produce no actionable operations (TOP-013).") + XCTAssertTrue(plan.operations.allSatisfy { $0.status == .alreadySatisfied }) + } + + func testDisconnectIsOrderedLastAndReconnectFirst() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + observation(center, active: true, main: true), + observation(builtin, active: true), + observation(left, active: false) + ]) + let scene = Scene(id: "work", name: "Work", members: [ + .init(selector: "builtin", required: false, desired: DesiredState(connected: false)), + .init(selector: "alias:Left", required: true, desired: DesiredState(connected: true)), + .init(selector: "alias:Center", required: true, desired: DesiredState(main: true)) + ]) + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, + resolution: ["builtin": builtin, "alias:Left": left, "alias:Center": center]) + let kinds = plan.operations.map(\.kind) + let reconnectIndex = kinds.firstIndex(of: .reconnect) + let disconnectIndex = kinds.firstIndex(of: .disconnect) + XCTAssertNotNil(reconnectIndex) + XCTAssertNotNil(disconnectIndex) + XCTAssertLessThan(reconnectIndex!, disconnectIndex!, + "Reconnect must come before disconnect so a safe surface exists first (§10.7).") + } + + func testMissingRequiredMemberBlocksPlan() { + let snapshot = TopologySnapshot(generation: .initial, observations: [observation(center, active: true)]) + let scene = Scene(id: "x", name: "X", members: [ + .init(selector: "alias:Missing", required: true, desired: DesiredState(connected: true)) + ]) + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, resolution: [:]) + XCTAssertTrue(plan.isBlocked) + XCTAssertEqual(plan.missingRequired, ["alias:Missing"]) + } + + func testMissingOptionalMemberDoesNotBlock() { + let snapshot = TopologySnapshot(generation: .initial, observations: [observation(center, active: true)]) + let scene = Scene(id: "x", name: "X", members: [ + .init(selector: "alias:Center", required: true, desired: DesiredState(main: true)), + .init(selector: "builtin", required: false, desired: DesiredState(connected: false)) + ]) + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, resolution: ["alias:Center": center]) + XCTAssertFalse(plan.isBlocked) + XCTAssertEqual(plan.missingOptional, ["builtin"]) + } + + func testPlanIsDeterministicRegardlessOfMemberOrder() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + observation(center, active: true), observation(left, active: false) + ]) + let membersA: [Scene.Member] = [ + .init(selector: "alias:Center", required: true, desired: DesiredState(main: true)), + .init(selector: "alias:Left", required: true, desired: DesiredState(connected: true)) + ] + let resolution = ["alias:Center": center, "alias:Left": left] + let planA = ScenePlanner().plan(scene: Scene(id: "s", name: "S", members: membersA), + snapshot: snapshot, resolution: resolution) + let planB = ScenePlanner().plan(scene: Scene(id: "s", name: "S", members: membersA.reversed()), + snapshot: snapshot, resolution: resolution) + XCTAssertEqual(planA.operations, planB.operations) + } +} diff --git a/Packages/SceneEngine/Tests/SceneEngineTests/SceneRecorderTests.swift b/Packages/SceneEngine/Tests/SceneEngineTests/SceneRecorderTests.swift new file mode 100644 index 0000000..78e0786 --- /dev/null +++ b/Packages/SceneEngine/Tests/SceneEngineTests/SceneRecorderTests.swift @@ -0,0 +1,54 @@ +import XCTest +import DisplayDomain +@testable import SceneEngine + +final class SceneRecorderTests: XCTestCase { + private func obs(_ id: String, active: Bool = true, main: Bool = false, x: Int = 0, y: Int = 0) -> DisplayObservation { + DisplayObservation(recordID: .init(rawValue: id), isActive: active, + origin: .init(x: x, y: y), isMain: main, generation: .initial) + } + + func testCaptureThenPlanIsIdempotent() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("cg:A", main: true), obs("cg:B", x: 1920) + ]) + let scene = SceneRecorder.capture(from: snapshot, name: "Desk", id: "scene_1") + let resolution = SceneRecorder.resolution(for: scene, in: snapshot) + XCTAssertEqual(resolution.count, 2) + + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, resolution: resolution) + XCTAssertFalse(plan.hasWork) + XCTAssertTrue(plan.operations.allSatisfy { $0.status == .alreadySatisfied }) + XCTAssertTrue(plan.missingRequired.isEmpty) + } + + func testPlanDetectsAMovedDisplay() { + let atCapture = TopologySnapshot(generation: .initial, observations: [ + obs("cg:A", main: true), obs("cg:B", x: 1920) + ]) + let scene = SceneRecorder.capture(from: atCapture, name: "Desk", id: "scene_1") + + // "cg:B" has since moved to a different origin. + let now = TopologySnapshot(generation: .initial, observations: [ + obs("cg:A", main: true), obs("cg:B", x: 800) + ]) + let plan = ScenePlanner().plan(scene: scene, snapshot: now, + resolution: SceneRecorder.resolution(for: scene, in: now)) + XCTAssertTrue(plan.hasWork) + XCTAssertTrue(plan.operations.contains { $0.kind == .setPosition && $0.status == .willApply }) + } + + func testAbsentDisplayIsMissingOptionalNotBlocking() { + let atCapture = TopologySnapshot(generation: .initial, observations: [ + obs("cg:A", main: true), obs("cg:B", x: 1920) + ]) + let scene = SceneRecorder.capture(from: atCapture, name: "Desk", id: "scene_1") + + // "cg:B" is gone now → optional miss, not a block. + let now = TopologySnapshot(generation: .initial, observations: [obs("cg:A", main: true)]) + let plan = ScenePlanner().plan(scene: scene, snapshot: now, + resolution: SceneRecorder.resolution(for: scene, in: now)) + XCTAssertFalse(plan.isBlocked) + XCTAssertEqual(plan.missingOptional, ["id:cg:B"]) + } +} diff --git a/Packages/SceneEngine/Tests/SceneEngineTests/SceneStoreTests.swift b/Packages/SceneEngine/Tests/SceneEngineTests/SceneStoreTests.swift new file mode 100644 index 0000000..aef48df --- /dev/null +++ b/Packages/SceneEngine/Tests/SceneEngineTests/SceneStoreTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import SceneEngine + +final class SceneStoreTests: XCTestCase { + private func scene(_ id: String, _ name: String) -> Scene { + Scene(id: id, name: name, members: []) + } + + func testLibrarySaveLookupDelete() async { + let library = await SceneLibrary(store: InMemorySceneStore()) + await library.save(scene("s1", "Desk")) + let named = await library.scene(named: "Desk") + XCTAssertEqual(named?.id, "s1") + await library.delete(id: "s1") + let all = await library.all() + XCTAssertTrue(all.isEmpty) + } + + func testUpsertByID() async { + let library = await SceneLibrary(store: InMemorySceneStore()) + await library.save(scene("s1", "Desk")) + await library.save(scene("s1", "Desk Renamed")) + let all = await library.all() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all.first?.name, "Desk Renamed") + } + + func testPersistsAcrossInstances() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("od-scene-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let first = await SceneLibrary(store: DiskSceneStore(directory: directory)) + await first.save(scene("s1", "Desk")) + + let second = await SceneLibrary(store: DiskSceneStore(directory: directory)) + let all = await second.all() + XCTAssertEqual(all.map(\.id), ["s1"]) + } +} diff --git a/Packages/SimulatorProvider/Sources/SimulatorProvider/SimulatedDisplaySystem.swift b/Packages/SimulatorProvider/Sources/SimulatorProvider/SimulatedDisplaySystem.swift new file mode 100644 index 0000000..85834cd --- /dev/null +++ b/Packages/SimulatorProvider/Sources/SimulatorProvider/SimulatedDisplaySystem.swift @@ -0,0 +1,131 @@ +import DisplayDomain +import Foundation +import ProviderInterfaces + +/// Faults that can be injected to drive the recovery/verification paths in tests +/// (PRD §15.2 provider-contract + fault-injection layers, T-006/T-008/T-013). +public struct SimulatedFaults: Sendable { + /// If set, `disconnect` throws this failure (simulating a provider error). + public var disconnectFailure: ProviderFailure? + /// If `true`, `disconnect` returns without actually removing the display, so verification + /// of postconditions must fail and trigger rollback (T-006 style). + public var disconnectSilentlyNoOps: Bool + /// If `true`, `recover` throws — simulating a failed rollback that degrades. + public var recoverFails: Bool + /// Extra, unrelated displays the (buggy/OS) provider also drops during `disconnect` — used to + /// exercise the "no unexpected endpoint lost" postcondition (PRD §9.4). + public var alsoDisconnect: [DisplayRecordID] + + public init( + disconnectFailure: ProviderFailure? = nil, + disconnectSilentlyNoOps: Bool = false, + recoverFails: Bool = false, + alsoDisconnect: [DisplayRecordID] = [] + ) { + self.disconnectFailure = disconnectFailure + self.disconnectSilentlyNoOps = disconnectSilentlyNoOps + self.recoverFails = recoverFails + self.alsoDisconnect = alsoDisconnect + } + + public static let none = SimulatedFaults() +} + +/// A deterministic, in-memory display topology that conforms to both `LifecycleProvider` and +/// `TopologyObserving`, so the platform-independent coordinator can be exercised end to end with +/// no macOS frameworks or real hardware. +public actor SimulatedDisplaySystem: LifecycleProvider, TopologyObserving { + public nonisolated let providerID = "simulator.lifecycle.v1" + public nonisolated let isExperimental = true + + private var observations: [DisplayObservation] + private var managedOffline: [ManagedOfflineRecord] + private var generation: TopologyGeneration + private var faults: SimulatedFaults + + public init( + observations: [DisplayObservation], + managedOffline: [ManagedOfflineRecord] = [], + generation: TopologyGeneration = .initial, + faults: SimulatedFaults = .none + ) { + self.observations = observations + self.managedOffline = managedOffline + self.generation = generation + self.faults = faults + } + + public func setFaults(_ faults: SimulatedFaults) { + self.faults = faults + } + + // MARK: TopologyObserving + + public func currentSnapshot() -> TopologySnapshot { + TopologySnapshot(generation: generation, observations: observations, managedOffline: managedOffline) + } + + public func awaitStableGeneration(after generation: TopologyGeneration) -> TopologySnapshot { + currentSnapshot() + } + + // MARK: DisplayProvider + + public func probe(_ environment: ProviderEnvironment) -> ProviderProbe { + ProviderProbe( + providerID: providerID, + status: environment.isAppleSilicon ? .supported : .unknown, + risk: .experimental, + reasons: environment.isAppleSilicon ? [] : [.architecture] + ) + } + + // MARK: LifecycleProvider + + public func disconnect(_ target: DisplayRecordID, deadline: Date) async throws { + if let failure = faults.disconnectFailure { throw failure } + if faults.disconnectSilentlyNoOps { return } // provider "succeeds" but state is unchanged + + guard let index = observations.firstIndex(where: { $0.recordID == target }) else { + throw ProviderFailure.ambiguous(candidates: []) + } + bumpGeneration() + observations[index].isActive = false + observations[index].generation = generation + managedOffline.append( + ManagedOfflineRecord(displayID: target, actor: .ui, reason: "simulated", providerID: providerID) + ) + // Simulate a faulty provider/OS path that also drops unrelated displays. + for extra in faults.alsoDisconnect { + if let i = observations.firstIndex(where: { $0.recordID == extra }) { + observations[i].isActive = false + observations[i].generation = generation + } + } + } + + public func reconnect(_ target: DisplayRecordID, deadline: Date) async throws { + guard let index = observations.firstIndex(where: { $0.recordID == target }) else { + throw ProviderFailure.ambiguous(candidates: []) + } + bumpGeneration() + observations[index].isActive = true + observations[index].generation = generation + managedOffline.removeAll { $0.displayID == target } + } + + public func recover(to checkpoint: Checkpoint) async throws { + if faults.recoverFails { throw ProviderFailure.providerError(message: "simulated recover failure") } + bumpGeneration() + observations = checkpoint.observations.map { + var copy = $0 + copy.generation = generation + return copy + } + managedOffline = checkpoint.managedOffline + } + + private func bumpGeneration() { + generation = generation.next() + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/AuditLog.swift b/Packages/TopologyCore/Sources/TopologyCore/AuditLog.swift new file mode 100644 index 0000000..8e9c9d8 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/AuditLog.swift @@ -0,0 +1,102 @@ +import DisplayDomain +import Foundation + +/// One recorded lifecycle command for the activity/audit trail (AUT-010): who did what, when, to +/// which displays, and how it ended. Stable + Codable so the rescue utility and diagnostics can +/// read history. +public struct AuditEntry: Hashable, Sendable, Codable { + public var timestamp: Date + public var actor: Actor + public var command: String + public var transactionId: String + public var status: String + public var targets: [String] + + public init( + timestamp: Date, actor: Actor, command: String, + transactionId: String, status: String, targets: [String] + ) { + self.timestamp = timestamp + self.actor = actor + self.command = command + self.transactionId = transactionId + self.status = status + self.targets = targets + } +} + +/// Append-only activity trail. The coordinator/gateway records every command here. +public protocol AuditLogging: Sendable { + func append(_ entry: AuditEntry) async + func recent(limit: Int) async -> [AuditEntry] +} + +/// In-memory audit trail for tests and previews. +public actor InMemoryAuditLog: AuditLogging { + private var entries: [AuditEntry] = [] + public init() {} + public func append(_ entry: AuditEntry) { entries.append(entry) } + public func recent(limit: Int) -> [AuditEntry] { Array(entries.suffix(limit)) } + public var all: [AuditEntry] { entries } +} + +/// Append-only, rescue-readable audit log: one JSON object per line (JSONL) in Application Support. +/// A torn final line from a crash is skipped on read, never breaking the rest of the history. +/// Pure Foundation, so it lives in the cross-platform core and is exercised by `make test`. +public struct DiskAuditLog: AuditLogging { + private let fileURL: URL + + public init(directory: URL) { + self.fileURL = directory.appendingPathComponent("audit.jsonl") + } + + public static func defaultDirectory( + appName: String = "OpenDisplay", + fileManager: FileManager = .default + ) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return base.appendingPathComponent(appName, isDirectory: true) + } + + public func append(_ entry: AuditEntry) async { + guard let encoded = try? Self.encoder().encode(entry) else { return } + var line = encoded + line.append(0x0A) // newline + let fileManager = FileManager.default + try? fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + if let handle = try? FileHandle(forWritingTo: fileURL) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: line) + } else { + try? line.write(to: fileURL, options: .atomic) + } + } + + public func recent(limit: Int) async -> [AuditEntry] { + guard let data = try? Data(contentsOf: fileURL), + let text = String(data: data, encoding: .utf8) else { return [] } + let decoder = Self.decoder() + let entries = text.split(separator: "\n").compactMap { line in + try? decoder.decode(AuditEntry.self, from: Data(line.utf8)) + } + return Array(entries.suffix(limit)) + } + + private static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] // single line per entry — no pretty printing + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/CommandGateway.swift b/Packages/TopologyCore/Sources/TopologyCore/CommandGateway.swift new file mode 100644 index 0000000..6b17cf0 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/CommandGateway.swift @@ -0,0 +1,156 @@ +import AutomationSchema +import DisplayDomain +import Foundation +import ProviderInterfaces + +/// The single entry point every external command surface (UI, CLI, App Intents, local HTTP) goes +/// through (PRD §10 CommandGateway / AutomationGateway). It owns one `TopologyCoordinator`, so all +/// commands share the same serialized, safety-checked, audited path, and it translates the +/// coordinator's internal `LifecycleResult` into the stable, versioned `ResultEnvelope` that every +/// automation surface returns — keeping that mapping in one tested place instead of duplicated per +/// surface. Platform-independent: callers inject the concrete observer/lifecycle providers. +public actor CommandGateway { + private let observer: TopologyObserving + private let lifecycle: LifecycleProvider + private let coordinator: TopologyCoordinator + private let safety: SafetyEngine + private let auditLog: (any AuditLogging)? + + public init( + observer: TopologyObserving, + lifecycleProvider: LifecycleProvider, + checkpoints: CheckpointStoring, + safety: SafetyEngine = SafetyEngine(), + auditLog: (any AuditLogging)? = nil, + recoveryServiceHealthy: @escaping @Sendable () async -> Bool = { true }, + confirm: @escaping ConfirmationHandler = { _, _ in false } + ) { + self.observer = observer + self.lifecycle = lifecycleProvider + self.safety = safety + self.auditLog = auditLog + self.coordinator = TopologyCoordinator( + observer: observer, + lifecycleProvider: lifecycleProvider, + checkpoints: checkpoints, + safety: safety, + recoveryServiceHealthy: recoveryServiceHealthy, + confirm: confirm + ) + } + + // MARK: - Commands + + /// Reconnects every managed-offline display and returns a per-target envelope. + public func reconnectAll(actor: Actor = .ui) async -> ResultEnvelope { + let results = await coordinator.reconnectAll() + let snapshot = await observer.currentSnapshot() + let targets = results + .sorted { $0.key.rawValue < $1.key.rawValue } + .map { id, ok in + ResultEnvelope.TargetResult( + displayId: id.rawValue, alias: nil, identityConfidence: 1.0, + operations: [.init(field: "reconnect", verification: ok ? .verified : .readBackUnavailable)] + ) + } + let status: ResultEnvelope.Status = results.isEmpty + ? .noOp + : (results.values.allSatisfy { $0 } ? .committed : .partial) + let envelope = ResultEnvelope( + transactionId: "txn_reconnectAll", status: status, actor: actor, + requestedAt: Date(), topologyGeneration: snapshot.generation.value, targets: targets + ) + await record(envelope, command: "reconnectAll") + return envelope + } + + /// Runs a logical disconnect through the full staged transaction and returns its envelope. + public func disconnect(_ target: DisplayRecordID, options: DisconnectOptions) async -> ResultEnvelope { + let envelope: ResultEnvelope + do { + let result = try await coordinator.disconnect(target, options: options) + let after = await observer.currentSnapshot() + envelope = Self.envelope(for: result, target: target, actor: options.actor, generation: after.generation.value) + } catch { + let snapshot = await observer.currentSnapshot() + envelope = ResultEnvelope( + transactionId: "txn_disconnect", status: .failed, actor: options.actor, + requestedAt: Date(), topologyGeneration: snapshot.generation.value, + errors: [.init(code: "coordinatorError", message: "\(error)")] + ) + } + await record(envelope, command: "disconnect") + return envelope + } + + private func record(_ envelope: ResultEnvelope, command: String) async { + await auditLog?.append(AuditEntry( + timestamp: envelope.requestedAt, actor: envelope.actor, command: command, + transactionId: envelope.transactionId, status: envelope.status.rawValue, + targets: envelope.targets.map(\.displayId) + )) + } + + /// Preview a disconnect's preflight decision without mutating anything (`--dry-run`, confirm UI). + public func preflightDisconnect( + _ target: DisplayRecordID, + identityConfidence: Double, + recoveryServiceHealthy: Bool = true, + isFirstUseForRoute: Bool = false + ) async -> PreflightOutcome { + let snapshot = await observer.currentSnapshot() + let decision = safety.preflightDisconnect( + target: target, snapshot: snapshot, identityConfidence: identityConfidence, + recoveryServiceHealthy: recoveryServiceHealthy, isFirstUseForRoute: isFirstUseForRoute + ) + switch decision { + case .allowed(let surface): + return PreflightOutcome(decision: .allowed, safeSurface: surface, reasons: []) + case .needsConfirmation(let surface, let reasons): + return PreflightOutcome(decision: .needsConfirmation, safeSurface: surface, reasons: reasons.map(\.rawValue)) + case .blocked(let reasons): + return PreflightOutcome(decision: .blocked, safeSurface: nil, reasons: reasons.map(\.rawValue)) + } + } + + public struct PreflightOutcome: Hashable, Sendable { + public enum Decision: String, Hashable, Sendable { case allowed, needsConfirmation, blocked } + public var decision: Decision + public var safeSurface: DisplayRecordID? + public var reasons: [String] + } + + // MARK: - Result mapping + + /// Translates a coordinator `LifecycleResult` into a stable `ResultEnvelope`. Internal so tests + /// can assert the mapping directly. + static func envelope( + for result: LifecycleResult, target: DisplayRecordID, actor: Actor, generation: UInt64 + ) -> ResultEnvelope { + func make(_ status: ResultEnvelope.Status, _ transactionId: String, + verification: VerificationState = .notApplicable, + errors: [ResultEnvelope.ErrorInfo] = []) -> ResultEnvelope { + ResultEnvelope( + transactionId: transactionId, status: status, actor: actor, requestedAt: Date(), + topologyGeneration: generation, + targets: [.init(displayId: target.rawValue, alias: nil, identityConfidence: 1.0, + operations: [.init(field: "disconnect", verification: verification)])], + errors: errors + ) + } + switch result { + case .committed(let tx, let verification): + return make(.committed, tx.rawValue, verification: verification) + case .noOp(let tx): + return make(.noOp, tx.rawValue) + case .cancelled(let tx): + return make(.noOp, tx.rawValue, errors: [.init(code: "cancelled", message: "confirmation declined")]) + case .rolledBack(let tx, let recovered): + return make(.rolledBack, tx.rawValue, errors: [.init(code: "rolledBack", message: "recovered=\(recovered)")]) + case .failed(let tx, let failure): + return make(.failed, tx.rawValue, errors: [.init(code: "providerFailure", message: "\(failure)")]) + case .blocked(let reasons): + return make(.failed, "txn_blocked", errors: reasons.map { .init(code: "blocked", message: $0.rawValue) }) + } + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/DiskCheckpointStore.swift b/Packages/TopologyCore/Sources/TopologyCore/DiskCheckpointStore.swift new file mode 100644 index 0000000..aec4c74 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/DiskCheckpointStore.swift @@ -0,0 +1,74 @@ +import DisplayDomain +import Foundation + +/// Atomic, rescue-readable, on-disk `CheckpointStoring` (PRD §10.8, §9.4, DIA-008). +/// +/// Each checkpoint is written as a self-contained JSON file under `/checkpoints/`, and +/// the most recent one is also mirrored to `/latest.json` so the independent rescue +/// process can find the last-known-safe state by reading a single well-known file — no scanning, +/// no shared in-process state, no secrets. Writes go through `Data.write(options: .atomic)` (temp +/// file + rename) so a crash mid-write can never leave a torn checkpoint. +/// +/// Pure Foundation, so it lives in the cross-platform core and is exercised by `make test`; the +/// macOS app and the rescue utility both point it at the same Application Support directory. +public struct DiskCheckpointStore: CheckpointStoring { + private let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + /// The shared Application Support location both the app and the rescue utility use. Creating + /// the store does no I/O; this resolves (and creates) the base directory. + public static func defaultDirectory( + appName: String = "OpenDisplay", + fileManager: FileManager = .default + ) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return base.appendingPathComponent(appName, isDirectory: true) + } + + public func writeAtomic(_ checkpoint: Checkpoint) async throws { + let checkpointsDir = directory.appendingPathComponent("checkpoints", isDirectory: true) + try FileManager.default.createDirectory(at: checkpointsDir, withIntermediateDirectories: true) + let data = try Self.encoder().encode(checkpoint) + try data.write(to: fileURL(for: checkpoint.id, in: checkpointsDir), options: .atomic) + // Mirror to the well-known pointer the rescue process reads first. + try data.write(to: directory.appendingPathComponent("latest.json"), options: .atomic) + } + + public func restore(_ id: CheckpointID) async throws -> Checkpoint? { + let checkpointsDir = directory.appendingPathComponent("checkpoints", isDirectory: true) + let url = fileURL(for: id, in: checkpointsDir) + guard let data = try? Data(contentsOf: url) else { return nil } + return try Self.decoder().decode(Checkpoint.self, from: data) + } + + public func latest() async -> Checkpoint? { + let url = directory.appendingPathComponent("latest.json") + guard let data = try? Data(contentsOf: url) else { return nil } + return try? Self.decoder().decode(Checkpoint.self, from: data) + } + + // MARK: - Private + + private func fileURL(for id: CheckpointID, in checkpointsDir: URL) -> URL { + checkpointsDir.appendingPathComponent("\(id.rawValue).json") + } + + private static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + // Stable, human-inspectable output for the rescue utility and diffs. + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + private static func decoder() -> JSONDecoder { + JSONDecoder() + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/DisplayRegistry.swift b/Packages/TopologyCore/Sources/TopologyCore/DisplayRegistry.swift new file mode 100644 index 0000000..6141d64 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/DisplayRegistry.swift @@ -0,0 +1,216 @@ +import DisplayDomain +import Foundation + +/// Persisted registry state: the remembered display records plus a same-Mac fast-path index from +/// the (stable on this machine) CG display UUID to a record. The UUID index is a routing hint, not +/// portable identity — cross-machine recognition relies on the scored fingerprint. +public struct RegistryState: Hashable, Sendable, Codable { + public var records: [DisplayRecord] + public var cgUUIDIndex: [String: DisplayRecordID] + + public init(records: [DisplayRecord] = [], cgUUIDIndex: [String: DisplayRecordID] = [:]) { + self.records = records + self.cgUUIDIndex = cgUUIDIndex + } +} + +/// Persistence backend for the registry. +public protocol RegistryStoring: Sendable { + func load() async -> RegistryState + func save(_ state: RegistryState) async +} + +/// In-memory store for tests and previews. +public actor InMemoryRegistryStore: RegistryStoring { + private var state: RegistryState + public init(_ state: RegistryState = RegistryState()) { self.state = state } + public func load() -> RegistryState { state } + public func save(_ state: RegistryState) { self.state = state } +} + +/// Atomic JSON store at `/registry.json` (pure Foundation; covered by `make test`). +public struct DiskRegistryStore: RegistryStoring { + private let fileURL: URL + + public init(directory: URL) { + self.fileURL = directory.appendingPathComponent("registry.json") + } + + public static func defaultDirectory( + appName: String = "OpenDisplay", + fileManager: FileManager = .default + ) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return base.appendingPathComponent(appName, isDirectory: true) + } + + public func load() async -> RegistryState { + guard let data = try? Data(contentsOf: fileURL), + let state = try? JSONDecoder().decode(RegistryState.self, from: data) else { + return RegistryState() + } + return state + } + + public func save(_ state: RegistryState) async { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? encoder.encode(state).write(to: fileURL, options: .atomic) + } +} + +/// The source of remembered display identity (PRD §10.5, REG-003/004/005). Resolves a live +/// observation's fingerprint to a stable `DisplayRecord` — recognizing a display we've seen before +/// or minting a new record — and owns user-attached alias/tag/pairing edits. Resolution order: +/// 1. exact EDID serial match (definitive), +/// 2. same-Mac CG-UUID fast path, +/// 3. best scored fingerprint match above the recognition threshold (`IdentityScorer`), +/// 4. otherwise mint a new record. +/// Two identical monitors with no serial therefore stay distinct until paired, rather than being +/// silently merged. +public actor DisplayRegistry { + private var state: RegistryState + private let store: any RegistryStoring + private let recognitionThreshold: Double + + public init(store: any RegistryStoring, recognitionThreshold: Double = 0.5) async { + self.store = store + self.recognitionThreshold = recognitionThreshold + self.state = await store.load() + } + + public func allRecords() -> [DisplayRecord] { state.records } + + public func record(for id: DisplayRecordID) -> DisplayRecord? { + state.records.first { $0.id == id } + } + + /// Resolves a fingerprint (+ optional CG UUID) to a stable record, recognizing or minting. + public func resolve( + fingerprint: DisplayFingerprint, + cgUUID: String?, + displayClass: DisplayClass = .unknown, + now: Date = Date() + ) async -> DisplayRecord { + let record = resolveLocked(fingerprint: fingerprint, cgUUID: cgUUID, displayClass: displayClass, now: now) + await persist() + return record + } + + /// Batched resolve: recognizes/mints every input against in-memory state, then persists EXACTLY + /// once. Per-input behaviour (serial/cgUUID/scored/mint, recency + fingerprint merge) is identical + /// to `resolve`; only the disk write is coalesced, so N-displays-per-refresh no longer means N + /// full-registry JSON writes. + public func resolveAll( + _ inputs: [(fingerprint: DisplayFingerprint, cgUUID: String?, displayClass: DisplayClass)], + now: Date = Date() + ) async -> [DisplayRecord] { + let records = inputs.map { + resolveLocked(fingerprint: $0.fingerprint, cgUUID: $0.cgUUID, displayClass: $0.displayClass, now: now) + } + if !inputs.isEmpty { await persist() } + return records + } + + /// The recognize-or-mint logic plus the recency/fingerprint mutation, WITHOUT persisting. Callers + /// persist once after applying every mutation they intend to (see `resolve` / `resolveAll`). + private func resolveLocked( + fingerprint: DisplayFingerprint, + cgUUID: String?, + displayClass: DisplayClass, + now: Date + ) -> DisplayRecord { + // 1. Exact serial match. + if let serial = fingerprint.serialNumber ?? fingerprint.serialHash, + let match = state.records.first(where: { + ($0.fingerprint.serialNumber ?? $0.fingerprint.serialHash) == serial + }) { + return touchLocked(match.id, fingerprint: fingerprint, cgUUID: cgUUID, now: now) + } + + // 2. Same-Mac CG-UUID fast path. + if let cgUUID, let id = state.cgUUIDIndex[cgUUID], record(for: id) != nil { + return touchLocked(id, fingerprint: fingerprint, cgUUID: cgUUID, now: now) + } + + // 3. Best scored fingerprint match. + let best = state.records + .map { ($0, IdentityScorer.score(observed: fingerprint, candidate: $0).score) } + .max { $0.1 < $1.1 } + if let best, best.1 >= recognitionThreshold { + return touchLocked(best.0.id, fingerprint: fingerprint, cgUUID: cgUUID, now: now) + } + + // 4. Mint a new record. + let record = DisplayRecord( + id: .generate(now: now), fingerprint: fingerprint, displayClass: displayClass, lastSeen: now + ) + state.records.append(record) + if let cgUUID { state.cgUUIDIndex[cgUUID] = record.id } + return record + } + + public func setAlias(_ alias: String?, for id: DisplayRecordID) async { + try? await mutate(id) { $0.alias = alias?.isEmpty == true ? nil : alias } + } + + public func addTag(_ tag: String, to id: DisplayRecordID) async { + try? await mutate(id) { $0.tags.insert(tag) } + } + + public func removeTag(_ tag: String, from id: DisplayRecordID) async { + try? await mutate(id) { $0.tags.remove(tag) } + } + + /// Marks a record as an explicit user pairing, lifting its identity confidence (REG-004). + public func confirmPairing(for id: DisplayRecordID) async { + try? await mutate(id) { $0.pairingConfirmed = true } + } + + // MARK: - Private + + private enum RegistryError: Error { case unknownRecord } + + private func mutate(_ id: DisplayRecordID, _ change: (inout DisplayRecord) -> Void) async throws { + guard let index = state.records.firstIndex(where: { $0.id == id }) else { + throw RegistryError.unknownRecord + } + change(&state.records[index]) + await persist() + } + + private func touchLocked( + _ id: DisplayRecordID, fingerprint: DisplayFingerprint, cgUUID: String?, now: Date + ) -> DisplayRecord { + let index = state.records.firstIndex { $0.id == id }! + state.records[index].fingerprint = Self.merged(state.records[index].fingerprint, fingerprint) + state.records[index].lastSeen = now + if let cgUUID { state.cgUUIDIndex[cgUUID] = id } + return state.records[index] + } + + private func persist() async { + await store.save(state) + } + + /// Fills gaps in `base` from `new` without dropping evidence we already had. + private static func merged(_ base: DisplayFingerprint, _ new: DisplayFingerprint) -> DisplayFingerprint { + DisplayFingerprint( + vendorID: new.vendorID ?? base.vendorID, + productID: new.productID ?? base.productID, + serialNumber: new.serialNumber ?? base.serialNumber, + serialHash: new.serialHash ?? base.serialHash, + modelName: new.modelName ?? base.modelName, + manufactureYear: new.manufactureYear ?? base.manufactureYear, + manufactureWeek: new.manufactureWeek ?? base.manufactureWeek, + physicalWidthMM: new.physicalWidthMM ?? base.physicalWidthMM, + physicalHeightMM: new.physicalHeightMM ?? base.physicalHeightMM, + edidHash: new.edidHash ?? base.edidHash + ) + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/InMemoryCheckpointStore.swift b/Packages/TopologyCore/Sources/TopologyCore/InMemoryCheckpointStore.swift new file mode 100644 index 0000000..83f9db8 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/InMemoryCheckpointStore.swift @@ -0,0 +1,25 @@ +import DisplayDomain +import Foundation + +/// A simple in-memory `CheckpointStoring` implementation. Useful for SwiftUI previews, the +/// composition root before a disk-backed store exists, and tests. The production store is +/// atomic and rescue-readable on disk (PRD §10.8) and lands in M0. +public actor InMemoryCheckpointStore: CheckpointStoring { + private var byID: [CheckpointID: Checkpoint] = [:] + private var latestID: CheckpointID? + + public init() {} + + public func writeAtomic(_ checkpoint: Checkpoint) async throws { + byID[checkpoint.id] = checkpoint + latestID = checkpoint.id + } + + public func restore(_ id: CheckpointID) async throws -> Checkpoint? { + byID[id] + } + + public func latest() async -> Checkpoint? { + latestID.flatMap { byID[$0] } + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/SafetyEngine.swift b/Packages/TopologyCore/Sources/TopologyCore/SafetyEngine.swift new file mode 100644 index 0000000..92cdc2d --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/SafetyEngine.swift @@ -0,0 +1,112 @@ +import DisplayDomain +import Foundation + +/// The non-bypassable preflight authority for destructive lifecycle actions. Automation cannot +/// route around it (PRD §10.3 SafetyEngine boundary). Pure and deterministic so it is fully +/// unit-testable against generated topologies. +public struct SafetyEngine: Sendable { + public init() {} + + public enum Decision: Equatable, Sendable { + /// Safe to proceed without extra confirmation. + case allowed(safeSurface: DisplayRecordID) + /// Allowed only behind a first-use / elevated-risk countdown confirmation (LIF-006). + case needsConfirmation(safeSurface: DisplayRecordID, reasons: [Reason]) + /// Must not proceed on the default path (LIF-003, §9.2 invariants 3/4). + case blocked(reasons: [Reason]) + + public var isBlocked: Bool { + if case .blocked = self { return true } + return false + } + + public var safeSurface: DisplayRecordID? { + switch self { + case .allowed(let surface), .needsConfirmation(let surface, _): return surface + case .blocked: return nil + } + } + } + + public enum Reason: String, Equatable, Sendable { + case noSafeSurface + case wouldRemoveLastSafeDisplay + case identityBelowThreshold + case targetIsCurrentMain + case recoveryServiceUnhealthy + case firstUseForRoute + case ambiguousIdentity + } + + /// Computes a safe surface: an active, non-mirrored display with a stable identity that is not + /// in the disconnect target set and is not itself slated to disappear (PRD §9.6). + public func safeSurface(in snapshot: TopologySnapshot, + excluding targets: Set) -> DisplayRecordID? { + let candidates = snapshot.activeDisplays.filter { observation in + !targets.contains(observation.recordID) + && !observation.isMirrored + && observation.overlayIsRecoverable + } + // Prefer the built-in panel, then the current main, then any stable candidate. Deterministic + // ordering keeps preflight reproducible across runs. + if let builtIn = candidates.first(where: { $0.displayClass == .builtIn }) { + return builtIn.recordID + } + if let main = candidates.first(where: { $0.isMain }) { + return main.recordID + } + return candidates.sorted { $0.recordID.rawValue < $1.recordID.rawValue }.first?.recordID + } + + /// Preflight a single logical disconnect (PRD §9.4 "Preflight safety", §9.2 invariants). + public func preflightDisconnect( + target: DisplayRecordID, + snapshot: TopologySnapshot, + identityConfidence: Double, + recoveryServiceHealthy: Bool, + isFirstUseForRoute: Bool, + confidenceThreshold: Double = IdentityConfidence.destructiveThreshold + ) -> Decision { + var blocking: [Reason] = [] + var confirmations: [Reason] = [] + + guard recoveryServiceHealthy else { + return .blocked(reasons: [.recoveryServiceUnhealthy]) + } + + // Invariant 3/§9.6: there must be a safe surface left after removing the target. + guard let surface = safeSurface(in: snapshot, excluding: [target]) else { + // Distinguish "removing the last safe display" from "no safe surface at all". + let activeOthers = snapshot.activeDisplays.filter { $0.recordID != target } + blocking.append(activeOthers.isEmpty ? .wouldRemoveLastSafeDisplay : .noSafeSurface) + return .blocked(reasons: blocking) + } + + // Invariant 4 (LIF-004): identity must clear the destructive threshold or be confirmed. + if identityConfidence < confidenceThreshold { + confirmations.append(.identityBelowThreshold) + } + + // Disconnecting the current main requires moving the main/recovery role first (LIF-017, + // §9.10): allowed, but always confirmed. + if snapshot.observation(for: target)?.isMain == true { + confirmations.append(.targetIsCurrentMain) + } + + if isFirstUseForRoute { + confirmations.append(.firstUseForRoute) + } + + return confirmations.isEmpty + ? .allowed(safeSurface: surface) + : .needsConfirmation(safeSurface: surface, reasons: confirmations) + } +} + +private extension DisplayObservation { + /// A blacked-out or filtered surface can't be relied on for recovery feedback unless the + /// recovery path removes overlays (PRD §9.6). Treated conservatively here. + var overlayIsRecoverable: Bool { + overlay == .visible || overlay == .dimmed + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/SettingsStore.swift b/Packages/TopologyCore/Sources/TopologyCore/SettingsStore.swift new file mode 100644 index 0000000..ac1436c --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/SettingsStore.swift @@ -0,0 +1,83 @@ +import DisplayDomain +import Foundation + +/// User-tunable app settings persisted as JSON (PRD §10.8 SettingsStore). Kept small, Codable, and +/// versioned-by-tolerance: unknown keys are ignored and missing keys fall back to the defaults, so +/// older/newer settings files load without error. +public struct OpenDisplaySettings: Hashable, Sendable, Codable { + /// Default reconnect behavior applied to managed-offline displays (D-005). + public var persistencePolicy: PersistencePolicy + /// Countdown shown before a confirmed (risky) disconnect proceeds (LIF-006). + public var confirmationCountdownSeconds: Int + /// Whether the global Reconnect-All hotkey is registered. + public var reconnectAllHotkeyEnabled: Bool + + public init( + persistencePolicy: PersistencePolicy = .reconnectOnQuit, + confirmationCountdownSeconds: Int = 5, + reconnectAllHotkeyEnabled: Bool = true + ) { + self.persistencePolicy = persistencePolicy + self.confirmationCountdownSeconds = confirmationCountdownSeconds + self.reconnectAllHotkeyEnabled = reconnectAllHotkeyEnabled + } + + public static let `default` = OpenDisplaySettings() + + private enum CodingKeys: String, CodingKey { + case persistencePolicy, confirmationCountdownSeconds, reconnectAllHotkeyEnabled + } + + /// Tolerant decoder: every missing key falls back to its default and unknown keys are ignored, + /// so settings files survive schema changes in either direction. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = OpenDisplaySettings.default + persistencePolicy = try container.decodeIfPresent(PersistencePolicy.self, forKey: .persistencePolicy) + ?? defaults.persistencePolicy + confirmationCountdownSeconds = try container.decodeIfPresent(Int.self, forKey: .confirmationCountdownSeconds) + ?? defaults.confirmationCountdownSeconds + reconnectAllHotkeyEnabled = try container.decodeIfPresent(Bool.self, forKey: .reconnectAllHotkeyEnabled) + ?? defaults.reconnectAllHotkeyEnabled + } +} + +/// Atomic, on-disk store for `OpenDisplaySettings`. Pure Foundation, so it lives in the +/// cross-platform core and is exercised by `make test`; the app points it at Application Support. +public struct SettingsStore: Sendable { + private let fileURL: URL + + public init(directory: URL) { + self.fileURL = directory.appendingPathComponent("settings.json") + } + + /// The shared Application Support location (same folder the checkpoints use). + public static func defaultDirectory( + appName: String = "OpenDisplay", + fileManager: FileManager = .default + ) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return base.appendingPathComponent(appName, isDirectory: true) + } + + /// Returns persisted settings, or `.default` when the file is absent or unreadable — so first + /// run and a corrupt file both degrade to sane defaults instead of failing. + public func load() -> OpenDisplaySettings { + guard let data = try? Data(contentsOf: fileURL), + let settings = try? JSONDecoder().decode(OpenDisplaySettings.self, from: data) else { + return .default + } + return settings + } + + public func save(_ settings: OpenDisplaySettings) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(settings).write(to: fileURL, options: .atomic) + } +} diff --git a/Packages/TopologyCore/Sources/TopologyCore/TopologyCoordinator.swift b/Packages/TopologyCore/Sources/TopologyCore/TopologyCoordinator.swift new file mode 100644 index 0000000..01ffb41 --- /dev/null +++ b/Packages/TopologyCore/Sources/TopologyCore/TopologyCoordinator.swift @@ -0,0 +1,235 @@ +import DisplayDomain +import Foundation +import ProviderInterfaces + +/// Persists last-known-safe checkpoints. On macOS this is backed by an atomic, rescue-readable +/// store; the coordinator depends only on this protocol (PRD §10.8 CheckpointStore, DIA-008). +public protocol CheckpointStoring: Sendable { + func writeAtomic(_ checkpoint: Checkpoint) async throws + func restore(_ id: CheckpointID) async throws -> Checkpoint? + func latest() async -> Checkpoint? +} + +/// Options for a disconnect request. +public struct DisconnectOptions: Sendable { + public var actor: Actor + public var reason: String + public var identityConfidence: Double + public var isFirstUseForRoute: Bool + public var deadline: Date + public var persistencePolicy: PersistencePolicy + + public init( + actor: Actor, + reason: String = "user requested", + identityConfidence: Double, + isFirstUseForRoute: Bool = false, + deadline: Date = Date().addingTimeInterval(10), + persistencePolicy: PersistencePolicy = .reconnectOnQuit + ) { + self.actor = actor + self.reason = reason + self.identityConfidence = identityConfidence + self.isFirstUseForRoute = isFirstUseForRoute + self.deadline = deadline + self.persistencePolicy = persistencePolicy + } +} + +/// The outcome of a lifecycle transaction. Provider success alone is never `committed` — the +/// coordinator verifies observed postconditions first (PRD D-010). +public enum LifecycleResult: Equatable, Sendable { + case committed(TransactionID, verification: VerificationState) + case noOp(TransactionID) + case blocked([SafetyEngine.Reason]) + case cancelled(TransactionID) + case rolledBack(TransactionID, recovered: Bool) + case failed(TransactionID, ProviderFailure) +} + +/// Asks the user to confirm a risky action behind a countdown on the safe surface (LIF-006). +/// Returns `true` to proceed. In tests this is injected to auto-confirm or auto-cancel. +public typealias ConfirmationHandler = @Sendable (_ safeSurface: DisplayRecordID, _ reasons: [SafetyEngine.Reason]) async -> Bool + +public enum CoordinatorError: Error, Equatable, Sendable { + case busy + case illegalTransition(from: TransactionState, to: TransactionState) +} + +/// The single serialized owner of every topology/lifecycle write (PRD §10.3, §9.2 invariant 1). +/// Actor isolation guarantees at most one in-flight transaction; recovery preempts ordinary work. +public actor TopologyCoordinator { + private let observer: TopologyObserving + private let lifecycleProvider: LifecycleProvider + private let checkpoints: CheckpointStoring + private let safety: SafetyEngine + private let confirm: ConfirmationHandler + private let recoveryServiceHealthy: @Sendable () async -> Bool + + private var state: TransactionState = .idle + /// The state path of the most recent transaction, exposed for audit/testing (PRD §10.4). + public private(set) var lastTransition: [TransactionState] = [] + + public init( + observer: TopologyObserving, + lifecycleProvider: LifecycleProvider, + checkpoints: CheckpointStoring, + safety: SafetyEngine = SafetyEngine(), + recoveryServiceHealthy: @escaping @Sendable () async -> Bool = { true }, + // Fail-safe default: with no explicit handler, every `.needsConfirmation` preflight is + // *cancelled* rather than silently approved (LIF-006). Production callers must supply a + // real countdown/confirmation handler to allow risky disconnects to proceed. + confirm: @escaping ConfirmationHandler = { _, _ in false } + ) { + self.observer = observer + self.lifecycleProvider = lifecycleProvider + self.checkpoints = checkpoints + self.safety = safety + self.recoveryServiceHealthy = recoveryServiceHealthy + self.confirm = confirm + } + + public var currentState: TransactionState { state } + + /// Logically disconnects `target`, following the §9.4 staged transaction. Throws `CoordinatorError.busy` + /// if another transaction is in flight (exclusivity, invariant 1). + public func disconnect(_ target: DisplayRecordID, options: DisconnectOptions) async throws -> LifecycleResult { + guard state.isTerminal || state == .idle else { throw CoordinatorError.busy } + let txID = TransactionID.generate() + beginTransaction() + + // 1. Resolve. + try transition(to: .resolving) + let snapshot = await observer.currentSnapshot() + guard let observation = snapshot.observation(for: target) else { + // Idempotent: already managed-offline → no-op; otherwise it's system-absent (failed). + if snapshot.managedOffline.contains(where: { $0.displayID == target }) { + try transition(to: .failed) // terminal; treated as benign no-op + return .noOp(txID) + } + try transition(to: .failed) + return .failed(txID, .ambiguous(candidates: [])) + } + + // 2. Preflight safety (non-bypassable). + try transition(to: .preflight) + let healthy = await recoveryServiceHealthy() + let decision = safety.preflightDisconnect( + target: target, + snapshot: snapshot, + identityConfidence: options.identityConfidence, + recoveryServiceHealthy: healthy, + isFirstUseForRoute: options.isFirstUseForRoute + ) + // A `.blocked` preflight is a hard stop and cannot be bypassed here (§9.2 invariants 3/9): + // e.g. an unhealthy recovery service or removing the last safe display. An advanced + // "disconnect all" override (§9.10) requires an independently verified remote recovery + // surface and is a separate, future mechanism — never a boolean that skips these checks. + if case .blocked(let reasons) = decision { + try transition(to: .failed) + return .blocked(reasons) + } + + // 3. Checkpoint (atomic, before any provider call — invariant 2). + let checkpoint = Checkpoint( + transactionID: txID, + generation: snapshot.generation, + observations: snapshot.observations, + mainDisplayID: snapshot.activeDisplays.first(where: { $0.isMain })?.recordID, + managedOffline: snapshot.managedOffline + ) + do { + try await checkpoints.writeAtomic(checkpoint) + } catch { + try transition(to: .failed) + return .failed(txID, .providerError(message: "checkpoint write failed")) + } + try transition(to: .checkpointed) + + // 4. Confirm if required. + if case .needsConfirmation(let surface, let reasons) = decision { + let proceed = await confirm(surface, reasons) + guard proceed else { + try transition(to: .failed) + return .cancelled(txID) + } + } + + // 5. Apply. + try transition(to: .applying) + do { + try await lifecycleProvider.disconnect(target, deadline: options.deadline) + } catch let failure as ProviderFailure { + return await rollback(txID, checkpoint: checkpoint, failure: failure) + } catch { + return await rollback(txID, checkpoint: checkpoint, failure: .unknown) + } + + // 6. Observe the resulting stabilized generation. + try transition(to: .observing) + let after = await observer.awaitStableGeneration(after: snapshot.generation) + + // 7. Verify postconditions (§9.4): the target became inactive, a safe surface remains, AND + // no *other* previously-active display was unexpectedly lost. A provider/OS path that + // drops an unrelated endpoint alongside the target must roll back, never commit — even + // if some third display still qualifies as a safe surface. + try transition(to: .verifying) + let targetInactive = after.observation(for: target)?.isActive != true + let safeSurfaceRemains = safety.safeSurface(in: after, excluding: [target]) != nil + let expectedActive = Set(snapshot.activeDisplays.map(\.recordID)).subtracting([target]) + let stillActive = Set(after.activeDisplays.map(\.recordID)) + let unexpectedlyLost = expectedActive.subtracting(stillActive) + guard targetInactive && safeSurfaceRemains && unexpectedlyLost.isEmpty else { + return await rollback(txID, checkpoint: checkpoint, failure: .partial(message: "postconditions not met")) + } + + // 8. Commit. + try transition(to: .committed) + _ = observation // observed identity retained for audit/result construction by callers + return .committed(txID, verification: .verified) + } + + /// Reconnects every managed-offline display. Always available; intended to preempt queued work + /// (PRD §9.2 invariant 6, LIF-009/010). Returns per-target success/failure. + public func reconnectAll(deadline: Date = Date().addingTimeInterval(15)) async -> [DisplayRecordID: Bool] { + let snapshot = await observer.currentSnapshot() + var results: [DisplayRecordID: Bool] = [:] + for record in snapshot.managedOffline { + do { + try await lifecycleProvider.reconnect(record.displayID, deadline: deadline) + results[record.displayID] = true + } catch { + results[record.displayID] = false + } + } + return results + } + + // MARK: - Private + + private func beginTransaction() { + state = .idle + lastTransition = [.idle] + } + + private func transition(to next: TransactionState) throws { + guard state.canTransition(to: next) else { + throw CoordinatorError.illegalTransition(from: state, to: next) + } + state = next + lastTransition.append(next) + } + + private func rollback(_ txID: TransactionID, checkpoint: Checkpoint, failure: ProviderFailure) async -> LifecycleResult { + // Force the rolling-back state even from `applying`/`observing`/`verifying`. + try? transition(to: .rollingBack) + do { + try await lifecycleProvider.recover(to: checkpoint) + try? transition(to: .recovered) + return .rolledBack(txID, recovered: true) + } catch { + try? transition(to: .degraded) + return .rolledBack(txID, recovered: false) + } + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/AuditLogTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/AuditLogTests.swift new file mode 100644 index 0000000..0cca4ba --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/AuditLogTests.swift @@ -0,0 +1,62 @@ +import XCTest +import DisplayDomain +@testable import TopologyCore + +final class AuditLogTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("od-audit-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func entry(_ id: String, status: String = "committed") -> AuditEntry { + AuditEntry(timestamp: Date(timeIntervalSinceReferenceDate: 1000), actor: .cli, + command: "disconnect", transactionId: id, status: status, targets: ["ext"]) + } + + func testAppendThenRecentPreservesOrder() async { + let log = DiskAuditLog(directory: directory) + await log.append(entry("txn_1")) + await log.append(entry("txn_2")) + let recent = await log.recent(limit: 10) + XCTAssertEqual(recent.map(\.transactionId), ["txn_1", "txn_2"]) + } + + func testRecentRespectsLimit() async { + let log = DiskAuditLog(directory: directory) + for i in 1...5 { await log.append(entry("txn_\(i)")) } + let recent = await log.recent(limit: 2) + XCTAssertEqual(recent.map(\.transactionId), ["txn_4", "txn_5"]) + } + + func testEmptyWhenAbsent() async { + let recent = await DiskAuditLog(directory: directory).recent(limit: 10) + XCTAssertTrue(recent.isEmpty) + } + + func testTornFinalLineIsSkipped() async throws { + let log = DiskAuditLog(directory: directory) + await log.append(entry("txn_good")) + // Simulate a crash mid-append leaving a partial trailing line. + let handle = try FileHandle(forWritingTo: directory.appendingPathComponent("audit.jsonl")) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{\"partial\":".utf8)) + try handle.close() + let recent = await log.recent(limit: 10) + XCTAssertEqual(recent.map(\.transactionId), ["txn_good"]) + } + + func testWritesOneJSONObjectPerLine() async throws { + let log = DiskAuditLog(directory: directory) + await log.append(entry("a")) + await log.append(entry("b")) + let text = try String(contentsOf: directory.appendingPathComponent("audit.jsonl"), encoding: .utf8) + XCTAssertEqual(text.split(separator: "\n").count, 2) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/CommandGatewayTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/CommandGatewayTests.swift new file mode 100644 index 0000000..dd2f78f --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/CommandGatewayTests.swift @@ -0,0 +1,118 @@ +import XCTest +import AutomationSchema +import DisplayDomain +import ProviderInterfaces +import SimulatorProvider +@testable import TopologyCore + +final class CommandGatewayTests: XCTestCase { + private func obs(_ id: String, active: Bool = true, main: Bool = false, + klass: DisplayClass = .external) -> DisplayObservation { + DisplayObservation(recordID: .init(rawValue: id), isActive: active, isMain: main, + displayClass: klass, generation: .initial) + } + + private func offline(_ id: String) -> ManagedOfflineRecord { + ManagedOfflineRecord(displayID: .init(rawValue: id), actor: .ui, reason: "test", + providerID: "simulator.lifecycle.v1") + } + + func testReconnectAllReenablesManagedOffline() async { + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("ext", active: false)], + managedOffline: [offline("ext")] + ) + let gateway = CommandGateway(observer: system, lifecycleProvider: system, checkpoints: InMemoryCheckpointStore()) + let envelope = await gateway.reconnectAll(actor: .cli) + XCTAssertEqual(envelope.status, .committed) + XCTAssertEqual(envelope.actor, .cli) + XCTAssertEqual(envelope.targets.map(\.displayId), ["ext"]) + XCTAssertEqual(envelope.targets.first?.operations.first?.verification, .verified) + XCTAssertEqual(envelope.schemaVersion, ResultEnvelope.currentSchemaVersion) + } + + func testReconnectAllNoOpWhenNothingOffline() async { + let system = SimulatedDisplaySystem(observations: [obs("builtin", main: true, klass: .builtIn)]) + let gateway = CommandGateway(observer: system, lifecycleProvider: system, checkpoints: InMemoryCheckpointStore()) + let envelope = await gateway.reconnectAll() + XCTAssertEqual(envelope.status, .noOp) + XCTAssertTrue(envelope.targets.isEmpty) + } + + func testReconnectAllPartialWhenSomeFail() async { + // "ext" exists (reconnect succeeds); the "ghost" managed-offline record has no matching + // observation, so its reconnect throws and is reported false → overall partial. + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("ext", active: false)], + managedOffline: [offline("ext"), offline("ghost")] + ) + let gateway = CommandGateway(observer: system, lifecycleProvider: system, checkpoints: InMemoryCheckpointStore()) + let envelope = await gateway.reconnectAll() + XCTAssertEqual(envelope.status, .partial) + let verification = Dictionary(uniqueKeysWithValues: + envelope.targets.map { ($0.displayId, $0.operations.first?.verification) }) + XCTAssertEqual(verification["ext"], .verified) + XCTAssertEqual(verification["ghost"], .readBackUnavailable) + } + + func testDisconnectCommitsWhenSafeSurfaceRemains() async { + // Default confirm handler declines, so reaching .committed proves preflight returned + // .allowed (no confirmation needed) for a non-main target with the built-in as safe surface. + let system = SimulatedDisplaySystem(observations: [obs("builtin", main: true, klass: .builtIn), obs("ext")]) + let gateway = CommandGateway(observer: system, lifecycleProvider: system, checkpoints: InMemoryCheckpointStore()) + let envelope = await gateway.disconnect(.init(rawValue: "ext"), + options: DisconnectOptions(actor: .cli, identityConfidence: 1.0)) + XCTAssertEqual(envelope.status, .committed) + XCTAssertEqual(envelope.targets.first?.operations.first?.verification, .verified) + } + + func testDisconnectBlockedWhenRemovingLastSafeDisplay() async { + let system = SimulatedDisplaySystem(observations: [obs("only", main: true, klass: .builtIn)]) + let gateway = CommandGateway(observer: system, lifecycleProvider: system, checkpoints: InMemoryCheckpointStore()) + let envelope = await gateway.disconnect(.init(rawValue: "only"), + options: DisconnectOptions(actor: .cli, identityConfidence: 1.0)) + XCTAssertEqual(envelope.status, .failed) + XCTAssertTrue(envelope.errors.contains { $0.code == "blocked" }) + } + + func testPreflightAllowedAndBlocked() async { + let pair = SimulatedDisplaySystem(observations: [obs("builtin", main: true, klass: .builtIn), obs("ext")]) + let gateway = CommandGateway(observer: pair, lifecycleProvider: pair, checkpoints: InMemoryCheckpointStore()) + let allowed = await gateway.preflightDisconnect(.init(rawValue: "ext"), identityConfidence: 1.0) + XCTAssertEqual(allowed.decision, .allowed) + XCTAssertEqual(allowed.safeSurface, DisplayRecordID(rawValue: "builtin")) + + let single = SimulatedDisplaySystem(observations: [obs("only", main: true, klass: .builtIn)]) + let gateway2 = CommandGateway(observer: single, lifecycleProvider: single, checkpoints: InMemoryCheckpointStore()) + let blocked = await gateway2.preflightDisconnect(.init(rawValue: "only"), identityConfidence: 1.0) + XCTAssertEqual(blocked.decision, .blocked) + XCTAssertNil(blocked.safeSurface) + } + + func testCommandsAreRecordedToAuditLog() async { + let system = SimulatedDisplaySystem(observations: [obs("builtin", main: true, klass: .builtIn)]) + let audit = InMemoryAuditLog() + let gateway = CommandGateway(observer: system, lifecycleProvider: system, + checkpoints: InMemoryCheckpointStore(), auditLog: audit) + _ = await gateway.reconnectAll(actor: .cli) + let entries = await audit.all + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.command, "reconnectAll") + XCTAssertEqual(entries.first?.actor, .cli) + XCTAssertEqual(entries.first?.status, "noOp") + } + + func testEnvelopeMappingCoversEveryResult() { + let target = DisplayRecordID(rawValue: "d") + let tx = TransactionID(rawValue: "txn_1") + func status(_ result: LifecycleResult) -> ResultEnvelope.Status { + CommandGateway.envelope(for: result, target: target, actor: .cli, generation: 5).status + } + XCTAssertEqual(status(.committed(tx, verification: .verified)), .committed) + XCTAssertEqual(status(.noOp(tx)), .noOp) + XCTAssertEqual(status(.cancelled(tx)), .noOp) + XCTAssertEqual(status(.rolledBack(tx, recovered: true)), .rolledBack) + XCTAssertEqual(status(.failed(tx, .denied)), .failed) + XCTAssertEqual(status(.blocked([.noSafeSurface])), .failed) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/DiskCheckpointStoreTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/DiskCheckpointStoreTests.swift new file mode 100644 index 0000000..fe1d10f --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/DiskCheckpointStoreTests.swift @@ -0,0 +1,98 @@ +import XCTest +import DisplayDomain +@testable import TopologyCore + +final class DiskCheckpointStoreTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("od-checkpoint-tests-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + // Fixed timestamp so encode/decode round-trips to an exactly equal value. + private let when = Date(timeIntervalSinceReferenceDate: 1_000_000) + + private func makeCheckpoint(suffix: String, generation: UInt64) -> Checkpoint { + let gen = TopologyGeneration(generation) + let observations = [ + DisplayObservation(recordID: .init(rawValue: "cg:AAAA"), cgDisplayID: 1, isActive: true, + isMain: true, displayClass: .builtIn, generation: gen, observedAt: when), + DisplayObservation(recordID: .init(rawValue: "cg:BBBB"), cgDisplayID: 2, isActive: false, + displayClass: .external, generation: gen, observedAt: when) + ] + let offline = [ + ManagedOfflineRecord(displayID: .init(rawValue: "cg:BBBB"), actor: .ui, reason: "test", + disconnectedAt: when, providerID: "test.provider") + ] + return Checkpoint( + id: CheckpointID(rawValue: "cp_\(suffix)"), + transactionID: TransactionID(rawValue: "txn_\(suffix)"), + generation: gen, + observations: observations, + mainDisplayID: .init(rawValue: "cg:AAAA"), + managedOffline: offline, + createdAt: when + ) + } + + func testWriteThenLatestRoundTrips() async throws { + let store = DiskCheckpointStore(directory: directory) + let checkpoint = makeCheckpoint(suffix: "one", generation: 3) + try await store.writeAtomic(checkpoint) + let latest = await store.latest() + XCTAssertEqual(latest, checkpoint) + } + + func testRestoreByID() async throws { + let store = DiskCheckpointStore(directory: directory) + let checkpoint = makeCheckpoint(suffix: "two", generation: 5) + try await store.writeAtomic(checkpoint) + let restored = try await store.restore(checkpoint.id) + XCTAssertEqual(restored, checkpoint) + } + + func testLatestReflectsMostRecentWrite() async throws { + let store = DiskCheckpointStore(directory: directory) + let first = makeCheckpoint(suffix: "first", generation: 1) + let second = makeCheckpoint(suffix: "second", generation: 2) + try await store.writeAtomic(first) + try await store.writeAtomic(second) + let latest = await store.latest() + XCTAssertEqual(latest, second) + // The earlier checkpoint is still individually restorable by id. + let restoredFirst = try await store.restore(first.id) + XCTAssertEqual(restoredFirst, first) + } + + func testLatestIsNilOnEmptyDirectory() async { + let store = DiskCheckpointStore(directory: directory) + let latest = await store.latest() + XCTAssertNil(latest) + } + + func testRestoreUnknownIDIsNil() async throws { + let store = DiskCheckpointStore(directory: directory) + let restored = try await store.restore(CheckpointID(rawValue: "cp_missing")) + XCTAssertNil(restored) + } + + /// The rescue contract: the latest checkpoint must be readable by an independent reader from a + /// well-known file, with no store instance and no shared state — just JSON + Codable. + func testLatestFileIsIndependentlyReadable() async throws { + let store = DiskCheckpointStore(directory: directory) + let checkpoint = makeCheckpoint(suffix: "rescue", generation: 7) + try await store.writeAtomic(checkpoint) + + let latestURL = directory.appendingPathComponent("latest.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: latestURL.path)) + + let data = try Data(contentsOf: latestURL) + let decoded = try JSONDecoder().decode(Checkpoint.self, from: data) + XCTAssertEqual(decoded, checkpoint) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/DisplayRegistryTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/DisplayRegistryTests.swift new file mode 100644 index 0000000..64622a3 --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/DisplayRegistryTests.swift @@ -0,0 +1,76 @@ +import XCTest +import DisplayDomain +@testable import TopologyCore + +final class DisplayRegistryTests: XCTestCase { + private func fingerprint(vendor: Int? = 1, product: Int? = 1, serial: String? = nil, + model: String? = nil) -> DisplayFingerprint { + DisplayFingerprint(vendorID: vendor, productID: product, serialNumber: serial, modelName: model) + } + + func testMintsNewRecordForUnknownDisplay() async { + let registry = await DisplayRegistry(store: InMemoryRegistryStore()) + let record = await registry.resolve(fingerprint: fingerprint(serial: "S1"), cgUUID: "U1") + let all = await registry.allRecords() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all.first?.id, record.id) + } + + func testRecognizesSameSerialAsSameRecord() async { + let registry = await DisplayRegistry(store: InMemoryRegistryStore()) + let first = await registry.resolve(fingerprint: fingerprint(serial: "ABC"), cgUUID: "U1") + // Same serial, different UUID (e.g. moved to another port) → still the same record. + let second = await registry.resolve(fingerprint: fingerprint(serial: "ABC"), cgUUID: "U2") + XCTAssertEqual(first.id, second.id) + let count = await registry.allRecords().count + XCTAssertEqual(count, 1) + } + + func testRecognizesViaCGUUIDWhenNoSerial() async { + let registry = await DisplayRegistry(store: InMemoryRegistryStore()) + // No serial: a fingerprint-only re-match scores model-family (0.25) < the 0.5 threshold, so + // recognition here depends on the CG-UUID fast path. + let first = await registry.resolve(fingerprint: fingerprint(serial: nil), cgUUID: "U1") + let second = await registry.resolve(fingerprint: fingerprint(serial: nil), cgUUID: "U1") + XCTAssertEqual(first.id, second.id) + let count = await registry.allRecords().count + XCTAssertEqual(count, 1) + } + + func testMintsDistinctRecordsForDifferentDisplaysWithoutSerial() async { + let registry = await DisplayRegistry(store: InMemoryRegistryStore()) + _ = await registry.resolve(fingerprint: fingerprint(vendor: 1, product: 1, serial: nil), cgUUID: "U1") + _ = await registry.resolve(fingerprint: fingerprint(vendor: 2, product: 2, serial: nil), cgUUID: "U2") + let count = await registry.allRecords().count + XCTAssertEqual(count, 2) + } + + func testAliasAndTagPersistAcrossResolve() async { + let registry = await DisplayRegistry(store: InMemoryRegistryStore()) + let record = await registry.resolve(fingerprint: fingerprint(serial: "S1"), cgUUID: "U1") + await registry.setAlias("Desk Left", for: record.id) + await registry.addTag("studio", to: record.id) + // Re-resolving the same display keeps the user-attached alias/tags. + let again = await registry.resolve(fingerprint: fingerprint(serial: "S1"), cgUUID: "U1") + XCTAssertEqual(again.alias, "Desk Left") + XCTAssertTrue(again.tags.contains("studio")) + } + + func testStatePersistsAcrossInstances() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("od-registry-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let first = await DisplayRegistry(store: DiskRegistryStore(directory: directory)) + let record = await first.resolve(fingerprint: fingerprint(serial: "S1", model: "S34J55x"), cgUUID: "U1") + await first.setAlias("Ultrawide", for: record.id) + + // A fresh registry over the same directory loads the persisted record + alias. + let second = await DisplayRegistry(store: DiskRegistryStore(directory: directory)) + let reloaded = await second.record(for: record.id) + XCTAssertEqual(reloaded?.alias, "Ultrawide") + let resolvedAgain = await second.resolve(fingerprint: fingerprint(serial: "S1"), cgUUID: "U1") + XCTAssertEqual(resolvedAgain.id, record.id) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/SafetyEngineTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/SafetyEngineTests.swift new file mode 100644 index 0000000..55c4765 --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/SafetyEngineTests.swift @@ -0,0 +1,86 @@ +import XCTest +import DisplayDomain +@testable import TopologyCore + +final class SafetyEngineTests: XCTestCase { + private let engine = SafetyEngine() + + private func obs(_ id: String, active: Bool = true, main: Bool = false, + klass: DisplayClass = .external, overlay: PresentationOverlay = .visible, + mirrorOf: DisplayRecordID? = nil) -> DisplayObservation { + DisplayObservation(recordID: DisplayRecordID(rawValue: id), isActive: active, overlay: overlay, + isMain: main, mirrorSourceID: mirrorOf, displayClass: klass, generation: .initial) + } + + func testSafeSurfacePrefersBuiltIn() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("external", main: true, klass: .external), + obs("builtin", klass: .builtIn) + ]) + let surface = engine.safeSurface(in: snapshot, excluding: []) + XCTAssertEqual(surface, DisplayRecordID(rawValue: "builtin")) + } + + func testSafeSurfaceExcludesTargetsAndMirrorsAndBlackedOut() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("target", klass: .external), + obs("mirror", mirrorOf: DisplayRecordID(rawValue: "target")), + obs("blacked", overlay: .blackedOut), + obs("good", klass: .external) + ]) + let surface = engine.safeSurface(in: snapshot, excluding: [DisplayRecordID(rawValue: "target")]) + XCTAssertEqual(surface, DisplayRecordID(rawValue: "good")) + } + + func testDisconnectingCurrentMainNeedsConfirmation() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("builtin", main: true, klass: .builtIn), + obs("external") + ]) + let decision = engine.preflightDisconnect( + target: DisplayRecordID(rawValue: "builtin"), + snapshot: snapshot, + identityConfidence: 1.0, + recoveryServiceHealthy: true, + isFirstUseForRoute: false + ) + guard case .needsConfirmation(_, let reasons) = decision else { + return XCTFail("expected needsConfirmation, got \(decision)") + } + XCTAssertTrue(reasons.contains(.targetIsCurrentMain)) + } + + func testLowConfidenceNeedsConfirmation() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("builtin", main: true, klass: .builtIn), obs("external") + ]) + let decision = engine.preflightDisconnect( + target: DisplayRecordID(rawValue: "external"), + snapshot: snapshot, + identityConfidence: 0.4, + recoveryServiceHealthy: true, + isFirstUseForRoute: false + ) + guard case .needsConfirmation(_, let reasons) = decision else { + return XCTFail("expected needsConfirmation, got \(decision)") + } + XCTAssertTrue(reasons.contains(.identityBelowThreshold)) + } + + func testAllowedWhenSafeAndConfident() { + let snapshot = TopologySnapshot(generation: .initial, observations: [ + obs("builtin", main: true, klass: .builtIn), obs("external") + ]) + let decision = engine.preflightDisconnect( + target: DisplayRecordID(rawValue: "external"), + snapshot: snapshot, + identityConfidence: 1.0, + recoveryServiceHealthy: true, + isFirstUseForRoute: false + ) + guard case .allowed(let surface) = decision else { + return XCTFail("expected allowed, got \(decision)") + } + XCTAssertEqual(surface, DisplayRecordID(rawValue: "builtin")) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/SettingsStoreTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/SettingsStoreTests.swift new file mode 100644 index 0000000..2533cf2 --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/SettingsStoreTests.swift @@ -0,0 +1,57 @@ +import XCTest +import DisplayDomain +@testable import TopologyCore + +final class SettingsStoreTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("od-settings-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + func testLoadReturnsDefaultsWhenAbsent() { + let store = SettingsStore(directory: directory) + XCTAssertEqual(store.load(), .default) + XCTAssertEqual(store.load().persistencePolicy, .reconnectOnQuit) + } + + func testSaveThenLoadRoundTrips() throws { + let store = SettingsStore(directory: directory) + let settings = OpenDisplaySettings( + persistencePolicy: .persistentOffline, + confirmationCountdownSeconds: 12, + reconnectAllHotkeyEnabled: false + ) + try store.save(settings) + XCTAssertEqual(store.load(), settings) + } + + func testCorruptFileFallsBackToDefaults() throws { + let store = SettingsStore(directory: directory) + try Data("not json".utf8).write(to: directory.appendingPathComponent("settings.json")) + XCTAssertEqual(store.load(), .default) + } + + func testUnknownKeysAndMissingKeysTolerated() throws { + // A file with an extra key and a missing key should still load, using defaults for the gaps. + let json = #"{"persistencePolicy":"reconnectOnWake","futureKey":42}"# + try Data(json.utf8).write(to: directory.appendingPathComponent("settings.json")) + let loaded = SettingsStore(directory: directory).load() + XCTAssertEqual(loaded.persistencePolicy, .reconnectOnWake) + XCTAssertEqual(loaded.confirmationCountdownSeconds, OpenDisplaySettings.default.confirmationCountdownSeconds) + } + + func testSettingsFileIsIndependentlyReadable() throws { + let store = SettingsStore(directory: directory) + try store.save(OpenDisplaySettings(persistencePolicy: .reconnectOnWake)) + let data = try Data(contentsOf: directory.appendingPathComponent("settings.json")) + let decoded = try JSONDecoder().decode(OpenDisplaySettings.self, from: data) + XCTAssertEqual(decoded.persistencePolicy, .reconnectOnWake) + } +} diff --git a/Packages/TopologyCore/Tests/TopologyCoreTests/TopologyCoordinatorTests.swift b/Packages/TopologyCore/Tests/TopologyCoreTests/TopologyCoordinatorTests.swift new file mode 100644 index 0000000..4569571 --- /dev/null +++ b/Packages/TopologyCore/Tests/TopologyCoreTests/TopologyCoordinatorTests.swift @@ -0,0 +1,209 @@ +import XCTest +import DisplayDomain +import ProviderInterfaces +import SimulatorProvider +@testable import TopologyCore + +/// In-memory checkpoint store for tests. +private actor TestCheckpointStore: CheckpointStoring { + private var store: [CheckpointID: Checkpoint] = [:] + private var latestID: CheckpointID? + + func writeAtomic(_ checkpoint: Checkpoint) async throws { + store[checkpoint.id] = checkpoint + latestID = checkpoint.id + } + func restore(_ id: CheckpointID) async throws -> Checkpoint? { store[id] } + func latest() async -> Checkpoint? { latestID.flatMap { store[$0] } } +} + +final class TopologyCoordinatorTests: XCTestCase { + private func obs(_ id: String, active: Bool = true, main: Bool = false, + klass: DisplayClass = .external) -> DisplayObservation { + DisplayObservation(recordID: DisplayRecordID(rawValue: id), isActive: active, + isMain: main, displayClass: klass, generation: .initial) + } + + private func makeCoordinator( + _ system: SimulatedDisplaySystem, + confirm: @escaping ConfirmationHandler = { _, _ in true }, + recoveryHealthy: @escaping @Sendable () async -> Bool = { true } + ) -> TopologyCoordinator { + TopologyCoordinator( + observer: system, + lifecycleProvider: system, + checkpoints: TestCheckpointStore(), + recoveryServiceHealthy: recoveryHealthy, + confirm: confirm + ) + } + + // T-003: blocking the last safe display. + func testBlocksRemovingLastSafeDisplay() async throws { + let system = SimulatedDisplaySystem(observations: [obs("only", main: true, klass: .builtIn)]) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "only"), + options: .init(actor: .ui, identityConfidence: 1.0)) + XCTAssertEqual(result, .blocked([.wouldRemoveLastSafeDisplay])) + } + + // T-001: first successful logical disconnect with a remaining safe surface. + func testSuccessfulDisconnectCommits() async throws { + let system = SimulatedDisplaySystem(observations: [ + obs("builtin", main: true, klass: .builtIn), + obs("external") + ]) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + guard case .committed(_, let verification) = result else { + return XCTFail("expected committed, got \(result)") + } + XCTAssertEqual(verification, .verified) + let finalState = await coordinator.currentState + XCTAssertEqual(finalState, .committed) + let snapshot = await system.currentSnapshot() + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "external"))?.isActive, false) + } + + // T-006: a provider failure after checkpoint rolls back and recovers. + func testProviderFailureRollsBackAndRecovers() async throws { + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("external")], + faults: SimulatedFaults(disconnectFailure: .timeout) + ) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + XCTAssertEqual(result, .rolledBack(resultTxID(result), recovered: true)) + let finalState = await coordinator.currentState + XCTAssertEqual(finalState, .recovered) + } + + // T-006 variant: provider "succeeds" but state is unchanged → verification fails → rollback. + func testSilentNoOpFailsVerificationAndRollsBack() async throws { + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("external")], + faults: SimulatedFaults(disconnectSilentlyNoOps: true) + ) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + guard case .rolledBack(_, let recovered) = result else { + return XCTFail("expected rolledBack, got \(result)") + } + XCTAssertTrue(recovered) + } + + // A failed rollback degrades rather than silently succeeding. + func testFailedRollbackDegrades() async throws { + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("external")], + faults: SimulatedFaults(disconnectFailure: .providerError(message: "boom"), recoverFails: true) + ) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + guard case .rolledBack(_, let recovered) = result else { + return XCTFail("expected rolledBack, got \(result)") + } + XCTAssertFalse(recovered) + let finalState = await coordinator.currentState + XCTAssertEqual(finalState, .degraded) + } + + // §9.4: if the provider drops an unrelated active display alongside the target, the coordinator + // must roll back rather than commit — even though a third display remains a safe surface. + func testRollsBackWhenProviderDropsUnrelatedDisplay() async throws { + let system = SimulatedDisplaySystem( + observations: [ + obs("builtin", main: true, klass: .builtIn), + obs("external"), + obs("third") + ], + faults: SimulatedFaults(alsoDisconnect: [DisplayRecordID(rawValue: "third")]) + ) + let coordinator = makeCoordinator(system) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + guard case .rolledBack(_, let recovered) = result else { + return XCTFail("expected rolledBack after losing an unrelated display, got \(result)") + } + XCTAssertTrue(recovered) + // After rollback both the target and the unrelated display are restored. + let snapshot = await system.currentSnapshot() + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "third"))?.isActive, true) + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "external"))?.isActive, true) + } + + // Default coordinator (no confirmation handler supplied) must NOT silently approve a + // `.needsConfirmation` disconnect — it cancels, leaving the display active. + func testDefaultConfirmHandlerCancels() async throws { + let system = SimulatedDisplaySystem(observations: [ + obs("builtin", main: true, klass: .builtIn), obs("external") + ]) + // No `confirm:` argument → fail-safe default (deny). + let coordinator = TopologyCoordinator( + observer: system, lifecycleProvider: system, checkpoints: TestCheckpointStore() + ) + let result = try await coordinator.disconnect( + DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0, isFirstUseForRoute: true) + ) + guard case .cancelled = result else { return XCTFail("expected cancelled, got \(result)") } + let snapshot = await system.currentSnapshot() + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "external"))?.isActive, true) + } + + // LIF-006: first-use confirmation that the user cancels. + func testConfirmationCancelled() async throws { + let system = SimulatedDisplaySystem(observations: [ + obs("builtin", main: true, klass: .builtIn), obs("external") + ]) + let coordinator = makeCoordinator(system, confirm: { _, _ in false }) + let result = try await coordinator.disconnect( + DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0, isFirstUseForRoute: true) + ) + guard case .cancelled = result else { return XCTFail("expected cancelled, got \(result)") } + // The display must remain active after a cancelled confirmation. + let snapshot = await system.currentSnapshot() + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "external"))?.isActive, true) + } + + // Recovery service unhealthy blocks the disconnect entirely. + func testUnhealthyRecoveryServiceBlocks() async throws { + let system = SimulatedDisplaySystem(observations: [ + obs("builtin", main: true, klass: .builtIn), obs("external") + ]) + let coordinator = makeCoordinator(system, recoveryHealthy: { false }) + let result = try await coordinator.disconnect(DisplayRecordID(rawValue: "external"), + options: .init(actor: .ui, identityConfidence: 1.0)) + XCTAssertEqual(result, .blocked([.recoveryServiceUnhealthy])) + } + + // LIF-009/010: Reconnect All returns per-target results. + func testReconnectAllReturnsPerTargetResults() async throws { + let offline = ManagedOfflineRecord(displayID: DisplayRecordID(rawValue: "external"), + actor: .ui, reason: "test", providerID: "simulator.lifecycle.v1") + let system = SimulatedDisplaySystem( + observations: [obs("builtin", main: true, klass: .builtIn), obs("external", active: false)], + managedOffline: [offline] + ) + let coordinator = makeCoordinator(system) + let results = await coordinator.reconnectAll() + XCTAssertEqual(results[DisplayRecordID(rawValue: "external")], true) + let snapshot = await system.currentSnapshot() + XCTAssertEqual(snapshot.observation(for: DisplayRecordID(rawValue: "external"))?.isActive, true) + } + + private func resultTxID(_ result: LifecycleResult) -> TransactionID { + switch result { + case .committed(let id, _), .noOp(let id), .cancelled(let id), + .rolledBack(let id, _), .failed(let id, _): + return id + case .blocked: + return TransactionID(rawValue: "n/a") + } + } +} diff --git a/Providers/CaptureProvider/README.md b/Providers/CaptureProvider/README.md new file mode 100644 index 0000000..77d384f --- /dev/null +++ b/Providers/CaptureProvider/README.md @@ -0,0 +1,10 @@ +# CaptureProvider + +**macOS target.** ScreenCaptureKit-backed picture-in-picture, display zoom, and screenshots +(PRD VIR-004..007). Requests Screen Recording permission only when a capture feature is +started; denial leaves topology/controls/scenes/lifecycle fully functional. Honors system +exclusions and stops sessions on lock/logout. + +Implements: `CaptureProvider`-style protocol. Milestone: **Core 1.x (M4)**. + +> Stub — concrete implementation added on macOS in Xcode. diff --git a/Providers/CaptureProvider/Sources/CaptureProvider.swift b/Providers/CaptureProvider/Sources/CaptureProvider.swift new file mode 100644 index 0000000..5f65cc1 --- /dev/null +++ b/Providers/CaptureProvider/Sources/CaptureProvider.swift @@ -0,0 +1,17 @@ +#if os(macOS) +import DisplayDomain +import ProviderInterfaces + +/// ScreenCaptureKit-backed PIP / zoom / screenshots (PRD VIR-004..007, Core 1.x). Stub — capture +/// sessions and permission handling land in M4. Requests no permission until a capture feature runs. +public struct CaptureProvider: DisplayProvider { + public let providerID = "capture.v1" + public let isExperimental = false + + public init() {} + + public func probe(_ environment: ProviderEnvironment) async -> ProviderProbe { + ProviderProbe(providerID: providerID, status: .unknown, risk: .normal, reasons: [.permission]) + } +} +#endif diff --git a/Providers/CoreGraphicsProvider/README.md b/Providers/CoreGraphicsProvider/README.md new file mode 100644 index 0000000..b5483a6 --- /dev/null +++ b/Providers/CoreGraphicsProvider/README.md @@ -0,0 +1,12 @@ +# CoreGraphicsProvider + +**macOS target.** Public display enumeration and configuration via Core Graphics: +enumerate endpoints, read/apply bounds, modes, mirror sets, and main display where supported +(PRD §10.3, TOP-001/002/003). Documented-API boundary; ships in every build flavor including +public-API-only. + +Implements: the macOS source for `DisplayRegistry` observations and a `TopologyObserving` +event source feeding `TopologyCore`. Milestone: **M0/M1**. + +> Stub — concrete implementation added on macOS in Xcode. It conforms to the protocols in +> `Packages/ProviderInterfaces`. diff --git a/Providers/CoreGraphicsProvider/Sources/CoreGraphicsProvider.swift b/Providers/CoreGraphicsProvider/Sources/CoreGraphicsProvider.swift new file mode 100644 index 0000000..fe87e35 --- /dev/null +++ b/Providers/CoreGraphicsProvider/Sources/CoreGraphicsProvider.swift @@ -0,0 +1,456 @@ +#if os(macOS) +import ColorSync // CGDisplayCreateUUIDFromDisplayID is declared here, not in CoreGraphics +import CoreGraphics +import DisplayDomain +import Foundation +import ProviderInterfaces + +/// Top-level C callback for `CGDisplayRegisterReconfigurationCallback`. It must be a +/// non-capturing function so it bridges to a `@convention(c)` pointer; the owning provider is +/// recovered from `userInfo`. Runs on the registering thread's run loop (the app's main loop). +private func openDisplayReconfigurationCallback( + _ display: CGDirectDisplayID, + _ flags: CGDisplayChangeSummaryFlags, + _ userInfo: UnsafeMutableRawPointer? +) { + guard let userInfo else { return } + let provider = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + let raw = flags.rawValue + Task { await provider.handleReconfiguration(rawFlags: raw) } +} + +/// Public display enumeration via Core Graphics, exposed as the platform-independent +/// `TopologyObserving` the coordinator depends on (PRD §10.3, TOP-001/002/003). +/// +/// This is the M0 observation source: it enumerates the real online displays, normalizes them +/// into `DisplayObservation`s, and advances the `TopologyGeneration` whenever the topology +/// signature changes — driven both lazily (each snapshot) and eagerly by a +/// `CGDisplayRegisterReconfigurationCallback` event source so hotplug/rotation/mirror changes are +/// noticed promptly. +/// +/// It also serves as the **public, reversible** `LifecycleProvider` fallback: lacking a public +/// API to truly remove a display, it approximates logical disconnect by mirroring the target into +/// the safe surface via `CGConfigureDisplayMirrorOfDisplay` (un-mirroring on reconnect). The true +/// logical disconnect lives in the experimental SkyLight provider; the router prefers that and +/// falls back here. Only documented Apple APIs are used, so this provider stays in the +/// public-API-only build (NFR-010, D-008). +public actor CoreGraphicsProvider: TopologyObserving, DisplayProvider, LifecycleProvider { + public nonisolated let providerID = "coregraphics.v1" + public nonisolated let isExperimental = false + + private var generation: TopologyGeneration = .initial + private var lastSignature = "" + /// Opaque retained-self token while the OS reconfiguration callback is registered; nil otherwise. + /// Retaining self keeps the instance alive across an in-flight callback, so a topology event can + /// never resurrect a deallocating provider. + private var observingToken: UnsafeMutableRawPointer? + + public init() {} + // No deinit: startObserving() takes a +1 self-retain, so a registered observer is kept alive by the + // OS for the app's lifetime and can never deinit mid-callback (closing the resurrection race); an + // un-registered provider (intents/CLI/rescue) holds no callback, so there is nothing to clean up. + + /// Begins delivering reconfiguration events to `changes()` subscribers. Idempotent. Only the + /// long-lived observer that consumes the stream registers; short-lived providers (App Intents, + /// CLI, one-shot rescue) never call this, so their teardown can't race the global callback table. + public func startObserving() { + guard observingToken == nil else { return } + let token = Unmanaged.passRetained(self).toOpaque() + observingToken = token + CGDisplayRegisterReconfigurationCallback(openDisplayReconfigurationCallback, token) + } + + /// Stops delivering events and balances the self-retain taken in `startObserving`. + public func stopObserving() { + guard let token = observingToken else { return } + CGDisplayRemoveReconfigurationCallback(openDisplayReconfigurationCallback, token) + observingToken = nil + Unmanaged.fromOpaque(token).release() + } + + // MARK: TopologyObserving + + public func currentSnapshot() -> TopologySnapshot { + currentTopology() + } + + /// Polls the live topology until the generation advances past `generation` or a short deadline + /// elapses. Actor reentrancy lets the reconfiguration callback (and lazy re-enumeration) run + /// during the sleeps; the timeout guarantees the coordinator never blocks if the OS emits no + /// event (e.g. a logical op that silently no-ops). + public func awaitStableGeneration(after generation: TopologyGeneration) async -> TopologySnapshot { + let stepNanos: UInt64 = 100_000_000 // 100 ms + let timeoutNanos: UInt64 = 2_000_000_000 // 2 s + var waited: UInt64 = 0 + var snapshot = currentTopology() + while snapshot.generation <= generation && waited < timeoutNanos { + try? await Task.sleep(nanoseconds: stepNanos) + waited += stepNanos + snapshot = currentTopology() + } + return snapshot + } + + // MARK: DisplayProvider + + public func probe(_ environment: ProviderEnvironment) -> ProviderProbe { + // Public display enumeration is available on every supported macOS. + ProviderProbe(providerID: providerID, status: .supported, risk: .normal) + } + + // MARK: LifecycleProvider (public, reversible — mirroring fallback) + + /// Approximates a logical disconnect by mirroring `target` into the current main display, so + /// it stops being an independent surface. Reversible via `reconnect`. Refuses to mirror the + /// main display onto itself (the coordinator independently guarantees a safe surface remains). + public func disconnect(_ target: DisplayRecordID, deadline: Date) async throws { + guard let id = Self.displayID(for: target) else { throw ProviderFailure.ambiguous(candidates: []) } + let master = CGMainDisplayID() + guard id != master else { throw ProviderFailure.unsupported(reason: [.safetyPolicy]) } + try applyMirror(of: id, onto: master) + } + + /// Un-mirrors `target`, restoring it as an independent display. Idempotent: un-mirroring a + /// display that is not mirrored is a successful no-op. + public func reconnect(_ target: DisplayRecordID, deadline: Date) async throws { + guard let id = Self.displayID(for: target) else { throw ProviderFailure.ambiguous(candidates: []) } + try applyMirror(of: id, onto: kCGNullDirectDisplay) + } + + /// Best-effort restoration: un-mirror every display the checkpoint recorded as active, so the + /// independent arrangement comes back. + public func recover(to checkpoint: Checkpoint) async throws { + for observation in checkpoint.observations where observation.isActive { + guard let id = Self.displayID(for: observation.recordID) else { continue } + try? applyMirror(of: id, onto: kCGNullDirectDisplay) + } + } + + /// Resolves an app record ID back to a live `CGDirectDisplayID`. The `cg:` form is + /// resolved through the persistent CG UUID (stable across reboots); `cgid:` is the raw ID. + public nonisolated static func displayID(for record: DisplayRecordID) -> CGDirectDisplayID? { + let raw = record.rawValue + if raw.hasPrefix("cgid:") { return UInt32(raw.dropFirst("cgid:".count)) } + if raw.hasPrefix("cg:") { + let uuidString = String(raw.dropFirst("cg:".count)) + guard let uuid = CFUUIDCreateFromString(kCFAllocatorDefault, uuidString as CFString) else { return nil } + let id = CGDisplayGetDisplayIDFromUUID(uuid) + return id != 0 ? id : nil + } + return nil + } + + /// Runs one mirror (re)configuration inside a CG display-configuration transaction. Pass + /// `kCGNullDirectDisplay` as the master to un-mirror. Applied `.forSession` so any mistake + /// self-heals at logout — an extra safety net beyond the coordinator's checkpoint/rollback. + private func applyMirror(of display: CGDirectDisplayID, onto master: CGDirectDisplayID) throws { + var configRef: CGDisplayConfigRef? + guard CGBeginDisplayConfiguration(&configRef) == .success, let config = configRef else { + throw ProviderFailure.osRejected(code: -1) + } + let configureError = CGConfigureDisplayMirrorOfDisplay(config, display, master) + guard configureError == .success else { + CGCancelDisplayConfiguration(config) + throw ProviderFailure.osRejected(code: Int(configureError.rawValue)) + } + let completeError = CGCompleteDisplayConfiguration(config, .forSession) + guard completeError == .success else { + throw ProviderFailure.osRejected(code: Int(completeError.rawValue)) + } + } + + /// Software-dims a display by scaling its gamma transfer ramp (`level` 0.15...1, where 1 = no + /// dim). Works on any display — including externals without DDC and below the hardware minimum. + /// Floored so the screen can never go fully black. Public Core Graphics (CGSetDisplayTransferByFormula). + public nonisolated func setGammaDim(_ level: Float, for displayID: CGDirectDisplayID) { + let scale = CGGammaValue(max(0.15, min(1, level))) + _ = CGSetDisplayTransferByFormula(displayID, 0, scale, 1, 0, scale, 1, 0, scale, 1) + } + + /// Restores every display's gamma to its ColorSync calibration, clearing any software dim. Call + /// on quit so a dim never outlives the app. + public nonisolated static func restoreGamma() { + CGDisplayRestoreColorSyncSettings() + } + + /// Mirrors `displayID` onto the current main display (both show the same content), or stops + /// mirroring when `enabled` is false. Reversible; public Core Graphics only. + public func setMirroring(of displayID: CGDirectDisplayID, enabled: Bool) -> Bool { + let master = enabled ? CGMainDisplayID() : kCGNullDirectDisplay + do { + try applyMirror(of: displayID, onto: master) + return true + } catch { + return false + } + } + + // MARK: Reconfiguration event source + + private var changeContinuations: [UUID: AsyncStream.Continuation] = [:] + + /// Emits whenever the live topology changes (hotplug, unplug, sleep, rotation, mirror, + /// enable/disable). The app subscribes to refresh promptly and to enforce the + /// always-one-active-display invariant when a display is physically unplugged. + public func changes() -> AsyncStream { + startObserving() // lazily register the OS callback on the first (long-lived) subscriber + let (stream, continuation) = AsyncStream.makeStream() + let id = UUID() + changeContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.dropContinuation(id) } + } + return stream + } + + private func dropContinuation(_ id: UUID) { changeContinuations[id] = nil } + + /// Invoked off the CG reconfiguration callback. Recomputing the topology bumps the generation + /// if the signature changed; redundant callbacks (e.g. the begin-configuration phase) are + /// harmless no-ops because the signature is unchanged. Subscribers are then notified. + func handleReconfiguration(rawFlags: UInt32) { + // The begin-configuration callback fires *before* the change lands (and while another app may + // hold the configuration), so the display list is mid-flight. React only to settled callbacks. + let flags = CGDisplayChangeSummaryFlags(rawValue: rawFlags) + if flags.contains(.beginConfigurationFlag) { return } + _ = currentTopology() + for continuation in changeContinuations.values { continuation.yield(()) } + } + + // MARK: - Enumeration + + private func currentTopology() -> TopologySnapshot { + let ids = onlineDisplayIDs() + let signature = topologySignature(of: ids) + if signature != lastSignature { + lastSignature = signature + generation = generation.next() + } + let now = Date() + let observations = ids.map { observation(for: $0, generation: generation, at: now) } + return TopologySnapshot(generation: generation, observations: observations, capturedAt: now) + } + + private func onlineDisplayIDs() -> [CGDirectDisplayID] { + let maxDisplays: UInt32 = 32 + var ids = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays)) + var count: UInt32 = 0 + guard CGGetOnlineDisplayList(maxDisplays, &ids, &count) == .success else { return [] } + return Array(ids.prefix(Int(count))) + } + + private func observation( + for id: CGDirectDisplayID, + generation: TopologyGeneration, + at now: Date + ) -> DisplayObservation { + let uuid = displayUUID(id) + let isBuiltin = CGDisplayIsBuiltin(id) != 0 + let bounds = CGDisplayBounds(id) + let mirrorMaster = CGDisplayMirrorsDisplay(id) + let mirrorSourceID = mirrorMaster != 0 + ? recordID(uuid: displayUUID(mirrorMaster), id: mirrorMaster) + : nil + return DisplayObservation( + recordID: recordID(uuid: uuid, id: id), + cgDisplayID: id, + cgUUID: uuid, + isActive: CGDisplayIsActive(id) != 0, + origin: DisplayOrigin(x: Int(bounds.origin.x), y: Int(bounds.origin.y)), + mode: CGDisplayCopyDisplayMode(id).map(displayMode(from:)), + rotation: rotation(of: id), + isMain: CGDisplayIsMain(id) != 0, + mirrorSourceID: mirrorSourceID, + transport: isBuiltin ? .internalPanel : .unknown, + displayClass: isBuiltin ? .builtIn : .external, + generation: generation, + observedAt: now + ) + } + + /// Stable record ID derived from the persistent CG display UUID where available (it survives + /// reboots and re-enumeration), falling back to the transient CG display ID. Scored identity + /// resolution against persisted `DisplayRecord`s lands later (PRD D-009). + private func recordID(uuid: String?, id: CGDirectDisplayID) -> DisplayRecordID { + if let uuid { return DisplayRecordID(rawValue: "cg:\(uuid)") } + return DisplayRecordID(rawValue: "cgid:\(id)") + } + + /// Builds the identity fingerprint for a display from public Core Graphics EDID accessors + /// (vendor/model/serial numbers + physical size). The registry scores this to recognize a + /// display across reconnects. Nonisolated — pure CG reads, no actor state. + public nonisolated func fingerprint(for cgID: CGDirectDisplayID) -> DisplayFingerprint { + func valid(_ value: UInt32) -> Int? { + (value == 0 || value == 0xFFFF_FFFF) ? nil : Int(value) + } + let serial = CGDisplaySerialNumber(cgID) + let size = CGDisplayScreenSize(cgID) // millimeters; (0,0) when unknown + return DisplayFingerprint( + vendorID: valid(CGDisplayVendorNumber(cgID)), + productID: valid(CGDisplayModelNumber(cgID)), + serialNumber: serial == 0 ? nil : String(serial), + physicalWidthMM: size.width > 0 ? Int(size.width.rounded()) : nil, + physicalHeightMM: size.height > 0 ? Int(size.height.rounded()) : nil + ) + } + + /// One display's target arrangement for `applyArrangement`. + public struct ArrangementTarget: Sendable { + public var displayID: CGDirectDisplayID + public var origin: DisplayOrigin? + public var mode: DisplayMode? + public init(displayID: CGDirectDisplayID, origin: DisplayOrigin?, mode: DisplayMode?) { + self.displayID = displayID + self.origin = origin + self.mode = mode + } + } + + /// Applies display positions and modes atomically inside one Core Graphics configuration + /// transaction (`.permanently`). Restoring origins also restores the main display, since the + /// display at (0,0) is the main one. Reversible — apply another arrangement to undo. Returns + /// human-readable warnings for anything it couldn't satisfy (e.g. an unavailable mode). + /// Nonisolated: pure CG calls, no actor state. + public nonisolated func applyArrangement(_ targets: [ArrangementTarget]) -> [String] { + var warnings: [String] = [] + var configRef: CGDisplayConfigRef? + guard CGBeginDisplayConfiguration(&configRef) == .success, let config = configRef else { + return ["could not begin display configuration"] + } + var changed = false + for target in targets { + // Mode first (resolution can shift the origin), then origin. Skip whatever already + // matches so a no-op apply doesn't flicker the displays. + if let mode = target.mode, !modeSatisfied(target.displayID, mode) { + if let cgMode = bestMode(for: target.displayID, matching: mode) { + if CGConfigureDisplayWithDisplayMode(config, target.displayID, cgMode, nil) == .success { + changed = true + } else { + warnings.append("could not set mode for \(target.displayID)") + } + } else { + warnings.append("no matching mode for \(target.displayID) (\(mode.pixelWidth)x\(mode.pixelHeight)@\(Int(mode.refreshHz.rounded())))") + } + } + if let origin = target.origin { + let current = CGDisplayBounds(target.displayID).origin + if Int(current.x.rounded()) != origin.x || Int(current.y.rounded()) != origin.y { + if CGConfigureDisplayOrigin(config, target.displayID, Int32(origin.x), Int32(origin.y)) == .success { + changed = true + } else { + warnings.append("could not move \(target.displayID)") + } + } + } + } + guard changed else { + CGCancelDisplayConfiguration(config) + return warnings + } + if CGCompleteDisplayConfiguration(config, .permanently) != .success { + warnings.append("apply failed (complete error)") + } + return warnings + } + + private nonisolated func modeSatisfied(_ id: CGDirectDisplayID, _ desired: DisplayMode) -> Bool { + guard let current = CGDisplayCopyDisplayMode(id) else { return false } + // Compare the logical (point) size + HiDPI + refresh: a scaled HiDPI mode and a native mode + // can share pixel dimensions, so a pixel-only check would wrongly treat them as identical. + return current.width == desired.pointWidth && current.height == desired.pointHeight + && (current.pixelWidth > current.width) == desired.isHiDPI + && abs(current.refreshRate - desired.refreshHz) < 1 + } + + private nonisolated func bestMode(for id: CGDirectDisplayID, matching desired: DisplayMode) -> CGDisplayMode? { + let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary + guard let modes = CGDisplayCopyAllDisplayModes(id, options) as? [CGDisplayMode] else { return nil } + // Prefer an exact logical (point) + HiDPI + refresh match, since a scaled HiDPI mode and a + // native mode can share pixel dimensions; fall back to a pixel match if none lines up. + return modes.first { + $0.width == desired.pointWidth && $0.height == desired.pointHeight + && ($0.pixelWidth > $0.width) == desired.isHiDPI + && abs($0.refreshRate - desired.refreshHz) < 1 + } ?? modes.first { + $0.pixelWidth == desired.pixelWidth && $0.pixelHeight == desired.pixelHeight + && abs($0.refreshRate - desired.refreshHz) < 1 + } + } + + /// Every display mode (un-deduped), including scaled-HiDPI and refresh-rate variants — drives the + /// refresh-rate picker and HiDPI toggle, which need to see all modes at a given point-resolution. + public nonisolated func allModes(for cgID: CGDirectDisplayID) -> [DisplayMode] { + let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary + guard let cgModes = CGDisplayCopyAllDisplayModes(cgID, options) as? [CGDisplayMode] else { return [] } + return cgModes.map { + DisplayMode(pixelWidth: $0.pixelWidth, pixelHeight: $0.pixelHeight, + pointWidth: $0.width, pointHeight: $0.height, + refreshHz: $0.refreshRate, isHiDPI: $0.pixelWidth > $0.width) + } + } + + /// All selectable resolutions for a display, de-duplicated to one mode per point-size (HiDPI + /// preferred, then highest refresh) and sorted by area ascending — drives the resolution slider. + public nonisolated func availableModes(for cgID: CGDirectDisplayID) -> [DisplayMode] { + // Include scaled HiDPI ("looks like") modes — without this option the built-in returns only + // its 1:1 pixel modes, so the user's actual scaled resolution wouldn't appear in the list. + let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary + guard let cgModes = CGDisplayCopyAllDisplayModes(cgID, options) as? [CGDisplayMode] else { return [] } + var best: [String: DisplayMode] = [:] + for cg in cgModes { + let mode = DisplayMode( + pixelWidth: cg.pixelWidth, pixelHeight: cg.pixelHeight, + pointWidth: cg.width, pointHeight: cg.height, + refreshHz: cg.refreshRate, isHiDPI: cg.pixelWidth > cg.width) + let key = "\(mode.pointWidth)x\(mode.pointHeight)" + let rank = (mode.isHiDPI ? 1 : 0, mode.refreshHz) + if let existing = best[key] { + if rank > (existing.isHiDPI ? 1 : 0, existing.refreshHz) { best[key] = mode } + } else { + best[key] = mode + } + } + return best.values.sorted { $0.pointWidth * $0.pointHeight < $1.pointWidth * $1.pointHeight } + } + + private func displayUUID(_ id: CGDirectDisplayID) -> String? { + guard let unmanaged = CGDisplayCreateUUIDFromDisplayID(id) else { return nil } + let uuid = unmanaged.takeRetainedValue() + return CFUUIDCreateString(kCFAllocatorDefault, uuid) as String? + } + + private func displayMode(from mode: CGDisplayMode) -> DisplayMode { + DisplayMode( + pixelWidth: mode.pixelWidth, + pixelHeight: mode.pixelHeight, + pointWidth: mode.width, + pointHeight: mode.height, + refreshHz: mode.refreshRate, + isHiDPI: mode.pixelWidth > mode.width + ) + } + + private func rotation(of id: CGDirectDisplayID) -> Rotation { + switch Int(CGDisplayRotation(id).rounded()) { + case 90: return .degrees90 + case 180: return .degrees180 + case 270: return .degrees270 + default: return .degrees0 + } + } + + /// A compact fingerprint of the structural topology used to decide when to advance the + /// generation: which displays are online, active, main, mirrored, and where/how big they are. + private func topologySignature(of ids: [CGDirectDisplayID]) -> String { + ids.sorted().map { id in + let b = CGDisplayBounds(id) + let active = CGDisplayIsActive(id) != 0 ? 1 : 0 + let main = CGDisplayIsMain(id) != 0 ? 1 : 0 + let mirror = CGDisplayMirrorsDisplay(id) + return "\(id):\(active):\(main):\(Int(b.origin.x)),\(Int(b.origin.y)):" + + "\(Int(b.size.width))x\(Int(b.size.height)):\(mirror)" + } + .joined(separator: "|") + } +} +#endif diff --git a/Providers/ExperimentalLifecycleProvider/README.md b/Providers/ExperimentalLifecycleProvider/README.md new file mode 100644 index 0000000..5a897a9 --- /dev/null +++ b/Providers/ExperimentalLifecycleProvider/README.md @@ -0,0 +1,16 @@ +# ExperimentalLifecycleProvider + +**macOS target — optional, isolated.** The logical connect/disconnect mechanism. This is the +most safety-sensitive code in the project and is kept behind the `LifecycleProvider` protocol +(`Packages/ProviderInterfaces`) so it can be compiled, tested, disabled, kill-switched, or +**excluded entirely** from the public-API-only build (PRD §9.9, §10.9, OSS-02, D-001/D-008). + +- Feature-flagged and runtime-probed; certified per OS/Mac family (Apple Silicon baseline). +- Never reports its own success — the `TopologyCoordinator` verifies postconditions. +- A `recover(to:)` path restores from a checkpoint with minimal dependencies. + +Milestone: **M0 spike → M2**. The full transaction logic that drives this provider already +exists, platform-independently, in `Packages/TopologyCore` and is tested against +`SimulatorProvider`. + +> Stub — concrete implementation added on macOS in Xcode after the M0 boundary memo (Q-002). diff --git a/Providers/ExperimentalLifecycleProvider/Sources/BrightnessControl.swift b/Providers/ExperimentalLifecycleProvider/Sources/BrightnessControl.swift new file mode 100644 index 0000000..80d8ca4 --- /dev/null +++ b/Providers/ExperimentalLifecycleProvider/Sources/BrightnessControl.swift @@ -0,0 +1,50 @@ +#if os(macOS) +import CoreGraphics +import Foundation + +/// Hardware display brightness via the private `DisplayServices` framework, resolved with `dlsym` at +/// runtime — so it links without a private-framework dependency and degrades to "unavailable" when +/// the symbols are absent. This is the path Apple's own brightness HUD uses: it drives the built-in +/// panel and any external the framework recognizes (many do not — those need DDC/CI, a separate +/// provider). Undocumented SPI, so — like the SkyLight lifecycle path — it lives in this experimental +/// module and is excluded from the public-API-only build (NFR-010 / D-008). +public struct DisplayServicesBrightnessProvider: Sendable { + /// `(CGDirectDisplayID, float *out) -> 0 on success`. + private typealias GetFn = @convention(c) (CGDirectDisplayID, UnsafeMutablePointer) -> Int32 + /// `(CGDirectDisplayID, float value 0...1) -> 0 on success`. + private typealias SetFn = @convention(c) (CGDirectDisplayID, Float) -> Int32 + + private let getFn: GetFn? + private let setFn: SetFn? + + public init() { + let handle = dlopen( + "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", RTLD_LAZY) + getFn = Self.lookup(handle, "DisplayServicesGetBrightness", as: GetFn.self) + setFn = Self.lookup(handle, "DisplayServicesSetBrightness", as: SetFn.self) + } + + private static func lookup(_ handle: UnsafeMutableRawPointer?, _ name: String, as type: T.Type) -> T? { + guard let handle, let symbol = dlsym(handle, name) else { return nil } + return unsafeBitCast(symbol, to: T.self) + } + + /// True if the brightness symbols resolved on this OS. + public var isAvailable: Bool { getFn != nil && setFn != nil } + + /// The display's current brightness in 0...1, or nil if it can't be read (e.g. an external the + /// framework doesn't drive — the caller should treat that as "brightness unsupported here"). + public func brightness(for id: CGDirectDisplayID) -> Float? { + guard let getFn else { return nil } + var value: Float = 0 + return getFn(id, &value) == 0 ? value : nil + } + + /// Sets the display's brightness (clamped to 0...1). Returns false if unsupported or the call fails. + @discardableResult + public func setBrightness(_ value: Float, for id: CGDirectDisplayID) -> Bool { + guard let setFn else { return false } + return setFn(id, max(0, min(1, value))) == 0 + } +} +#endif diff --git a/Providers/ExperimentalLifecycleProvider/Sources/DDCControl.swift b/Providers/ExperimentalLifecycleProvider/Sources/DDCControl.swift new file mode 100644 index 0000000..d47399c --- /dev/null +++ b/Providers/ExperimentalLifecycleProvider/Sources/DDCControl.swift @@ -0,0 +1,111 @@ +#if os(macOS) +import CoreGraphics +import Foundation +import IOKit + +/// DDC/CI control of an external display over its private `IOAVService` I2C channel (Apple Silicon). +/// VCP feature codes follow the DDC/CI + MCCS spec: 0x10 brightness, 0x12 contrast, 0x62 audio +/// volume, 0x60 input source. The `IOAVService*` symbols are undocumented IOKit SPI resolved with +/// `dlsym` at runtime — so this links cleanly and is excluded from the public-API-only build, like +/// the SkyLight lifecycle and DisplayServices brightness paths. Serialized on its own actor and +/// using non-blocking sleeps for the DDC inter-message delays, so the slow I2C never touches the UI. +public actor ExternalDisplayDDC { + /// Common VCP feature codes (Monitor Control Command Set). + public enum Feature: UInt8, Sendable { + case brightness = 0x10 + case contrast = 0x12 + case volume = 0x62 + case inputSource = 0x60 + case colorPreset = 0x14 + } + + private typealias CreateFn = @convention(c) (CFAllocator?, io_service_t) -> Unmanaged? + private typealias WriteFn = @convention(c) (AnyObject, UInt32, UInt32, UnsafePointer?, UInt32) -> Int32 + private typealias ReadFn = @convention(c) (AnyObject, UInt32, UInt32, UnsafeMutablePointer?, UInt32) -> Int32 + + private let service: AnyObject + private let writeFn: WriteFn + private let readFn: ReadFn + + private static let i2cChip: UInt32 = 0x37 // DDC/CI 7-bit I2C address + private static let i2cSource: UInt32 = 0x51 // host source address (the "dataAddress" arg) + + /// Binds to the external display's IOAVService, or fails if the display is the built-in, has no + /// AV service, or the SPI is unavailable. + public init?(displayID: CGDirectDisplayID) { + guard CGDisplayIsBuiltin(displayID) == 0 else { return nil } + guard let iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW), + let createPtr = dlsym(iokit, "IOAVServiceCreateWithService"), + let writePtr = dlsym(iokit, "IOAVServiceWriteI2C"), + let readPtr = dlsym(iokit, "IOAVServiceReadI2C") + else { return nil } + let create = unsafeBitCast(createPtr, to: CreateFn.self) + writeFn = unsafeBitCast(writePtr, to: WriteFn.self) + readFn = unsafeBitCast(readPtr, to: ReadFn.self) + guard let svc = Self.avService(for: displayID) else { return nil } + defer { IOObjectRelease(svc) } + guard let av = create(kCFAllocatorDefault, svc) else { return nil } + service = av.takeRetainedValue() + } + + /// Reads a VCP feature: (current, max) in the display's native units (brightness/contrast 0...100 + /// on most panels), or nil if the display didn't answer. + public func read(_ feature: Feature) async -> (current: Int, max: Int)? { + let code = feature.rawValue + let checksum = UInt8(0x6e ^ Int(Self.i2cSource) ^ 0x82 ^ 0x01) ^ code + var request: [UInt8] = [0x82, 0x01, code, checksum] + guard writeFn(service, Self.i2cChip, Self.i2cSource, &request, 4) == 0 else { return nil } + try? await Task.sleep(nanoseconds: 60_000_000) + var buffer = [UInt8](repeating: 0, count: 12) + guard readFn(service, Self.i2cChip, Self.i2cSource, &buffer, 12) == 0, + buffer[0] == 0x6e, buffer[2] == 0x02, buffer[3] == 0x00, buffer[4] == code + else { return nil } // buffer[3] is the DDC result code; non-zero = feature unsupported + let maxValue = Int(buffer[6]) << 8 | Int(buffer[7]) + let current = Int(buffer[8]) << 8 | Int(buffer[9]) + return (current, maxValue) + } + + /// Sets a VCP feature to a value in the display's native units. Returns false if the write failed. + @discardableResult + public func write(_ feature: Feature, _ value: Int) async -> Bool { + let code = feature.rawValue + let high = UInt8((value >> 8) & 0xff) + let low = UInt8(value & 0xff) + let checksum = UInt8(0x6e ^ Int(Self.i2cSource) ^ 0x84 ^ 0x03) ^ code ^ high ^ low + var packet: [UInt8] = [0x84, 0x03, code, high, low, checksum] + let ok = writeFn(service, Self.i2cChip, Self.i2cSource, &packet, 6) == 0 + try? await Task.sleep(nanoseconds: 50_000_000) + return ok + } + + /// Maps a `CGDirectDisplayID` to its external `IOAVService`. Exact for a single external; with + /// several it matches by order among external displays (EDID matching is a later refinement). + private static func avService(for displayID: CGDirectDisplayID) -> io_service_t? { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices( + kIOMainPortDefault, IOServiceMatching("DCPAVServiceProxy"), &iterator) == KERN_SUCCESS + else { return nil } + defer { IOObjectRelease(iterator) } + var externals: [io_service_t] = [] + var service = IOIteratorNext(iterator) + while service != 0 { + let location = IORegistryEntryCreateCFProperty( + service, "Location" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String + if location == "External" { externals.append(service) } else { IOObjectRelease(service) } + service = IOIteratorNext(iterator) + } + guard !externals.isEmpty else { return nil } + let index = externalDisplayIDs().firstIndex(of: displayID) ?? 0 + let chosen = externals[min(index, externals.count - 1)] + for candidate in externals where candidate != chosen { IOObjectRelease(candidate) } + return chosen + } + + private static func externalDisplayIDs() -> [CGDirectDisplayID] { + var ids = [CGDirectDisplayID](repeating: 0, count: 16) + var count: UInt32 = 0 + CGGetOnlineDisplayList(16, &ids, &count) + return ids.prefix(Int(count)).filter { CGDisplayIsBuiltin($0) == 0 }.sorted() + } +} +#endif diff --git a/Providers/ExperimentalLifecycleProvider/Sources/DisplayRotation.swift b/Providers/ExperimentalLifecycleProvider/Sources/DisplayRotation.swift new file mode 100644 index 0000000..7c258da --- /dev/null +++ b/Providers/ExperimentalLifecycleProvider/Sources/DisplayRotation.swift @@ -0,0 +1,44 @@ +#if os(macOS) +import CoreGraphics +import Foundation + +/// EXPERIMENTAL, opt-in only. Rotates a display via the private SkyLight `SLSSetDisplayRotation`, +/// using ONLY the two-argument ABI corroborated by the MIT-licensed `knoll` project (recovered by +/// SkyLight disassembly): `CGError SLSSetDisplayRotation(CGDirectDisplayID, int32_t)`. No other +/// signature is attempted, and there is no IOKit fallback. The symbol is resolved at runtime, so an +/// absent symbol degrades to "unavailable" rather than crashing. +/// +/// This lives in the experimental module and is therefore excluded from the public-API-only / App +/// Store build (App Store guideline 2.5.1). It must never run unless explicitly enabled, and callers +/// should invoke it from a short-lived helper process after their own safety validation — a crash in +/// the WindowServer client path then kills only the helper. +public struct SkyLightDisplayRotator { + /// `CGError SLSSetDisplayRotation(CGDirectDisplayID, int32_t)`. + private typealias SetRotationFn = @convention(c) (CGDirectDisplayID, Int32) -> Int32 + private let setRotationFn: SetRotationFn? + + public init() { + let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY) + setRotationFn = handle + .flatMap { dlsym($0, "SLSSetDisplayRotation") } + .map { unsafeBitCast($0, to: SetRotationFn.self) } + } + + /// True if the rotation symbol resolved on this OS build. + public var isAvailable: Bool { setRotationFn != nil } + + /// Performs the rotation inside a normal Core Graphics configuration transaction (knoll's usage), + /// returning the raw CGError-style result, or a negative sentinel if the symbol is absent or the + /// transaction couldn't open. Performs NO safety validation — the helper/caller must validate the + /// angle (0/90/180/270) and display safety first, and verify the result afterwards. + @discardableResult + public func rotate(_ degrees: Int32, displayID: CGDirectDisplayID) -> Int32 { + guard let setRotationFn else { return -1 } + var config: CGDisplayConfigRef? + guard CGBeginDisplayConfiguration(&config) == .success, let config else { return -2 } + let result = setRotationFn(displayID, degrees) + guard CGCompleteDisplayConfiguration(config, .permanently) == .success else { return -3 } + return result + } +} +#endif diff --git a/Providers/ExperimentalLifecycleProvider/Sources/ExperimentalLifecycleProvider.swift b/Providers/ExperimentalLifecycleProvider/Sources/ExperimentalLifecycleProvider.swift new file mode 100644 index 0000000..02cb49d --- /dev/null +++ b/Providers/ExperimentalLifecycleProvider/Sources/ExperimentalLifecycleProvider.swift @@ -0,0 +1,151 @@ +#if os(macOS) +import ColorSync // CGDisplayGetDisplayIDFromUUID +import CoreGraphics +import DisplayDomain +import Foundation +import ProviderInterfaces + +/// The isolated, separable logical connect/disconnect provider (PRD §9.9, §10.9, LIF-003/004). +/// +/// Real mechanism: a **fully private CGS/SkyLight display-configuration transaction**. There is no +/// public API to remove a display from the active arrangement, so this drives the same three-step +/// shape the public `CGBeginDisplayConfiguration` flow uses, but with the SkyLight functions whose +/// signatures were recovered by disassembly (macOS 26 / SkyLight; no connection ID, config first): +/// +/// 1. `SLSBeginDisplayConfiguration(&config)` — allocates a SkyLight `CGSConfigData` +/// 2. `SLSConfigureDisplayEnabled(config, displayID, enabled)` — appends an enable/disable entry +/// 3. `SLSCompleteDisplayConfigurationWithOption(config, option)` — commit + free +/// +/// The config object MUST come from `SLSBeginDisplayConfiguration` (it carries a `0xbeefcafe` +/// capacity header that `checkCapacity` validates) — the public `CGDisplayConfigRef` is a different +/// object, and a `(cid, displayID, enabled)` call with no config segfaults in +/// `checkCapacity(CGSConfigData*)`. Disabling a display makes `CGGetActiveDisplayList` drop it — a +/// true logical disconnect, unlike mirroring. +/// +/// The private symbols are resolved with `dlsym` at runtime, so this links without a +/// private-framework dependency and **degrades to `.unsupported`** when a symbol is absent — the +/// router then falls back to the public mirroring provider. Undocumented and inherently +/// `.recoveryCritical`; excluded from the public-API-only build (NFR-010 / D-008) and Labs-gated. +/// Committed with the `forAppOnly` option, so the change reverts automatically if OpenDisplay exits +/// (matching the reconnect-on-quit default, D-005) — a strong safety net on top of the +/// coordinator's checkpoint/rollback and the independent rescue utility. Success is never reported +/// here — the coordinator verifies observed postconditions (D-010). +public struct ExperimentalLifecycleProvider: LifecycleProvider { + public let providerID = "experimentalLifecycle.v1" + public let isExperimental = true + + /// `(CGSConfigData **out) -> CGError` — one out-param, no connection ID. + private typealias BeginFn = @convention(c) (UnsafeMutablePointer) -> Int32 + /// `(CGSConfigData *config, CGDirectDisplayID display, bool enabled) -> CGError` — config FIRST. + private typealias ConfigureEnabledFn = @convention(c) (OpaquePointer?, CGDirectDisplayID, Bool) -> Int32 + /// `(CGSConfigData *config, CGSConfigureOption option) -> CGError` (option: 0=appOnly,1=session,2=permanent). + private typealias CompleteFn = @convention(c) (OpaquePointer?, Int32) -> Int32 + /// `(CGSConfigData *config) -> CGError` — discards a transaction (best-effort cleanup on error). + private typealias CancelFn = @convention(c) (OpaquePointer?) -> Int32 + + private let beginConfig: BeginFn? + private let configureEnabled: ConfigureEnabledFn? + private let completeConfig: CompleteFn? + private let cancelConfig: CancelFn? + + /// `forAppOnly`: the enable/disable reverts when this process exits. + private static let optionAppOnly: Int32 = 0 + + public init() { + let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY) + // Newer (SLS*) names first, then the legacy CGS* aliases. + beginConfig = + Self.lookup(handle, "SLSBeginDisplayConfiguration", as: BeginFn.self) + ?? Self.lookup(handle, "CGSBeginDisplayConfiguration", as: BeginFn.self) + configureEnabled = + Self.lookup(handle, "SLSConfigureDisplayEnabled", as: ConfigureEnabledFn.self) + ?? Self.lookup(handle, "CGSConfigureDisplayEnabled", as: ConfigureEnabledFn.self) + completeConfig = + Self.lookup(handle, "SLSCompleteDisplayConfigurationWithOption", as: CompleteFn.self) + ?? Self.lookup(handle, "CGSCompleteDisplayConfigurationWithOption", as: CompleteFn.self) + cancelConfig = + Self.lookup(handle, "SLSCancelDisplayConfiguration", as: CancelFn.self) + ?? Self.lookup(handle, "CGSCancelDisplayConfiguration", as: CancelFn.self) + } + + public func probe(_ environment: ProviderEnvironment) async -> ProviderProbe { + guard beginConfig != nil, configureEnabled != nil, completeConfig != nil else { + return ProviderProbe(providerID: providerID, status: .unsupported, + risk: .recoveryCritical, reasons: [.osVersion]) + } + guard environment.isAppleSilicon else { + return ProviderProbe(providerID: providerID, status: .unsupported, + risk: .recoveryCritical, reasons: [.architecture]) + } + return ProviderProbe(providerID: providerID, status: .supported, risk: .recoveryCritical) + } + + public func disconnect(_ target: DisplayRecordID, deadline: Date) async throws { + try setEnabled(target, enabled: false) + } + + public func reconnect(_ target: DisplayRecordID, deadline: Date) async throws { + try setEnabled(target, enabled: true) + } + + public func recover(to checkpoint: Checkpoint) async throws { + // Best effort: re-enable every display the checkpoint recorded as active. Prefer the raw + // CG display ID — a logically-disabled display may have dropped off the online list, so its + // persistent UUID can fail to resolve, but the numeric ID is still valid while connected. + for observation in checkpoint.observations where observation.isActive { + let target = observation.cgDisplayID.map { DisplayRecordID(rawValue: "cgid:\($0)") } + ?? observation.recordID + try? setEnabled(target, enabled: true) + } + } + + // MARK: - Private + + /// Runs one SkyLight display-config transaction that sets `target`'s enabled flag. + /// `enabled == false` is a true logical disconnect (the display leaves the active arrangement); + /// `true` restores it. + private func setEnabled(_ target: DisplayRecordID, enabled: Bool) throws { + guard let beginConfig, let configureEnabled, let completeConfig else { + throw ProviderFailure.unsupported(reason: [.osVersion]) + } + guard let displayID = Self.displayID(for: target) else { + throw ProviderFailure.ambiguous(candidates: []) + } + + var config: OpaquePointer? + let beginStatus = beginConfig(&config) + guard beginStatus == 0, let config else { + throw ProviderFailure.osRejected(code: Int(beginStatus)) + } + let configureStatus = configureEnabled(config, displayID, enabled) + guard configureStatus == 0 else { + _ = cancelConfig?(config) // best-effort: discard + free the aborted transaction + throw ProviderFailure.osRejected(code: Int(configureStatus)) + } + let completeStatus = completeConfig(config, Self.optionAppOnly) + guard completeStatus == 0 else { + throw ProviderFailure.osRejected(code: Int(completeStatus)) + } + } + + private static func lookup(_ handle: UnsafeMutableRawPointer?, _ symbol: String, as type: T.Type) -> T? { + guard let handle, let sym = dlsym(handle, symbol) else { return nil } + return unsafeBitCast(sym, to: T.self) + } + + /// Resolves an app record ID to a live `CGDirectDisplayID`. Mirrors the record-ID convention + /// minted by `CoreGraphicsProvider` (`cg:` / `cgid:`); kept local to avoid a + /// provider-to-provider dependency. + private static func displayID(for record: DisplayRecordID) -> CGDirectDisplayID? { + let raw = record.rawValue + if raw.hasPrefix("cgid:") { return UInt32(raw.dropFirst("cgid:".count)) } + if raw.hasPrefix("cg:") { + let uuidString = String(raw.dropFirst("cg:".count)) + guard let uuid = CFUUIDCreateFromString(kCFAllocatorDefault, uuidString as CFString) else { return nil } + let id = CGDisplayGetDisplayIDFromUUID(uuid) + return id != 0 ? id : nil + } + return nil + } +} +#endif diff --git a/Providers/VirtualDisplayProvider/README.md b/Providers/VirtualDisplayProvider/README.md new file mode 100644 index 0000000..d6d8463 --- /dev/null +++ b/Providers/VirtualDisplayProvider/README.md @@ -0,0 +1,10 @@ +# VirtualDisplayProvider + +**macOS target — Labs only.** Software-created display endpoints (headless, capture, Sidecar +targets) with configurable size/density and an explicit sleep/window policy (PRD VIR-001..003/ +007). Disabled by default, absent from the Core dependency graph, bypassable by safe mode; a +corrupt virtual definition must never create a startup loop. + +Implements: a `VirtualDisplayProvider` protocol. Milestone: **Labs (parallel, gated)**. + +> Stub — concrete implementation added on macOS in Xcode. diff --git a/Providers/VirtualDisplayProvider/Sources/VirtualDisplayProvider.swift b/Providers/VirtualDisplayProvider/Sources/VirtualDisplayProvider.swift new file mode 100644 index 0000000..b50a2ff --- /dev/null +++ b/Providers/VirtualDisplayProvider/Sources/VirtualDisplayProvider.swift @@ -0,0 +1,17 @@ +#if os(macOS) +import DisplayDomain +import ProviderInterfaces + +/// Labs-only software display endpoints (PRD VIR-001..003/007). Stub — disabled by default and +/// absent from the Core dependency graph; the real provider lands on the parallel Labs track. +public struct VirtualDisplayProvider: DisplayProvider { + public let providerID = "virtualDisplay.v1" + public let isExperimental = true + + public init() {} + + public func probe(_ environment: ProviderEnvironment) async -> ProviderProbe { + ProviderProbe(providerID: providerID, status: .unsupported, risk: .experimental, reasons: [.buildFlavor]) + } +} +#endif diff --git a/README.md b/README.md index 91c8fff..7ce18b8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,159 @@ # OpenDisplay -Open source display management for MacOS + +Open-source display management for macOS. + +OpenDisplay gives predictable, **safe** control over multiple displays: a stable +registry and topology model, scenes (desired-state snapshots), brightness/audio/input +controls over native and DDC routes, and — its defining capability — **safe logical +display disconnect/reconnect with independent recovery**. You can remove a supported +display from the active desktop without unplugging it, and always get it back, even if +the disconnected screen was the one showing the app. + +> **Status: pre-1.0, in active bring-up.** The product, architecture, and scope are +> defined in the [PRD](Docs/PRD.md). The platform-independent core (domain models, +> state machines, scene planner, safety engine, automation schema) ships with unit +> tests, and the macOS app is functional and verified on Apple Silicon hardware: +> a menu-bar UI with live **brightness** (built-in via DisplayServices, external via +> DDC/CI), **hardware controls** (contrast/volume over DDC), **mirroring**, **display +> modes** (resolution / refresh rate / HiDPI), **software dimming** (gamma, any +> display), **scenes**, and **safe logical disconnect** with an always-one-display-active +> guarantee and an automatic fall-back to the built-in panel. The rescue utility, the +> `opendisplay` CLI, and Shortcuts/Siri intents drive the same safety-checked, audited path. + +> Functional reference only: BetterDisplay. OpenDisplay is an independent, clean-room +> project — no BetterDisplay name, assets, copy, UI cloning, or proprietary code. It is +> not affiliated with or endorsed by BetterDisplay. + +## Features + +- **Unified brightness** for every display from one slider — built-in panels via the + system API, external monitors over **DDC/CI**, and a universal **software (gamma)** + fallback for displays that answer neither (including below the hardware minimum). +- **Hardware controls** over DDC/CI: contrast, volume, input source, and colour preset. +- **Per-display colour profiles** (ICC) via public ColorSync — applied with validation and + reversible to the factory profile, targeted by each display's persistent UUID. +- **Resolution, refresh rate, and HiDPI (Retina)** switching, plus **mirroring** and a + drag-to-arrange layout canvas. +- **Safe logical disconnect / reconnect** — remove a display from the desktop without + unplugging it, with an always-one-display-active guarantee, automatic fall-back to the + built-in panel, and independent recovery (menu, a global hotkey, and a separate rescue app). +- **Scenes** — save a display arrangement and re-apply it later. +- **Black Out** and **software dimming** on any display. +- **Automation** — an `opendisplay` CLI and Shortcuts/Siri intents drive the same + safety-checked, audited path as the UI. +- **Labs (opt-in):** experimental display **rotation** through a sandboxed helper — off by + default and compiled out of the public-API build entirely. + +Built for **Apple Silicon**: the slow I/O (DDC/CI, private SPI, ColorSync iteration) runs +off the main thread, so the menu stays responsive while monitors are being driven. + +## Install + +**Requirements:** an Apple Silicon Mac running macOS 14 (Sonoma) or later. (Developed and +verified on macOS 26 / Apple Silicon.) OpenDisplay runs as a menu-bar item — no Dock icon. + +### Option 1 — download the app + +1. Download `OpenDisplay.zip` from the [latest release](https://github.com/aquitaine/OpenDisplay/releases/latest). +2. Unzip it and move **OpenDisplay.app** to `/Applications`. +3. The build is **not yet notarized**, so Gatekeeper quarantines it on first launch. Clear + the quarantine flag once, then open it: + ```sh + xattr -dr com.apple.quarantine /Applications/OpenDisplay.app + open /Applications/OpenDisplay.app + ``` + (Or right-click the app → **Open** → **Open** to approve it the first time.) + +Then click the display glyph in the menu bar. + +### Option 2 — build from source + +The recommended path until signed/notarized releases ship — see +[Building the macOS app](#building-the-macos-app-on-a-mac) below. + +## Principles + +- **Safety before capability** — a feature that can make the desktop unreachable is + incomplete until recovery is independently usable. +- **Observed state ≠ desired state** — we record what macOS reports, what you want, and + who changed it. +- **Verify, do not assume** — a provider call is not success; outcomes are verified via + OS events / read-back, or reported as `unverified`. +- **Open by default, risky by consent** — experimental system behavior is opt-in, + reversible **Labs**, and never a dependency of normal startup or recovery. + +## Repository layout + +``` +Apps/OpenDisplay Menu-bar + settings app (SwiftUI/AppKit) [macOS, Xcode] +Apps/OpenDisplayRescue Independent signed rescue app + CLI [macOS, Xcode] +Tools/opendisplay Automation CLI [macOS] +Packages/DisplayDomain Models, identity scoring, state machines [cross-platform] ✅ tested +Packages/ProviderInterfaces Provider protocols + typed failures [cross-platform] ✅ +Packages/SceneEngine Desired-state scenes: diff/plan/idempotency [cross-platform] ✅ tested +Packages/AutomationSchema Stable JSON result/selector schema [cross-platform] ✅ tested +Packages/TopologyCore SafetyEngine + transaction coordinator [cross-platform] ✅ tested +Packages/SimulatorProvider In-memory provider for tests/previews [cross-platform] ✅ +Packages/OpenDisplayDesignSystem SwiftUI port of the design kit [macOS] +Providers/* CoreGraphics, DDC, NativeControl, Capture, + ExperimentalLifecycle (optional), VirtualDisplay (Labs) [macOS] +Docs/ Architecture, Recovery, Compatibility, RFCs, ADRs, PRD +Tests/ Fixtures + hardware-lab evidence +``` + +## Building & testing + +Local-first: the platform-independent core builds and tests anywhere a **Swift 6** +toolchain is installed (macOS Xcode 16+ or Linux). + +```sh +make bootstrap # ensure a Swift 6 toolchain (installs it on Ubuntu; checks Xcode on macOS) +make test # swift build && swift test --parallel (78 unit/state-machine tests) +make lint # SwiftLint, if installed +``` + +`make` with no target runs the tests. See `make help` for all targets. (`./scripts/test.sh` +also works if you prefer not to use make.) There is **no remote CI** — local `make test` is +the verification gate. + +### Building the macOS app (on a Mac) + +The app, rescue utility, CLI, design system, and providers are macOS targets generated from +[`project.yml`](project.yml) with [XcodeGen](https://github.com/yonaskolb/XcodeGen). The +generated `OpenDisplay.xcodeproj` is **not committed** — regenerate it locally: + +```sh +make xcode # installs XcodeGen if needed, runs `xcodegen generate` +open OpenDisplay.xcodeproj # build & run the OpenDisplay menu-bar app +# or headless: +xcodebuild -scheme OpenDisplay build +xcodebuild -scheme OpenDisplay-PublicAPIOnly build # public-API-only flavor (NFR-010) +``` + +The app drives real hardware on Apple Silicon: live enumeration and a reversible mirroring +fallback through `CoreGraphicsProvider`, true logical disconnect through the experimental +`ExperimentalLifecycleProvider` (SkyLight), built-in brightness via DisplayServices, and +external controls over DDC/CI. All macOS targets depend on the cross-platform packages +through the protocols in `ProviderInterfaces`, so the safety core stays platform-independent +and unit-tested. + +## Documentation + +- [Product Requirements Document](Docs/PRD.md) — the normative spec. +- [macOS Quickstart](Docs/MacQuickstart.md) — build & run the app on a Mac. +- [Architecture overview](Docs/Architecture/overview.md) +- [Recovery model](Docs/Recovery/recovery.md) +- [Architecture decisions](Docs/Architecture/decisions.md) +- [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md) · [Code of conduct](CODE_OF_CONDUCT.md) + +## Roadmap + +Delivery is milestone-based: **M0** safety spike → **M1** developer preview → **M2** +alpha → **M3** beta / Core 1.0 → **M4** Core 1.x, with **Labs** as a parallel gated +track. See the [milestones](https://github.com/aquitaine/opendisplay/milestones) and +the architecture docs. + +## License + +GPL-3.0-or-later (see [LICENSE](LICENSE)). A separately packaged provider/automation SDK +may adopt a permissive license in the future, subject to maintainer and legal review. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a39f5c2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security policy + +## Reporting a vulnerability + +Please report security issues **privately**, not in public issues. Use GitHub's private +vulnerability reporting (Security → Report a vulnerability) or email +`security@opendisplay.example` *(placeholder — replace before public launch)*. + +We aim to acknowledge reports promptly, work on a fix under coordinated disclosure, and +credit reporters who wish to be named. Supported-version and disclosure-timeline details +will be finalized before the first public release. + +## Scope & threat model (summary) + +OpenDisplay reasons about these primary threats (PRD §14.1): + +| Threat | Control | +|--------|---------| +| Malicious local automation request | Authenticated gateway, loopback default, non-bypassable safety checks, rate limits, audit log | +| Compromised provider/dependency | Minimal dependencies, provider isolation, SBOM, review, signing | +| Leaked display/network identifiers | Local-only storage; hash/redact on export; no analytics by default | +| Capture without clear consent | On-demand permission, active indicator, stop on lock/logout | +| Update incompatibility (black screen / startup loop) | Signed updates, OS compatibility flags, safe-mode migration, experimental defaults off | +| Corrupt settings/import | Schema validation, atomic writes, backups, quarantine, recovery-first startup | +| Stolen API token | Keychain, scoped/rotatable token, LAN off by default, audit & revoke | +| Supply-chain tampering | Protected branches, reproducible metadata, checksums, notarization, provenance/SBOM | + +## Privacy defaults + +No analytics, crash upload, network discovery, HTTP listener, screen capture, or LAN access +on a fresh install. Diagnostics are opt-in and previewable. Display serials, EDID, and +network identifiers are treated as potentially identifying; logs use pseudonymous IDs and +secrets live only in the Keychain — never in exported settings or support bundles. + +## Secure development + +Threat model and security review are required for the lifecycle provider, rescue IPC, update +channel, and local API before 1.0. Dependencies are pinned and scanned; high/critical +findings block release. diff --git a/Tests/HardwareLab/README.md b/Tests/HardwareLab/README.md new file mode 100644 index 0000000..d9c86d7 --- /dev/null +++ b/Tests/HardwareLab/README.md @@ -0,0 +1,14 @@ +# Hardware lab + +Evidence and fixtures from real-hardware testing (PRD §15.4). Display management can't be +validated by unit tests alone; this is where the certification runs live. + +- **Fixtures** (`../Fixtures`) — recorded Core Graphics / IORegistry event sequences (wake + storms, reorder, route loss, mode invalidation, identical-monitor swaps) replayed against + the coordinator and providers in integration-simulation tests. +- **Hardware matrix runs** — per-release evidence across the Mac/dock/KVM/display classes in + PRD §15.4, including the 1,000-cycle endurance and fault-injection suites. +- **Certification records** feed `Docs/Compatibility/`. + +The 30 critical scenarios (T-001…T-030, PRD §15.3) are tracked here and in the test suites; +the fault-injection + recovery subset is a release gate and must be 100% green. diff --git a/Tools/opendisplay/README.md b/Tools/opendisplay/README.md new file mode 100644 index 0000000..af880d1 --- /dev/null +++ b/Tools/opendisplay/README.md @@ -0,0 +1,27 @@ +# opendisplay (CLI) + +**macOS target** (Swift ArgumentParser). Scripts all supported get/set/toggle/scene/lifecycle +actions through the same `AutomationGateway` — and therefore the same identity, capability, +safety, transaction, verification, and audit path — as the UI (PRD §12, AUT-001..004/011). + +- Stable selectors (`Packages/DisplayDomain/Selector.swift`); ambiguous selectors return + candidates and perform no mutation. +- Machine-readable JSON via `Packages/AutomationSchema` (`ResultEnvelope`); documented exit codes. +- `--dry-run` for every multi-field or lifecycle mutation. + +Proposed grammar (PRD §12.2): + +``` +opendisplay list [--state active|offline|all] [--json] +opendisplay get [field ...] [--json] +opendisplay set ... [--dry-run] [--json] +opendisplay connect|disconnect [--dry-run] +opendisplay blackout on|off|toggle +opendisplay scene list|show|apply|export|import [--dry-run] +opendisplay recover all|checkpoint|safe-mode +opendisplay diagnose display|route|provider|bundle [selector] +``` + +Milestone: **M1**. + +> Stub — Xcode/SPM executable target added on macOS. diff --git a/Tools/opendisplay/Sources/main.swift b/Tools/opendisplay/Sources/main.swift new file mode 100644 index 0000000..849bddc --- /dev/null +++ b/Tools/opendisplay/Sources/main.swift @@ -0,0 +1,532 @@ +import AutomationSchema +import CoreGraphics +import CoreGraphicsProvider +import DisplayDomain +import ExperimentalLifecycleProvider +import Foundation +import ProviderInterfaces +import SceneEngine +import TopologyCore + +// OpenDisplay automation CLI (PRD §12). Mutating commands route through CommandGateway (the same +// audited, safety-checked path the UI and App Intents use). A persisted DisplayRegistry recognizes +// displays across reconnects and stores user aliases/tags, so `alias:`/`tag:` selectors resolve. +// `disconnect --dry-run` previews the SafetyEngine decision without touching hardware. + +// MARK: - Argument parsing + +let rawArgs = Array(CommandLine.arguments.dropFirst()) +let flags = Set(rawArgs.filter { $0.hasPrefix("--") }) +let positional = rawArgs.filter { !$0.hasPrefix("--") } +let command = positional.first ?? "list" +let selectorArg: String? = positional.count > 1 ? positional[1] : nil +let valueArg: String? = positional.count > 2 ? positional[2] : nil +let asJSON = flags.contains("--json") +let dryRun = flags.contains("--dry-run") + +#if arch(arm64) +let isAppleSilicon = true +#else +let isAppleSilicon = false +#endif + +func fail(_ message: String, code: Int32 = 1) -> Never { + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + exit(code) +} + +func emit(_ value: T) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(value), let text = String(data: data, encoding: .utf8) else { + fail("failed to encode JSON output") + } + print(text) +} + +// MARK: - Composition root + +let observer = CoreGraphicsProvider() +let environment = ProviderEnvironment( + osBuild: ProcessInfo.processInfo.operatingSystemVersionString, + isAppleSilicon: isAppleSilicon, transport: .unknown, displayClass: .unknown +) +let experimental = ExperimentalLifecycleProvider() +let lifecycle: any LifecycleProvider = + await experimental.probe(environment).status == .supported ? experimental : observer +let checkpoints: any CheckpointStoring = + (try? DiskCheckpointStore.defaultDirectory()).map(DiskCheckpointStore.init(directory:)) + ?? InMemoryCheckpointStore() +let auditLog = (try? DiskAuditLog.defaultDirectory()).map(DiskAuditLog.init(directory:)) +let gateway = CommandGateway( + observer: observer, lifecycleProvider: lifecycle, checkpoints: checkpoints, auditLog: auditLog +) +let registryStore: any RegistryStoring = + (try? DiskRegistryStore.defaultDirectory()).map { DiskRegistryStore(directory: $0) } + ?? InMemoryRegistryStore() +let registry = await DisplayRegistry(store: registryStore) +let sceneStore: any SceneStoring = + (try? DiskSceneStore.defaultDirectory()).map { DiskSceneStore(directory: $0) } + ?? InMemorySceneStore() +let sceneLibrary = await SceneLibrary(store: sceneStore) + +typealias ResolvedDisplay = (observation: DisplayObservation, record: DisplayRecord) + +/// Resolves every live display's fingerprint into the registry (recognizing or minting), so the +/// registry learns the current displays and we can map observations <-> records for this run. +func resolveCurrentDisplays() async -> [ResolvedDisplay] { + let snapshot = await observer.currentSnapshot() + let observations = snapshot.observations.filter { $0.cgDisplayID != nil } + let inputs = observations.compactMap { + obs -> (fingerprint: DisplayFingerprint, cgUUID: String?, displayClass: DisplayClass)? in + guard let cgID = obs.cgDisplayID else { return nil } + return (observer.fingerprint(for: cgID), obs.cgUUID, obs.displayClass) + } + // One batched resolve → exactly one registry write for the whole display set this run. + let records = await registry.resolveAll(inputs) + return Array(zip(observations, records)) +} + +// MARK: - Selector resolution + +func reachability(of observation: DisplayObservation, managedOffline: Set) -> Reachability { + if managedOffline.contains(observation.recordID) { return .managedOffline } + return observation.isActive ? .active : .discoveredInactive +} + +func resolveObservation(_ raw: String, in pairs: [ResolvedDisplay], + managedOffline: Set) -> [DisplayObservation] { + if let cgID = UInt32(raw) { + return pairs.filter { $0.observation.cgDisplayID == cgID }.map(\.observation) + } + let selector: DisplaySelector + do { selector = try DisplaySelector.parse(raw) } catch { fail("could not parse selector '\(raw)': \(error)") } + switch selector { + case .id(let recordID): + return pairs.filter { $0.observation.recordID == recordID || $0.record.id == recordID }.map(\.observation) + case .alias(let alias): + return pairs.filter { $0.record.alias == alias }.map(\.observation) + case .tag(let tag): + return pairs.filter { $0.record.tags.contains(tag) }.map(\.observation) + case .role(.main): + return pairs.filter { $0.observation.isMain }.map(\.observation) + case .role(.builtin): + return pairs.filter { $0.observation.displayClass == .builtIn }.map(\.observation) + case .state(let reach): + return pairs.filter { reachability(of: $0.observation, managedOffline: managedOffline) == reach }.map(\.observation) + case .role, .name, .fingerprint, .topology: + fail("selector '\(raw)' isn't resolvable yet (use id:/alias:/tag:/main/builtin/state:/)") + } +} + +func uniqueDisplay(_ raw: String, in pairs: [ResolvedDisplay], + managedOffline: Set) -> ResolvedDisplay { + let matches = resolveObservation(raw, in: pairs, managedOffline: managedOffline) + guard !matches.isEmpty else { fail("no display matches '\(raw)'") } + guard matches.count == 1 else { + fail("'\(raw)' is ambiguous (\(matches.count) displays): \(matches.map(\.recordID.rawValue).joined(separator: ", "))") + } + let observation = matches[0] + return pairs.first { $0.observation.recordID == observation.recordID }! +} + +// MARK: - Output + +func name(for pair: ResolvedDisplay) -> String { + pair.record.alias ?? pair.record.fingerprint.modelName ?? pair.observation.recordID.rawValue +} + +func modeString(_ observation: DisplayObservation) -> String? { + observation.mode.map { "\($0.pixelWidth)x\($0.pixelHeight)@\(Int($0.refreshHz.rounded()))" } +} + +func emitEnvelope(_ envelope: ResultEnvelope) { + if asJSON { emit(envelope); return } + print("\(envelope.status.rawValue) [\(envelope.transactionId)]") + for target in envelope.targets { + let ops = target.operations.map { "\($0.field)=\($0.verification.rawValue)" }.joined(separator: ", ") + print(" \(target.displayId): \(ops)") + } + for error in envelope.errors { + print(" ! \(error.code): \(error.message)") + } +} + +// MARK: - Commands + +func runList() async { + let pairs = await resolveCurrentDisplays().sorted { $0.observation.recordID.rawValue < $1.observation.recordID.rawValue } + if asJSON { + struct Row: Encodable { + var id: String; var recordId: String; var alias: String?; var tags: [String] + var cgDisplayID: UInt32?; var active: Bool; var main: Bool + var displayClass: String; var mode: String?; var origin: String + } + emit(pairs.map { + Row(id: $0.observation.recordID.rawValue, recordId: $0.record.id.rawValue, alias: $0.record.alias, + tags: $0.record.tags.sorted(), cgDisplayID: $0.observation.cgDisplayID, + active: $0.observation.isActive, main: $0.observation.isMain, + displayClass: $0.observation.displayClass.rawValue, mode: modeString($0.observation), + origin: "(\($0.observation.origin.x),\($0.observation.origin.y))") + }) + return + } + for pair in pairs { + let mark = pair.observation.isActive ? "●" : "○" + let main = pair.observation.isMain ? " [main]" : "" + let tags = pair.record.tags.isEmpty ? "" : " #\(pair.record.tags.sorted().joined(separator: " #"))" + print("\(mark) \(name(for: pair))\(main) \(modeString(pair.observation) ?? "—")\(tags)") + } +} + +func runDiagnose() async { + let probes = [ + ("coregraphics", false, await observer.probe(environment)), + ("experimentalLifecycle", true, await experimental.probe(environment)) + ] + if asJSON { + struct Probe: Encodable { var provider: String; var experimental: Bool; var status: String; var risk: String; var reasons: [String] } + emit(probes.map { id, experimental, probe in + Probe(provider: id, experimental: experimental, status: probe.status.rawValue, + risk: probe.risk.rawValue, reasons: probe.reasons.map(\.rawValue)) + }) + return + } + for (id, experimental, probe) in probes { + let labsTag = experimental ? " [labs]" : "" + let reasons = probe.reasons.map(\.rawValue) + let suffix = reasons.isEmpty ? "" : " (\(reasons.joined(separator: ",")))" + print("\(id)\(labsTag): \(probe.status.rawValue) · risk=\(probe.risk.rawValue)\(suffix)") + } +} + +func runAlias() async { + guard let selectorArg, let valueArg else { fail("usage: opendisplay alias ") } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + let target = uniqueDisplay(selectorArg, in: pairs, managedOffline: Set(snapshot.managedOffline.map(\.displayID))) + await registry.setAlias(valueArg, for: target.record.id) + print("aliased \(target.observation.recordID.rawValue) → \"\(valueArg)\"") +} + +func runTag() async { + guard let selectorArg, let valueArg else { fail("usage: opendisplay tag ") } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + let target = uniqueDisplay(selectorArg, in: pairs, managedOffline: Set(snapshot.managedOffline.map(\.displayID))) + await registry.addTag(valueArg, to: target.record.id) + print("tagged \(name(for: target)) #\(valueArg)") +} + +func runRecover() async { + let envelope = await gateway.reconnectAll(actor: .cli) + if !asJSON && envelope.targets.isEmpty { + print("recover: nothing to reconnect") + return + } + emitEnvelope(envelope) +} + +func runDisconnect() async { + guard let selectorArg else { fail("usage: opendisplay disconnect [--dry-run] [--json]") } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + let target = uniqueDisplay(selectorArg, in: pairs, managedOffline: Set(snapshot.managedOffline.map(\.displayID))) + + if dryRun { + let outcome = await gateway.preflightDisconnect(target.observation.recordID, identityConfidence: 1.0) + let surface = outcome.safeSurface?.rawValue ?? "none" + switch outcome.decision { + case .allowed: + print("dry-run: ALLOWED — would disconnect \(name(for: target)); safe surface = \(surface)") + case .needsConfirmation: + print("dry-run: NEEDS CONFIRMATION (\(outcome.reasons.joined(separator: ","))) — safe surface = \(surface)") + case .blocked: + print("dry-run: BLOCKED (\(outcome.reasons.joined(separator: ",")))") + } + return + } + + let envelope = await gateway.disconnect( + target.observation.recordID, options: DisconnectOptions(actor: .cli, identityConfidence: 1.0) + ) + emitEnvelope(envelope) +} + +func runReconnect() async { + guard let selectorArg else { fail("usage: opendisplay reconnect [--json]") } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + let target = uniqueDisplay(selectorArg, in: pairs, managedOffline: Set(snapshot.managedOffline.map(\.displayID))) + do { + try await lifecycle.reconnect(target.observation.recordID, deadline: Date().addingTimeInterval(15)) + if asJSON { emit(["status": "committed", "target": target.observation.recordID.rawValue]) } + else { print("reconnected \(name(for: target))") } + } catch { + fail("reconnect failed: \(error)") + } +} + +// MARK: - Scenes + +/// Resolves a scene member selector to a single record id, or nil if absent/ambiguous (unlike the +/// command selectors, a scene with an unresolved member is "missing", not an error). +func resolveMember(_ selector: String, in pairs: [ResolvedDisplay]) -> DisplayRecordID? { + if let cgID = UInt32(selector) { + let matches = pairs.filter { $0.observation.cgDisplayID == cgID } + return matches.count == 1 ? matches[0].observation.recordID : nil + } + guard let parsed = try? DisplaySelector.parse(selector) else { return nil } + let matches: [DisplayRecordID] + switch parsed { + case .id(let recordID): + matches = pairs.filter { $0.observation.recordID == recordID || $0.record.id == recordID }.map(\.observation.recordID) + case .alias(let alias): + matches = pairs.filter { $0.record.alias == alias }.map(\.observation.recordID) + case .tag(let tag): + matches = pairs.filter { $0.record.tags.contains(tag) }.map(\.observation.recordID) + case .role(.main): + matches = pairs.filter { $0.observation.isMain }.map(\.observation.recordID) + case .role(.builtin): + matches = pairs.filter { $0.observation.displayClass == .builtIn }.map(\.observation.recordID) + default: + matches = [] + } + return matches.count == 1 ? matches[0] : nil +} + +func runScene() async { + let sub = positional.count > 1 ? positional[1] : "list" + let nameArg: String? = positional.count > 2 ? positional[2] : nil + + switch sub { + case "list": + let scenes = await sceneLibrary.all() + if asJSON { emit(scenes) } + else if scenes.isEmpty { print("no saved scenes (capture one with: scene save )") } + else { for scene in scenes { print("\(scene.name) (\(scene.members.count) displays)") } } + + case "save": + guard let nameArg else { fail("usage: opendisplay scene save ") } + let snapshot = await observer.currentSnapshot() + let id = await sceneLibrary.scene(named: nameArg)?.id ?? "scene_\(UUID().uuidString.prefix(8))" + let scene = SceneRecorder.capture(from: snapshot, name: nameArg, id: String(id)) + await sceneLibrary.save(scene) + print("saved scene \"\(nameArg)\" (\(scene.members.count) displays)") + + case "show": + guard let nameArg, let scene = await sceneLibrary.scene(named: nameArg) else { + fail("no scene named '\(nameArg ?? "")'") + } + if asJSON { emit(scene); return } + print("Scene \"\(scene.name)\":") + for member in scene.members { + var parts: [String] = [] + if let connected = member.desired.connected { parts.append(connected ? "connected" : "offline") } + if member.desired.main == true { parts.append("main") } + if let p = member.desired.position { parts.append("pos=(\(p.x),\(p.y))") } + if let m = member.desired.mode { parts.append("\(m.pixelWidth)x\(m.pixelHeight)") } + print(" \(member.selector): \(parts.joined(separator: ", "))") + } + + case "plan": + guard let nameArg, let scene = await sceneLibrary.scene(named: nameArg) else { + fail("no scene named '\(nameArg ?? "")'") + } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + var resolution: ScenePlanner.Resolution = [:] + for member in scene.members { + if let recordID = resolveMember(member.selector, in: pairs) { resolution[member.selector] = recordID } + } + let plan = ScenePlanner().plan(scene: scene, snapshot: snapshot, resolution: resolution) + if asJSON { emit(plan); return } + if plan.isBlocked { print("scene \"\(scene.name)\": BLOCKED — missing required: \(plan.missingRequired.joined(separator: ", "))") } + else if !plan.hasWork { print("scene \"\(scene.name)\": already satisfied (no changes)") } + for op in plan.operations where op.status == .willApply { + print(" → \(op.kind.rawValue) \(op.target.rawValue): \(op.detail)") + } + let satisfied = plan.operations.filter { $0.status == .alreadySatisfied }.count + if satisfied > 0 { print(" (\(satisfied) already satisfied)") } + for selector in plan.missingOptional { print(" · skipped (absent): \(selector)") } + + case "apply": + guard let nameArg, let scene = await sceneLibrary.scene(named: nameArg) else { + fail("no scene named '\(nameArg ?? "")'") + } + let pairs = await resolveCurrentDisplays() + let snapshot = await observer.currentSnapshot() + var targets: [CoreGraphicsProvider.ArrangementTarget] = [] + var skipped: [String] = [] + for member in scene.members { + guard let recordID = resolveMember(member.selector, in: pairs), + let observation = snapshot.observation(for: recordID), + let cgID = observation.cgDisplayID else { + skipped.append("\(member.selector): not present") + continue + } + if let rotation = member.desired.rotation, rotation != .degrees0 { + skipped.append("\(member.selector): rotation not applied (needs private API)") + } + if member.desired.brightness != nil { skipped.append("\(member.selector): brightness not applied (no control provider yet)") } + if member.desired.connected == false { skipped.append("\(member.selector): disconnect not applied by scene apply") } + targets.append(.init(displayID: cgID, origin: member.desired.position, mode: member.desired.mode)) + } + guard !targets.isEmpty else { fail("scene \"\(scene.name)\" resolved to no present displays") } + let warnings = observer.applyArrangement(targets) + print("applied scene \"\(scene.name)\" to \(targets.count) display(s)") + for note in warnings + skipped { print(" · \(note)") } + + case "delete": + guard let nameArg, let scene = await sceneLibrary.scene(named: nameArg) else { + fail("no scene named '\(nameArg ?? "")'") + } + await sceneLibrary.delete(id: scene.id) + print("deleted scene \"\(nameArg)\"") + + default: + fail("unknown scene subcommand '\(sub)' (try: list, save, show, plan, apply, delete)", code: 2) + } +} + +// MARK: - Control commands + +/// Get or set a display's brightness (0..1). Built-in via DisplayServices, external via DDC. +func runBrightness() async { + guard let sel = selectorArg else { fail("usage: opendisplay brightness [0..1]") } + let pairs = await resolveCurrentDisplays() + let target = uniqueDisplay(sel, in: pairs, managedOffline: []) + guard let cgID = target.observation.cgDisplayID else { fail("display has no Core Graphics id") } + let builtIn = target.observation.displayClass == .builtIn + if let raw = valueArg { + guard let level = Float(raw), (0...1).contains(level) else { fail("brightness value must be 0..1") } + if builtIn { + let ok = DisplayServicesBrightnessProvider().setBrightness(level, for: cgID) + print(ok ? "\(name(for: target)): brightness = \(Int((level * 100).rounded()))%" + : "failed (DisplayServices unavailable)") + } else if let ddc = ExternalDisplayDDC(displayID: cgID) { + let maxValue = await ddc.read(.brightness)?.max ?? 100 + let ok = await ddc.write(.brightness, Int(level * Float(maxValue))) + print(ok ? "\(name(for: target)): brightness = \(Int((level * 100).rounded()))% (DDC)" + : "DDC write failed") + } else { + fail("no brightness control for this display") + } + } else if builtIn, let value = DisplayServicesBrightnessProvider().brightness(for: cgID) { + print("\(Int((value * 100).rounded()))%") + } else if !builtIn, let ddc = ExternalDisplayDDC(displayID: cgID), + let reading = await ddc.read(.brightness), reading.max > 0 { + print("\(Int((Float(reading.current) / Float(reading.max) * 100).rounded()))% (\(reading.current)/\(reading.max), DDC)") + } else { + print("unsupported") + } +} + +/// Get or set a raw DDC/CI feature on an external display. +func runDDC() async { + let featureNames: [String: ExternalDisplayDDC.Feature] = [ + "brightness": .brightness, "contrast": .contrast, "volume": .volume, "input": .inputSource, + "colour": .colorPreset, "color": .colorPreset, "preset": .colorPreset, + ] + guard let sel = selectorArg, let featureArg = valueArg else { + fail("usage: opendisplay ddc [value]") + } + guard let feature = featureNames[featureArg.lowercased()] else { + fail("unknown feature '\(featureArg)' (brightness|contrast|volume|input|colour)") + } + let pairs = await resolveCurrentDisplays() + let target = uniqueDisplay(sel, in: pairs, managedOffline: []) + guard let cgID = target.observation.cgDisplayID, let ddc = ExternalDisplayDDC(displayID: cgID) else { + fail("no DDC for this display (external displays only)") + } + let setValue = positional.count > 3 ? positional[3] : nil + if let raw = setValue { + guard let value = Int(raw) else { fail("value must be an integer") } + let ok = await ddc.write(feature, value) + print(ok ? "\(featureArg) = \(value)" : "DDC write failed") + } else if let reading = await ddc.read(feature) { + print("\(featureArg): \(reading.current)/\(reading.max)") + } else { + print("\(featureArg): unsupported") + } +} + +// MARK: - Experimental rotation helper (short-lived, gated, isolated) + +/// EXPERIMENTAL rotation writer. Gated behind OPENDISPLAY_EXPERIMENTAL_ROTATION=1 so it never runs by +/// accident. Validates the angle + display safety, calls the private SkyLight rotator, polls +/// CGDisplayRotation to confirm, verifies no other display moved, and rolls back on any mismatch. +/// Running this in its own process isolates the app from a client-side WindowServer crash. +func runRotateExperimental() async { + guard ProcessInfo.processInfo.environment["OPENDISPLAY_EXPERIMENTAL_ROTATION"] == "1" else { + fail("experimental rotation disabled — set OPENDISPLAY_EXPERIMENTAL_ROTATION=1 to opt in", code: 3) + } + guard let sel = selectorArg, let raw = valueArg, let degrees = Int(raw) else { + fail("usage: OPENDISPLAY_EXPERIMENTAL_ROTATION=1 opendisplay _rotate-exp <0|90|180|270>") + } + guard [0, 90, 180, 270].contains(degrees) else { fail("angle must be 0, 90, 180 or 270") } + let pairs = await resolveCurrentDisplays() + let target = uniqueDisplay(sel, in: pairs, managedOffline: []) + guard let cgID = target.observation.cgDisplayID else { fail("target has no Core Graphics id") } + let snapshot = await observer.currentSnapshot() + let active = snapshot.activeDisplays + guard target.observation.isActive else { fail("target display is not active") } + guard target.observation.mirrorSourceID == nil else { fail("refusing to rotate a mirrored display") } + guard active.count > 1 else { fail("refusing: target is the only active display") } + + let before = Int(CGDisplayRotation(cgID).rounded()) + let rotator = SkyLightDisplayRotator() + guard rotator.isAvailable else { fail("SLSSetDisplayRotation unavailable on this OS", code: 4) } + + let rc = rotator.rotate(Int32(degrees), displayID: cgID) + var observed = before + for _ in 0..<12 { usleep(150_000); observed = Int(CGDisplayRotation(cgID).rounded()); if observed == degrees { break } } + // No other active display should have changed rotation. + let othersOK = active.allSatisfy { other in + guard let oid = other.cgDisplayID, oid != cgID else { return true } + return Int(CGDisplayRotation(oid).rounded()) == other.rotation.rawValue + } + if observed == degrees && othersOK { + print("rotated \(cgID) \(before)° → \(degrees)° (rc=\(rc))") + } else { + _ = rotator.rotate(Int32(before), displayID: cgID) + fail("verification failed (rc=\(rc), observed=\(observed)°, othersOK=\(othersOK)) — rolled back to \(before)°", code: 5) + } +} + +// MARK: - Dispatch + +switch command { +case "list": await runList() +case "diagnose": await runDiagnose() +case "alias": await runAlias() +case "tag": await runTag() +case "recover": await runRecover() +case "disconnect": await runDisconnect() +case "reconnect": await runReconnect() +case "scene": await runScene() +case "brightness": await runBrightness() +case "ddc": await runDDC() +case "_rotate-exp": await runRotateExperimental() +case "help", "--help", "-h": + print(""" + opendisplay — OpenDisplay automation CLI + + USAGE: + opendisplay list [--json] + opendisplay diagnose [--json] + opendisplay alias + opendisplay tag + opendisplay disconnect [--dry-run] [--json] + opendisplay reconnect [--json] + opendisplay recover [--json] + opendisplay scene [name] [--json] + opendisplay brightness [0..1] + opendisplay ddc [value] + + SELECTORS: id: · alias: · tag: · main · builtin · state: · + """) +default: + fail("unknown command '\(command)' (try: list, diagnose, alias, tag, disconnect, reconnect, recover, scene, brightness, ddc, help)", code: 2) +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..3441804 --- /dev/null +++ b/project.yml @@ -0,0 +1,255 @@ +# XcodeGen spec for the macOS build (app bundles, CLI tool, frameworks). Generate with +# `make xcode` (runs `xcodegen generate`). The generated OpenDisplay.xcodeproj is NOT +# committed — regenerate it locally. +# +# Shared core: the platform-independent modules in Packages/ are ALSO declared in Package.swift +# (so `make test` builds + tests them on Linux/macOS — one source of truth for the CODE). Here we +# compile those same source directories as native, *dynamic* macOS frameworks rather than consuming +# them as SwiftPM products. That is deliberate: +# +# The app links each core module into MANY Mach-O images — every provider framework +# (CoreGraphicsProvider, ExperimentalLifecycleProvider, …) AND the app/CLI/rescue. If a module is +# linked statically, its type metadata is copied into every image, so a public type like +# `ProviderInterfaces.ProviderFailure` exists as several distinct runtime types. `as?` / `catch as` +# across the framework boundary then compiles but silently fails at runtime (the coordinator's +# `catch let failure as ProviderFailure` falls through). One dynamic framework per module = exactly +# one copy of each type, so cross-boundary casts work. +# +# SwiftPM `.dynamic` library products cannot express this in Xcode 16/26: a package target that is +# *also* an internal package dependency (DisplayDomain under ProviderInterfaces/TopologyCore/…) can't +# be built dynamically when a same-named product exists ("cannot be built dynamically because there +# is a package product with the same name"), and renaming the product makes Xcode emit empty/duplicate +# framework wrappers. Native framework targets are the reliable mechanism, so the core lives here. +name: OpenDisplay + +options: + bundleIdPrefix: dev.opendisplay + deploymentTarget: + macOS: "14.0" + createIntermediateGroups: true + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic + ENABLE_HARDENED_RUNTIME: YES + +targets: + # --- Shared core (dynamic frameworks compiled from the cross-platform SPM sources) --- + # Frameworks need a synthesized Info.plist; without it codesign rejects the bundle + # ("bundle format unrecognized"). The app/CLI targets set INFOPLIST_FILE explicitly instead. + DisplayDomain: + type: framework + platform: macOS + settings: &frameworkSettings + base: + GENERATE_INFOPLIST_FILE: YES + sources: [Packages/DisplayDomain/Sources/DisplayDomain] + + ProviderInterfaces: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/ProviderInterfaces/Sources/ProviderInterfaces] + dependencies: + - target: DisplayDomain + + SceneEngine: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/SceneEngine/Sources/SceneEngine] + dependencies: + - target: DisplayDomain + + AutomationSchema: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/AutomationSchema/Sources/AutomationSchema] + dependencies: + - target: DisplayDomain + + TopologyCore: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/TopologyCore/Sources/TopologyCore] + dependencies: + - target: DisplayDomain + - target: ProviderInterfaces + - target: SceneEngine + - target: AutomationSchema + + SimulatorProvider: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/SimulatorProvider/Sources/SimulatorProvider] + dependencies: + - target: DisplayDomain + - target: ProviderInterfaces + + # --- Design system (framework) --- + OpenDisplayDesignSystem: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Packages/OpenDisplayDesignSystem/Sources] + dependencies: + - target: DisplayDomain + + # --- Providers (frameworks). Each imports DisplayDomain + ProviderInterfaces. --- + CoreGraphicsProvider: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Providers/CoreGraphicsProvider/Sources] + dependencies: &providerDeps + - target: DisplayDomain + - target: ProviderInterfaces + + CaptureProvider: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Providers/CaptureProvider/Sources] + dependencies: *providerDeps + + ExperimentalLifecycleProvider: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Providers/ExperimentalLifecycleProvider/Sources] + dependencies: *providerDeps + + VirtualDisplayProvider: + type: framework + platform: macOS + settings: *frameworkSettings + sources: [Providers/VirtualDisplayProvider/Sources] + dependencies: *providerDeps + + # --- Menu-bar + settings app (full: Core + experimental providers) --- + # Apps EMBED the full closure of dynamic frameworks (XcodeGen embeds framework deps of an app by + # default; the inter-framework deps above link without embedding). Every core + provider framework + # the app or its frameworks need is listed so the closure is embedded exactly once. + OpenDisplay: + type: application + platform: macOS + sources: [Apps/OpenDisplay/Sources] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.opendisplay.app + INFOPLIST_FILE: Apps/OpenDisplay/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: Apps/OpenDisplay/Resources/OpenDisplay.entitlements + dependencies: + - target: OpenDisplayDesignSystem + - target: CoreGraphicsProvider + - target: CaptureProvider + - target: ExperimentalLifecycleProvider + - target: VirtualDisplayProvider + - target: DisplayDomain + - target: ProviderInterfaces + - target: TopologyCore + - target: SceneEngine + - target: AutomationSchema + - target: SimulatorProvider + + # --- Public-API-only flavor: same sources, experimental/virtual providers excluded + # (NFR-010 / D-008); defines PUBLIC_API_ONLY for conditional wiring. --- + OpenDisplay-PublicAPIOnly: + type: application + platform: macOS + sources: [Apps/OpenDisplay/Sources] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.opendisplay.app.publicapionly + INFOPLIST_FILE: Apps/OpenDisplay/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: Apps/OpenDisplay/Resources/OpenDisplay.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: PUBLIC_API_ONLY + dependencies: + - target: OpenDisplayDesignSystem + - target: CoreGraphicsProvider + - target: CaptureProvider + - target: DisplayDomain + - target: ProviderInterfaces + - target: TopologyCore + - target: SceneEngine + - target: AutomationSchema + - target: SimulatorProvider + + # --- Independent rescue utility --- + OpenDisplayRescue: + type: application + platform: macOS + sources: [Apps/OpenDisplayRescue/Sources] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.opendisplay.rescue + INFOPLIST_FILE: Apps/OpenDisplayRescue/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: Apps/OpenDisplayRescue/Resources/OpenDisplayRescue.entitlements + dependencies: + - target: CoreGraphicsProvider + - target: ExperimentalLifecycleProvider + - target: DisplayDomain + - target: ProviderInterfaces + - target: SceneEngine + - target: TopologyCore + + # --- Automation CLI --- + # A command-line tool is not a bundle, so it can't embed frameworks. We link the dynamic core + # frameworks (embed: false) and add @executable_path / @loader_path to the runtime search path so + # the tool resolves them from the build-products dir (where they sit beside the executable). The + # frameworks' install names are @rpath/.framework/Versions/A/. + opendisplay: + type: tool + platform: macOS + sources: [Tools/opendisplay/Sources] + settings: + base: + LD_RUNPATH_SEARCH_PATHS: + - "@executable_path" + - "@loader_path" + # Bundled at OpenDisplay.app/Contents/Helpers, the embedded frameworks are in ../Frameworks. + - "@executable_path/../Frameworks" + dependencies: + - target: DisplayDomain + embed: false + - target: ProviderInterfaces + embed: false + - target: SceneEngine + embed: false + - target: AutomationSchema + embed: false + - target: TopologyCore + embed: false + - target: CoreGraphicsProvider + embed: false + - target: ExperimentalLifecycleProvider + embed: false + +schemes: + OpenDisplay: + build: + targets: { OpenDisplay: all } + run: + config: Debug + OpenDisplay-PublicAPIOnly: + build: + targets: { OpenDisplay-PublicAPIOnly: all } + run: + config: Debug + OpenDisplayRescue: + build: + targets: { OpenDisplayRescue: all } + run: + config: Debug + opendisplay: + build: + targets: { opendisplay: all } + run: + config: Debug diff --git a/scripts/bootstrap-swift.sh b/scripts/bootstrap-swift.sh new file mode 100755 index 0000000..4cf07ec --- /dev/null +++ b/scripts/bootstrap-swift.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Ensure a Swift 6 toolchain is available for local development. +# +# macOS : verifies Xcode 16+ / Swift 6 is present (install Xcode from the App Store). +# Linux : installs the Swift 6.0.3 toolchain + system dependencies (Ubuntu). +# +# Override the install location with SWIFT_INSTALL_DIR (default: /opt/swift). +set -euo pipefail + +SWIFT_VERSION="6.0.3" +SWIFT_INSTALL_DIR="${SWIFT_INSTALL_DIR:-/opt/swift}" + +have_swift6() { + command -v swift >/dev/null 2>&1 && swift --version 2>/dev/null | grep -qE "Swift version 6" +} + +case "$(uname -s)" in + Darwin) + if have_swift6; then + echo "✓ $(swift --version | head -1) (Xcode toolchain)"; exit 0 + fi + echo "Swift 6 not found. Install Xcode 16+ from the App Store, then run:" + echo " sudo xcode-select -s /Applications/Xcode.app && xcodebuild -runFirstLaunch" + exit 1 + ;; + Linux) + if have_swift6; then echo "✓ $(swift --version | head -1)"; exit 0; fi + . /etc/os-release 2>/dev/null || true + if [ "${ID:-}" != "ubuntu" ]; then + echo "Automated install supports Ubuntu. For other distros see https://www.swift.org/install/" + exit 1 + fi + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + UBU_DOTLESS="${VERSION_ID//./}" # e.g. 24.04 -> 2404 + URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/ubuntu${UBU_DOTLESS}/swift-${SWIFT_VERSION}-RELEASE/swift-${SWIFT_VERSION}-RELEASE-ubuntu${VERSION_ID}.tar.gz" + + echo "Installing Swift ${SWIFT_VERSION} for Ubuntu ${VERSION_ID} -> ${SWIFT_INSTALL_DIR}" + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + binutils git gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libgcc-13-dev \ + libncurses-dev libpython3-dev libsqlite3-0 libstdc++-13-dev libxml2-dev \ + libz3-dev pkg-config tzdata unzip zlib1g-dev + curl -fSL --retry 3 -o /tmp/swift.tar.gz "$URL" + $SUDO mkdir -p "$SWIFT_INSTALL_DIR" + $SUDO tar xzf /tmp/swift.tar.gz -C "$SWIFT_INSTALL_DIR" --strip-components=1 + rm -f /tmp/swift.tar.gz + echo + echo "✓ Installed. Add the toolchain to your PATH:" + echo " export PATH=${SWIFT_INSTALL_DIR}/usr/bin:\$PATH" + "${SWIFT_INSTALL_DIR}/usr/bin/swift" --version + ;; + *) + echo "Unsupported OS: $(uname -s)"; exit 1 ;; +esac diff --git a/scripts/bundle-helper.sh b/scripts/bundle-helper.sh new file mode 100755 index 0000000..3b36f6c --- /dev/null +++ b/scripts/bundle-helper.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Bundles the `opendisplay` CLI into OpenDisplay.app/Contents/Helpers so the experimental rotation +# backend can run it as an isolated helper process. The CLI is a Swift `tool` target that cannot be +# built inside the app's own Xcode build (its module outputs conflict with the app's), so we build it +# as a separate invocation and copy the product into the bundle. +# +# Usage: scripts/bundle-helper.sh [Debug|Release] +set -euo pipefail +CONFIG="${1:-Debug}" +cd "$(dirname "$0")/.." + +[ -d OpenDisplay.xcodeproj ] || make xcode + +echo "Building OpenDisplay.app ($CONFIG)…" +xcodebuild -project OpenDisplay.xcodeproj -scheme OpenDisplay -configuration "$CONFIG" -destination 'platform=macOS' build >/dev/null +echo "Building opendisplay CLI ($CONFIG)…" +xcodebuild -project OpenDisplay.xcodeproj -scheme opendisplay -configuration "$CONFIG" -destination 'platform=macOS' build >/dev/null + +DD=$(ls -dt "$HOME"/Library/Developer/Xcode/DerivedData/OpenDisplay-* | head -1) +PRODUCTS="$DD/Build/Products/$CONFIG" +APP="$PRODUCTS/OpenDisplay.app" +CLI="$PRODUCTS/opendisplay" + +[ -d "$APP" ] || { echo "error: $APP not found" >&2; exit 1; } +[ -x "$CLI" ] || { echo "error: opendisplay CLI not found at $CLI" >&2; exit 1; } + +mkdir -p "$APP/Contents/Helpers" +cp -f "$CLI" "$APP/Contents/Helpers/opendisplay" +echo "Bundled: $APP/Contents/Helpers/opendisplay" diff --git a/scripts/generate-xcodeproj.sh b/scripts/generate-xcodeproj.sh new file mode 100755 index 0000000..4f7054e --- /dev/null +++ b/scripts/generate-xcodeproj.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Generate OpenDisplay.xcodeproj from project.yml using XcodeGen (macOS). +# The generated project is not committed — run this whenever project.yml or the target +# source layout changes. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ "$(uname -s)" != "Darwin" ]; then + echo "The Xcode project is macOS-only. On Linux, use 'make test' for the cross-platform core." + exit 1 +fi + +if ! command -v xcodegen >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then + echo "Installing XcodeGen via Homebrew…" + brew install xcodegen + else + echo "XcodeGen not found and Homebrew is unavailable." + echo "Install it from https://github.com/yonaskolb/XcodeGen and re-run." + exit 1 + fi +fi + +xcodegen generate +echo +echo "✓ Generated OpenDisplay.xcodeproj" +echo " open OpenDisplay.xcodeproj # build & run the menu-bar app" +echo " xcodebuild -scheme OpenDisplay build" +echo " xcodebuild -scheme OpenDisplay-PublicAPIOnly build" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..daea31a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Build and test the platform-independent OpenDisplay packages. +# Works anywhere a Swift 6 toolchain is installed (macOS or Linux). +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v swift >/dev/null 2>&1; then + echo "error: no Swift toolchain found." + echo " - macOS: install Xcode 16+ (Swift 6)." + echo " - Linux: install from https://www.swift.org/install/ or use the swift:6.0 container." + exit 127 +fi + +swift --version +swift build +swift test --parallel