Scaffold OpenDisplay: monorepo + platform-independent safety core - #10
Conversation
Bootstrap the project from the PRD and design kit: - SPM monorepo per PRD §18.3 with cross-platform domain packages and macOS target stubs (apps, rescue, CLI, providers, design system). - DisplayDomain: models, multi-signal identity scoring, and the lifecycle + transaction state machines (PRD §9.3, §10.5, §13.1). - ProviderInterfaces: provider protocols and typed failure semantics (§9.9). - TopologyCore: non-bypassable SafetyEngine (safe-surface/preflight) and the serialized TopologyCoordinator with checkpoint → apply → verify → rollback (§9.4/§9.5); provider success is never product success (D-010). - SceneEngine: deterministic, idempotent, safely-ordered scene planner (§10.7). - AutomationSchema: stable JSON result envelope + selector grammar (§12). - SimulatorProvider + XCTest suites covering safety invariants, rollback, identity confidence, scene idempotency, and schema round-trips. - CI running cross-platform domain tests on Linux + macOS; SwiftLint. - Docs (architecture, recovery, decisions, PRD) and OSS governance (contributing, security, code of conduct, RFC/issue/PR templates). - Design kit preserved verbatim under the design-system reference folder as the source of truth for the SwiftUI port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
The placeholder `echo "TODO(M0): xcodebuild ..."` steps put a `: ` inside an unquoted YAML scalar, which GitHub rejected as an invalid workflow file — the run failed instantly with zero jobs. Remove the colon and make the macOS and SwiftLint jobs non-blocking (continue-on-error) until macOS CI is established on a Mac in M0; the Linux Swift 6 domain tests are the authoritative gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
- ScenePlanner: the local accumulator `operations` shadowed the helper method
of the same name, so `operations(for:...)` resolved to the array
("cannot call value of non-function type"). Rename the helper to
`plannedOperations(for:target:observed:)`.
- CI: the macos-14 image defaults to Xcode 15 (Swift 5.10), which can't read
the tools-version 6.0 manifest. Select the newest Xcode 16 before `swift test`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
All jobs now pass on the branch (Linux + macOS Swift 6 domain tests and SwiftLint are green), so drop the interim continue-on-error and let them gate the PR as intended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 983d1aba66
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| checkpoints: CheckpointStoring, | ||
| safety: SafetyEngine = SafetyEngine(), | ||
| recoveryServiceHealthy: @escaping @Sendable () async -> Bool = { true }, | ||
| confirm: @escaping ConfirmationHandler = { _, _ in true } |
There was a problem hiding this comment.
Require explicit confirmation handler by default
When a caller constructs TopologyCoordinator without passing confirm, every .needsConfirmation preflight is silently accepted, including low identity confidence, first-use routes, and disconnecting the current main display. That removes the explicit countdown/user-confirmation step before the provider mutation; make the default cancel/throw, or require production callers to supply a handler.
Useful? React with 👍 / 👎.
| recoveryServiceHealthy: healthy, | ||
| isFirstUseForRoute: options.isFirstUseForRoute | ||
| ) | ||
| if case .blocked(let reasons) = decision, !options.userOverride { |
There was a problem hiding this comment.
Keep blocked preflights non-bypassable
With userOverride: true, .blocked decisions such as .recoveryServiceUnhealthy or .wouldRemoveLastSafeDisplay continue past preflight into checkpointing and the provider call. Even if verification later rolls back the last-display case, the destructive mutation has already been attempted while the safety engine said there was no safe recovery path, so advanced overrides need their own verified safe surface instead of bypassing all blocked reasons here.
Useful? React with 👍 / 👎.
| let targetInactive = after.observation(for: target)?.isActive != true | ||
| let safeSurfaceRemains = safety.safeSurface(in: after, excluding: [target]) != nil | ||
| guard targetInactive && safeSurfaceRemains else { |
There was a problem hiding this comment.
Verify providers did not drop extra displays
The postcondition only checks that the target became inactive and that some safe surface remains. If a buggy/provider or OS path disconnects the target plus an unrelated active display while leaving a third safe display, this guard commits instead of rolling back, so the coordinator reports success after losing an unexpected endpoint; compare the pre/post active set for non-target displays before committing.
Useful? React with 👍 / 👎.
Addresses three P1 review findings on TopologyCoordinator, all verified by the full test suite running locally on a Swift 6.0.3 toolchain: - Non-bypassable blocked preflights: removed the `userOverride` flag that let `.blocked` decisions (unhealthy recovery service, removing the last safe display) proceed to the provider call. An advanced "disconnect all" override requires an independently verified remote recovery surface (future RFC), not a boolean (§9.2 invariants 3/9). - Fail-safe confirmation: the coordinator's default `confirm` handler now cancels rather than silently approving `.needsConfirmation` (low identity confidence, first-use route, disconnecting the current main) (LIF-006). - Verify no unexpected endpoint lost: verification now rolls back if any previously-active non-target display went inactive, not just when the target fails to disconnect (§9.4). Added a SimulatedFaults.alsoDisconnect fault and a regression test, plus a default-confirm-cancels test. Local-first tooling: add Makefile (bootstrap/build/test/lint) and scripts/bootstrap-swift.sh; document `make test` as the primary local path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
|
Thanks for the review — all three P1 findings on
Added regression coverage: Generated by Claude Code |
…gets
Verification has moved local (Swift 6.0.3 toolchain; `make test` → 42/42), so:
- Remove remote CI: delete .github/workflows/ci.yml; reword CI expectations to
local `make test` in CONTRIBUTING, the PR template, the architecture overview,
and the changelog.
- Scaffold a turnkey macOS build via XcodeGen (`project.yml`, generated project
not committed):
- Targets: OpenDisplay menu-bar app + OpenDisplay-PublicAPIOnly variant
(experimental/virtual providers excluded, NFR-010/D-008), OpenDisplayRescue,
the `opendisplay` CLI, the design-system framework, and six provider frameworks.
- Compile-ready sources: a composition root wiring the existing TopologyCoordinator
to SimulatedDisplaySystem so the menu-bar app runs immediately; rescue + CLI
stubs; one ProviderInterfaces-conforming stub per provider (probe → unsupported);
minimal design-system tokens.
- Info.plist + entitlements stubs (LSUIElement menu-bar app; app-sandbox off,
entitlement set pending Q-002).
- `scripts/generate-xcodeproj.sh`, `make xcode`, README "Building on macOS",
gitignore the generated project.
- Add a public InMemoryCheckpointStore to TopologyCore (used by the app/rescue
compositions); core still builds/tests on Linux.
Package.swift is untouched; new macOS sources live outside SPM target paths so the
cross-platform `make test` stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
Capture the macOS pickup procedure for continuing in a Claude Code session on a Mac: get the branch, `make bootstrap`/`make test` (42/42), `make xcode`, build & run the app + public-API-only flavor + CLI, and the ordered M0 first tasks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
Self-contained handover for continuing on a Mac: environment reality (local vs remote), how to get the code, build/run steps, current status, repo layout, architecture, decisions/open questions, GitHub/PR state, M0 first tasks, gotchas, and a ready-to-paste kickoff prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NkahX9AdXPA3rVHFfaiNk
…gets
The six provider frameworks and OpenDisplayDesignSystem were declared as
`type: framework` with no INFOPLIST_FILE and no GENERATE_INFOPLIST_FILE, so
Xcode built each framework bundle without an Info.plist and codesign rejected
it ("bundle format unrecognized, invalid, or unsuitable"). This was the
first-build failure the handover predicted for the never-on-a-Mac scaffold.
Add `GENERATE_INFOPLIST_FILE: YES` via a shared `&frameworkSettings` anchor on
every framework target; the app/CLI targets keep their explicit INFOPLIST_FILE.
After this fix (and a clean DerivedData), all four macOS schemes — OpenDisplay,
OpenDisplay-PublicAPIOnly, OpenDisplayRescue, opendisplay — build and codesign
clean, and the app passes `codesign --verify --deep --strict`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the CoreGraphicsProvider stub with a real TopologyObserving actor: - Live enumeration via CGGetOnlineDisplayList, normalized into DisplayObservations (CG display ID, UUID, bounds/origin, mode, rotation, main/active/builtin flags, mirror-source resolution). - Stable record IDs keyed on the persistent CG display UUID (cg:<uuid>), falling back to the transient CG display ID. - A CGDisplayRegisterReconfigurationCallback event source that advances the TopologyGeneration whenever the topology signature changes; awaitStableGeneration polls with a 2s timeout so the coordinator never blocks if the OS emits no event. - probe reports .supported and uses only documented APIs, so it stays in the public-API-only build. (CGDisplayCreateUUIDFromDisplayID lives in ColorSync.) Wire it into AppModel as the observation source in place of SimulatedDisplaySystem. Logical disconnect/reconnect is not a Core Graphics capability, so the coordinator is backed by an honest UnavailableLifecycleProvider placeholder (reports .unsupported) until the ExperimentalLifecycleProvider lands. Add an OPENDISPLAY_DUMP-gated stderr topology dump for headless verification. Verified: OpenDisplay and OpenDisplay-PublicAPIOnly build, make test 42/42, and the direct-run dump enumerates the real built-in Retina panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fallback Implement the "private primary + public fallback" lifecycle design: - ExperimentalLifecycleProvider: real logical disconnect/reconnect via the private SkyLight SLSConfigureDisplayEnabled (legacy alias CGSConfigureDisplayEnabled), resolved with dlsym at runtime so it links without a private-framework dependency and degrades to .unsupported when the symbols are absent. Maps the app record ID back to a CGDirectDisplayID via the persistent CG UUID. isExperimental = true; the target stays out of the public-API-only build. - CoreGraphicsProvider now also conforms to LifecycleProvider using the public, reversible CGConfigureDisplayMirrorOfDisplay (mirror the target into the main display as a stand-in for disconnect; un-mirror to reconnect), applied .forSession so a mistake self-heals at logout. Adds a public recordID -> CGDirectDisplayID resolver. - AppModel selects a RoutedLifecycleProvider (experimental primary, public fallback on .unsupported) in the full build, and the public provider alone in the public-API-only build. No disconnect path is wired to the UI yet (Reconnect All only touches managed-offline displays, currently none), so nothing mutates real hardware. Verified: both flavors build, make test 42/42, and all four SkyLight symbols resolve via dlsym on this macOS, so the real disconnect path is available. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-to-end Add DiskCheckpointStore (TopologyCore, pure Foundation so it is covered by make test): each checkpoint is written atomically as self-contained JSON under <dir>/checkpoints/<id>.json and mirrored to <dir>/latest.json, the single well-known file the independent rescue process reads first. defaultDirectory() points both the app and the rescue utility at the same Application Support folder. Six unit tests cover round-trip, restore-by-id, latest-reflects-newest, empty/unknown -> nil, and independent readability of latest.json. Wire it into AppModel in place of InMemoryCheckpointStore and write a last-known-safe baseline checkpoint of the current arrangement at startup, so the rescue utility has something to restore even before any disconnect. Rebuild OpenDisplayRescue around it: read the latest checkpoint from the shared location, render the recorded displays + capture time, and Reconnect All via the public CoreGraphicsProvider.recover (un-mirror) — public APIs only. Rescue now depends on CoreGraphicsProvider instead of SimulatorProvider. Verified: all four schemes build, make test 48/48, and launching the app writes a clean rescue-readable latest.json capturing the real display topology. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r routing Ran the full disconnect transaction on real hardware (Apple Silicon, an extended external display). Result: committed/verified through idle → resolving → preflight → checkpointed → applying → observing → verifying → committed, then reconnected cleanly — the built-in safe surface never flinched. Two bugs found and fixed getting there: 1. ExperimentalLifecycleProvider called SLSConfigureDisplayEnabled(cid, display, enabled) — the WRONG ABI. It segfaults in checkCapacity(CGSConfigData*): the real entry point takes a CGS display-configuration transaction object, not a bare display ID. Disabled the call (now throws .unsupported, documented) so the router falls back to the public provider; the correct CGS transaction is a follow-up. 2. RoutedLifecycleProvider routed by catching ProviderFailure.unsupported, but the cast failed at runtime. The SPM library products are statically linked into each dynamic framework AND the app, so ProviderFailure exists as distinct type metadata per image — `catch as` / `as?` across the boundary never matches. Switched to probe-status routing (a value comparison, boundary-safe). The deeper fix (making the SPM products dynamic so there is one copy of each type) is a separate change; it also affects the coordinator's error-typing precision. Add a DEBUG-only, OPENDISPLAY_DISCONNECT-gated harness in AppModel that runs one disconnect through the real coordinator and always reconnects after 3s, so a live test can never strand a display. Verified: all four schemes build, make test 48/48, live disconnect committed + restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shared core modules were statically linked into each provider framework AND the app, so types like ProviderInterfaces.ProviderFailure existed as distinct runtime types per Mach-O image — `catch as`/`as?` across the framework boundary compiled but silently failed (TopologyCoordinator's `catch let failure as ProviderFailure` fell through to `.unknown`). SwiftPM `.dynamic` products can't fix this under Xcode 26.3: a package target that is also an internal package dependency (the DisplayDomain diamond) can't be built dynamically when a same-named product exists, and renaming the product yields empty/duplicate framework wrappers with some targets silently static. `swift build` handles `.dynamic` fine — only Xcode's SPM-product integration is broken. Fix: compile the 6 core modules (DisplayDomain, ProviderInterfaces, SceneEngine, AutomationSchema, TopologyCore, SimulatorProvider) as native Xcode dynamic framework targets in project.yml (same Packages/*/Sources dirs, depended on via target:). One embedded dynamic framework per module => one runtime type each, so cross-boundary casts work. Apps embed the framework closure; the opendisplay CLI links them embed:false + LD_RUNPATH_SEARCH_PATHS=@executable_path,@loader_path. Package.swift products reverted to default (static) — Xcode no longer consumes them; `make test` uses the targets directly. RoutedLifecycleProvider kept on probe-based routing; comment corrected since cross-boundary `catch as ProviderFailure` is now sound. Verified: make test 48/48; all four schemes build + codesign; CLI runs; a live external-display disconnect committed/verified end-to-end with TopologyCore and the provider frameworks all @rpath-resolving the single ProviderInterfaces framework. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ansaction) The headline feature now works for real. ExperimentalLifecycleProvider performs a true logical disconnect via the fully-private SkyLight display-configuration transaction, with the signatures recovered by disassembling SkyLight (macOS 26 / Apple Silicon) — NOT the shapes guessed earlier: SLSBeginDisplayConfiguration(&config) // 1 arg, out-param; NO connection ID SLSConfigureDisplayEnabled(config, displayID, enabled) // 3 args, config FIRST SLSCompleteDisplayConfigurationWithOption(config, 0) // forAppOnly Earlier crashes were a wrong ABI: a bare (cid, displayID, enabled) call — and even (cid, publicCGDisplayConfigRef, displayID, enabled) — segfaults in checkCapacity(CGSConfigData*). The config object must come from SLSBeginDisplayConfiguration (it carries a 0xbeefcafe capacity header the private calls validate); the public CGDisplayConfigRef is a different object. All symbols are dlsym-resolved (SLS* then CGS* alias) and the provider degrades to .unsupported if any is absent, so the public mirror fallback still works. Committed with the forAppOnly option: the disable reverts automatically if OpenDisplay exits — a strong safety net on top of the coordinator's checkpoint/rollback, and it matches the reconnect-on-quit default (D-005). Verified LIVE on hardware (Samsung S34J55x, extended over HDMI): the coordinator transaction reached committed/verified (resolving → preflight → checkpointed → applying → observing → verifying → committed), and a post-disconnect topology dump proved the display dropped out of CGGetOnlineDisplayList entirely (online count 1, built-in only, mirror=none) — a true logical disconnect, not mirroring — then reconnected cleanly. The DEBUG harness logs that post-disconnect topology as proof of mechanism. Verified: make test 48/48, all four schemes build + codesign valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… recover gap) OpenDisplayRescue.recover now runs BOTH recovery paths best-effort from latest.json: the SkyLight re-enable transaction (undoes a private logical disconnect) and the public Core Graphics un-mirror (undoes the mirroring fallback). Each is a no-op when not applicable. Rescue gains an ExperimentalLifecycleProvider dependency (project.yml). ExperimentalLifecycleProvider.recover now re-enables by raw CG display ID (cgid:<n>) rather than UUID: a logically-disabled display can drop off the online list so its persistent UUID may not resolve, but the numeric ID is valid while connected. Add OPENDISPLAY_HOLD_SECONDS (DEBUG) to the main-app disconnect harness so a disconnect can be held while another process re-enables it, plus an OPENDISPLAY_RESCUE_RUN (DEBUG) auto-run in rescue that loads the checkpoint, runs recover, and dumps the before/after topology. Verified LIVE, cross-process: the main app privately disconnected the external and held it offline (online count 1, built-in only); the rescue process then re-enabled it from latest.json (online 1 -> 2, both active, no mirror), restoring the recorded arrangement. make test 48/48, all four schemes build + codesign valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MenuBarView showed raw cg:<uuid> record IDs. AppModel.displayName(for:) now resolves the OS localized name by matching the observation's cgDisplayID to NSScreen (e.g. "Built-in Retina Display", "S34J55x"), falling back to a class+resolution label and finally the record ID for displays with no live NSScreen (offline/managed). The OPENDISPLAY_DUMP diagnostic also prints the resolved names. Verified: dump shows cgID=1 -> "Built-in Retina Display", cgID=3 -> "S34J55x"; all four schemes build + codesign, make test 48/48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GlobalHotKey, a Carbon RegisterEventHotKey wrapper (no Accessibility permission
required, unlike an event tap) that binds Ctrl-Opt-Cmd-R system-wide to coordinator
reconnectAll. This is recovery-hierarchy step 3 (PRD §9.11 / LIF-009): an
always-available Reconnect All that works even when the menu bar is unreachable.
AppModel registers it at startup and falls back to the menu-bar item if the chord
can't be claimed. Carbon C handles are nonisolated(unsafe) so the nonisolated deinit
can unregister them.
Verified: registration succeeds at launch ("hotkey registered"); all four schemes
build + codesign valid; make test 48/48. (Triggering the chord is a manual keypress
check — macOS blocks synthetic keystrokes without Accessibility permission.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ices testing The global Reconnect-All hotkey is verified end-to-end: pressing Ctrl-Opt-Cmd-R fires the handler and runs reconnectAll. The trigger only works when the app is launched via LaunchServices (`open`) — a binary run directly from the shell is not routed global hotkeys by the window server (the DEBUG disconnect harness runs the binary directly, so its hotkey won't fire; the real app is unaffected). Switch the DEBUG hotkey log to debugMarkHotKeyFired(), which writes both to stderr and to /tmp/opendisplay_hotkey_fired.log so an activation can be confirmed even under LaunchServices (which doesn't inherit stderr). Verified: all four schemes build + codesign, make test 48/48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… JSON Replace the simulated CLI stub with a real automation surface (PRD §12) running through the same core the UI uses: - list: live Core Graphics enumeration (human + --json). - diagnose: provider probes (coregraphics, experimentalLifecycle) with status/risk/reasons. - disconnect <selector> [--dry-run]: resolves a DisplaySelector and either previews the SafetyEngine preflight decision (--dry-run, no hardware touched) or runs the full coordinator transaction; emits a stable ResultEnvelope with --json. - reconnect <selector>, recover: restore paths. Selector resolution supports id:/main/builtin/state:/<cgID> against live observations (alias/tag/name/fingerprint need the persisted registry, not yet wired — clean error). Lifecycle provider chosen by probe (experimental primary, public fallback). CLI links the core + provider frameworks (embed:false + @rpath, tool can't embed); drops SimulatorProvider. Verified safely (no live disconnect): list/diagnose/recover/--json all correct; dry-run reports ALLOWED for the external and NEEDS CONFIRMATION (targetIsCurrentMain) for the built-in; ambiguous selectors error cleanly. All four schemes build, make test 48/48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lope Add CommandGateway (TopologyCore), the unified entry point every external surface (UI, CLI, App Intents, HTTP) routes through (PRD §10). It owns one TopologyCoordinator so all commands share the serialized, safety-checked, audited path, and centralizes the LifecycleResult -> ResultEnvelope mapping that was otherwise duplicated per surface. API: reconnectAll(actor) -> ResultEnvelope, disconnect(target, options) -> ResultEnvelope, preflightDisconnect(...) -> PreflightOutcome (allowed/needsConfirmation/blocked, no mutation). Platform-independent — callers inject the concrete observer/lifecycle providers — so it is exercised under make test with SimulatedDisplaySystem. TopologyCore now depends on AutomationSchema (Package.swift + project.yml framework target; acyclic — AutomationSchema only depends on DisplayDomain). Verified: 6 new tests cover reconnectAll (committed/noOp), disconnect (committed with safe surface, blocked when removing the last safe display), preflight (allowed/blocked), and the full LifecycleResult->status mapping. make test 54/54; all four schemes build + codesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…CommandGateway Add ReconnectAllIntent + OpenDisplayShortcuts (AppShortcutsProvider) exposing the always-available recovery action to Shortcuts and Siri (recovery hierarchy step 3). The intent routes through CommandGateway — the same audited, safety-checked path the menu bar and CLI use — and reports how many displays were reconnected. Each invocation builds a fresh gateway (probe-selected lifecycle provider, shared on-disk checkpoints), mirroring the CLI's independent composition; excluded experimental provider falls back to the public path in the public-API-only build. First production consumer of CommandGateway. Verified: both app flavors build; the App Intents compiler extracted ReconnectAllIntent into Metadata.appintents (discoverable). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flesh out the placeholder Settings window: - Displays tab: live topology with friendly names (NSScreen.localizedName), resolution/ refresh, a Main badge, and active/managed-offline status. - Diagnostics & Recovery tab: provider probe rows (Core Graphics observation + the lifecycle provider) showing status/risk/reasons and a Labs badge for experimental providers, plus the global recovery hotkey, the rescue-readable checkpoint location, and a Reconnect All button. AppModel gains refreshDiagnostics() (probes observer + lifecycle into a published [DisplayDiagnostic]), checkpointLocation, and reconnectAllHotkey. The diagnostics tab populates via .task on appear. The probe logic itself is already verified through the CLI diagnose command. Verified: both app flavors build + codesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-platform core (envelope round-trip, selectors, scene planning, safety engine) already has comprehensive coverage; the genuine gap was the gateway's .partial reconnect path. Add a test where one managed-offline display reconnects and a ghost record (no matching observation) fails, asserting status == .partial and the per-target verification states. make test 55/55. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g / degraded Port a subset of the designed menu-bar states into MenuBarView, driven by model state: - scanning: spinner + "Scanning displays…" before the first enumeration completes. - ready: the live display list (friendly names, main badge, active/managed-offline). - empty: "No displays detected" fallback. - reconnecting: header spinner + "Reconnecting…" and a busy Reconnect All label. - degraded: a caution banner when any provider probe isn't supported. AppModel gains a DisplayLoadPhase (scanning → ready/empty, set in refresh) and isDegraded (derived from diagnostics, which refresh() now probes each cycle). Verified: all four schemes build + codesign, make test 55/55, and the app starts cleanly with the new refresh path (enumerates + probes diagnostics without error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add OpenDisplaySettings + SettingsStore (TopologyCore, pure Foundation): atomic JSON in Application Support holding the default persistence policy, confirmation countdown, and whether the global hotkey is enabled. A tolerant decoder defaults missing keys and ignores unknown ones, so settings files survive schema changes in either direction; load() returns .default when the file is absent or corrupt. Wire it into the app: AppModel loads settings at startup, only registers the global Reconnect-All hotkey when reconnectAllHotkeyEnabled, and Settings → Diagnostics & Recovery shows the persistence policy and hotkey state. Verified: 5 new tests (defaults-when-absent, round-trip, corrupt->default, unknown/missing keys tolerated, independently readable) — make test 60/60; all four schemes build + codesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AuditEntry + AuditLogging with InMemoryAuditLog and DiskAuditLog (TopologyCore, pure Foundation): append-only JSONL in Application Support — one JSON object per line, ISO-8601 timestamps, rescue-readable. A torn final line from a crash is skipped on read without breaking the rest of the history. CommandGateway takes an optional auditLog and records every command (actor, command, transaction id, status, targets) — so the audit trail is automatic for every surface that routes through the gateway. The App Intents path now passes a DiskAuditLog, so Shortcuts/ Siri Reconnect-All actions are recorded. Verified: 6 new tests (append/recent order, limit, empty, torn-line tolerance, one-object- per-line, and gateway-records-audit) — make test 66/66; all four schemes build + codesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ore) Add DisplayRegistry (TopologyCore actor) + RegistryState/RegistryStoring with InMemory and DiskRegistryStore (atomic JSON registry.json). It resolves a live display's fingerprint to a stable DisplayRecord — recognizing or minting — and owns user alias/tag/pairing edits (PRD §10.5, REG-003/004/005). Resolution order: exact EDID serial, then same-Mac CG-UUID fast path, then best IdentityScorer fingerprint match above a 0.5 recognition threshold, else mint a new record. Two identical serial-less monitors therefore stay distinct until paired rather than being silently merged. Fingerprints merge (gap-filling) on each sighting; alias/tags persist. Foundation for alias:/tag: selectors, friendlier names, and scenes. Fully cross-platform, so it is covered by make test (6 new tests: mint, serial-match, cg-uuid recognition, distinct-without-serial, alias/tag persistence across resolve, and persistence across instances). make test 72/72; all four schemes build + codesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…selectors CoreGraphicsProvider gains fingerprint(for:) built from public EDID accessors (CGDisplayVendor/Model/SerialNumber + CGDisplayScreenSize). The CLI now holds a persisted DisplayRegistry: every command resolves live displays' fingerprints into it (recognizing or minting stable records), so the registry learns the displays and maps observations<->records. New commands: `alias <selector> <name>` and `tag <selector> <tag>`. `list` shows the alias and tags; `alias:<name>` and `tag:<tag>` selectors resolve (in addition to id:/main/builtin/state:/ <cgID>). Mutations still route the actual disconnect through the gateway on the observation's id. Verified live (safe, no live disconnect): aliasing the external as "Desk" + tagging it #studio persists to registry.json; list shows them; `disconnect alias:Desk --dry-run` and `disconnect tag:studio --dry-run` resolve to the external and report ALLOWED. All four schemes build; make test 72/72. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ame in Settings AppModel builds a persisted DisplayRegistry at startup and resolves each live display's fingerprint into it on every refresh (records keyed by the observation's id). displayName() now prefers the user alias, so the menu bar shows it automatically. Settings → Displays gets an editable name field per display (DisplayRow) that commits the alias to the registry via AppModel.setAlias and re-resolves. The app and CLI share one registry.json, so an alias set in either surface shows in the other. Verified: all four schemes build + codesign, make test 72/72, and the app resolves the "Desk" alias that was set earlier via the CLI (dump: cgID=3 → "Desk") — confirming cross-surface persistence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cOS 14+) The menu-bar "Display Settings…" used the showSettingsWindow: selector, which Apple broke for SwiftUI apps on macOS 14+, and with "Displays have separate Spaces" the window opened on the main display's Space — so clicking the menu bar on an extended display appeared to do nothing. Switch to the @Environment(\.openSettings) action and, just after, move the Settings window to the active Space, activate the app, and center it on the screen under the cursor — so it appears on whichever display you opened it from. Bump the app deployment target to macOS 14 (the SPM core stays macOS 13 / cross-platform for `make test`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add SceneRecorder (SceneEngine): capture() snapshots the live arrangement into a Scene (one member per display, selected by stable record id, asserting connected/main/position/ mode/rotation; members optional so a later apply skips absent displays), and resolution() maps id:/main/builtin selectors back to records for the existing ScenePlanner. Add SceneStoring + InMemorySceneStore + DiskSceneStore (atomic scenes.json) and a SceneLibrary actor (upsert-by-id CRUD over a store). This is the safe, cross-platform foundation for scenes — apply (which mutates the layout) comes next and will be exercised with the user present. Verified: 6 new tests — capture→plan is idempotent (no work, all already-satisfied), a moved display yields a willApply setPosition, an absent display is missing-optional (not blocking), and library save/lookup/delete/upsert/persistence. make test 78/78; all four schemes build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(dry-run) Add `opendisplay scene <sub>`: save captures the live arrangement into a named scene (upsert by name) via SceneRecorder + SceneLibrary (scenes.json); list/show inspect saved scenes; plan resolves each member through the registry-aware resolver and runs ScenePlanner to print the dry-run diff (willApply / already-satisfied / skipped-absent), with --json on all of them. No mutation — apply (which rearranges displays) is a separate step to run with the user present. Verified live (safe): `scene save "Desk Setup"` captured 2 displays; show lists their connected/main/position/mode; plan against the unchanged topology reports "already satisfied" (9 ops, all already-satisfied) — confirming the capture→resolve→plan path. CLI builds; make test 78/78. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CoreGraphicsProvider.applyArrangement runs display positions + modes atomically in one Core Graphics configuration transaction (.permanently), skipping anything already satisfied (no flicker on a no-op) and cancelling an empty transaction. Restoring origins also restores the main display (main = the display at (0,0)). Reversible — apply another scene to undo. CLI gains `scene apply <name>`: resolves the scene's members, builds position/mode targets, applies them, and reports. Rotation (needs a private API), brightness/color (no control provider yet), and disconnect are not applied by arrangement and are reported as skipped. Also restore `origin` to `list --json`. Verified LIVE on hardware: saved the current layout as "Live", dragged the S34J55x to a new position in System Settings, then `scene apply "Live"` snapped it back to its captured origin (-911,-1440) — confirmed by the topology dump. No-op apply makes no change. All four schemes build, make test 78/78. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y fixes Menu-bar popover redesigned (Phase 1) into BetterDisplay-style per-display cards — icon, name, main badge, on/off toggle, inline brightness (disabled until the controls provider) and a working resolution slider — plus an expandable per-display action list, a Tools section, and a bottom toolbar. New reusable views (DisplayCard, OfflineDisplayCard, MenuActionRow); new AppModel controls (setMode, setMain, availableModes) and CoreGraphicsProvider.availableModes. "One display is always active" invariant + managed-offline UI: - Any display can now be turned off, the built-in included, as long as another stays active (the toggle is disabled only for the last active display; the SafetyEngine stays the non-bypassable backstop). AppModel supplies a real confirm handler so the menu's explicit intent is honored. - A turned-off display stays in the menu as a dimmed "off" card (the OS drops it from the online list, so AppModel tracks it) and can be switched back on. - Watchdog: AppModel subscribes to a new CoreGraphicsProvider.changes() stream and, if a topology change ever leaves zero active displays (e.g. the last external is unplugged while the built-in is off), re-enables the built-in so the user is never black-screened. forAppOnly disable still auto-reverts on app quit as a backstop. Fixes from live testing: - Menu no longer blanks out while another display-manager app (BetterDisplay) holds a reconfiguration: ignore the begin-configuration callback and re-poll a transiently-empty enumeration before trusting it. - Resolution slider now offers the scaled HiDPI "looks like" modes (the built-in's real Retina resolutions) via kCGDisplayShowDuplicateLowResolutionModes, matched by point size + HiDPI + refresh; previously only non-HiDPI pixel modes were listed (so the crisp default wasn't pickable). Also: Settings -> Scenes gains a drag-to-arrange display canvas below the save-scene field. All four schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New DisplayServicesBrightnessProvider (experimental module, dlsym'd private DisplayServices — DisplayServicesGet/SetBrightness) gives real hardware brightness for the built-in panel and any external the framework recognizes; excluded from the public-API-only build like the SkyLight path, degrades gracefully when absent. AppModel.brightness/setBrightness expose it (#if !PUBLIC_API_ONLY, nil/no-op otherwise). The menu's brightness slider is now live where supported — shows the percentage and drives the backlight as you drag — and stays a disabled "Soon" control where it isn't (e.g. an external that needs DDC). Verified the read path on hardware: built-in returns 0.43, the S34J55x returns unsupported (needs DDC next). All four schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New ExternalDisplayDDC actor (experimental module) drives an external monitor's brightness, contrast, volume and input over its private IOAVService I2C channel on Apple Silicon — IOAVServiceCreate/ Write/ReadI2C dlsym'd, DDC/CI + MCCS framing (VCP 0x10 brightness etc.), serialized off the main actor with non-blocking inter-message delays. Excluded from the public-API-only build like the other private-SPI paths. Verified on hardware (S34J55x): read brightness 13/100, contrast 38/100, input readable; write set brightness to 40 and read back 40, then restored. AppModel now caches brightness (0...1) per display and routes set/refresh through DisplayServices for the built-in and DDC for externals; DDC writes are coalesced so a fast slider drag sends only the latest value rather than flooding I2C. The menu brightness slider binds to that cache, so the external (S34J55x) slider is now live alongside the built-in. Single-external mapping is exact; multi-external EDID matching is a later refinement. All four schemes build (public-API-only without DDC); make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Hardware control" row on an external display now expands inline to contrast and volume sliders driven over DDC/CI, reusing the ExternalDisplayDDC actor. A public-safe HardwareControl enum maps to VCP codes (0x12 contrast, 0x62 volume) so the menu references it in every build; AppModel caches the levels (0...1) per display+feature, refreshes them lazily when the section opens, and coalesces the I2C writes like brightness. Features the panel reports as unsupported (DDC result code != 0) are skipped, so only real controls appear. ExternalDisplayDDC.read now checks that result-code byte; Feature is Sendable so it can cross into the actor. Built-in shows "Soon" (no DDC). All four schemes build (public-API-only excludes DDC); make test green. DDC read/write itself was verified on the S34J55x last increment; live re-test pending (both displays are asleep while AFK). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror display: a non-main display's action list gains a "Mirror to main display" switch that mirrors it onto the main display (both show the same content) or stops mirroring — reversible, public Core Graphics only (CoreGraphicsProvider.setMirroring wrapping the existing mirror transaction). The toggle reflects the live mirror state (observation.mirrorSourceID). Display info: an expandable "Display info" row shows EDID-derived metadata — name, type, vendor/model/ serial, native resolution, refresh, and physical size — read-only via the public CGDisplay* accessors. All four schemes build; make test green. (Live re-test pending — displays asleep while AFK.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…isplay The "Image adjustments" row expands to a Dimming slider that scales a display's gamma transfer ramp (CoreGraphicsProvider.setGammaDim, public Core Graphics) — so brightness control works on any display, including DDC-less externals and below the hardware minimum. Floored at 15% so the screen can never go fully black. AppModel caches the per-display level and restores every display's gamma on app quit (NSApplication.willTerminateNotification → CGDisplayRestoreColorSyncSettings), so a dim never outlives the app. Verified the gamma set/restore API on hardware (rc=0, left restored). All four schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Display mode" row expands to a refresh-rate menu and a Retina/HiDPI toggle for the current resolution. CoreGraphicsProvider.allModes exposes the full un-deduped mode list; AppModel derives the refresh rates available at the current point-size + HiDPI (refreshRates), switches refresh keeping the resolution (setRefresh), and toggles HiDPI to the best matching mode (setHiDPI / hiDPIToggleAvailable). Controls that don't apply hide themselves (single refresh, or no non-HiDPI variant). Verified the logic on hardware: built-in offers 120/60/50/48 Hz at 1512x982 and correctly reports no HiDPI toggle there (no non-Retina variant). All schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
opendisplay gains two scriptable control commands that route through the same providers the menu uses: opendisplay brightness <selector> [0..1] — get/set brightness (built-in DisplayServices, external DDC) opendisplay ddc <selector> <brightness|contrast|volume|input> [value] — raw DDC/CI get/set Besides being a useful automation surface, these exercise the compiled DisplayServicesBrightnessProvider and ExternalDisplayDDC from a separate binary — verified live: `brightness builtin` returns the real built-in level (30%), `ddc <ext> brightness` cleanly reports unsupported when the panel is asleep. All schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds SetBrightnessIntent — a parameterized (0–100%) Shortcuts/Siri action that sets the built-in display's brightness via the same DisplayServices path the menu uses, and registers it in OpenDisplayShortcuts alongside Reconnect All. Excluded from the public-API-only build. Completes the automation surface (menu + CLI + App Intents) for brightness. Both app flavors build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Updates the status from "scaffolding" to the current reality — a functional, hardware-verified menu-bar app with brightness (built-in + DDC), hardware controls, mirroring, display modes, software dimming, scenes, and safe logical disconnect with the always-one-active guarantee — plus the rescue utility, CLI, and Shortcuts intents on the same audited path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
External displays gain an "Input source" row that reads the live DDC input (VCP 0x60) and offers a picker of the standard inputs (HDMI/DisplayPort/USB-C/DVI/VGA) to switch to, reusing the verified DDC write path. The live code is shown alongside, so a panel with non-standard codes (e.g. the S34J55x) is still legible. Switching is reversible. Built-in shows nothing (no DDC). The DDC write mechanism is already hardware-verified; the actual input switch is intentionally NOT auto-tested (it would blank the panel) — verify with the display awake. Both app flavors build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
External displays' "Colour mode" row now reads the DDC colour preset (VCP 0x14) and offers a picker of presets 1...max (sRGB / colour temperature / native, labelled where standard), reusing the DDC actor. ExternalDisplayDDC.Feature gains .colorPreset; AppModel caches the current + max code; the CLI `ddc` command accepts `colour`. Built-in stays "Soon" (no DDC; CoreDisplay exposes no clean colour API). Verified live on the S34J55x (awake, user present): reads 2/5, write set preset 5 then restored 2 (visible colour-temperature shift), and the compiled CLI reads "colour: 2/5". Builds + make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rred) Per the safety review, rotation *writes* are deferred from stable v1 (no Apple-supported setter exists; guessing the private ABI risks crashing WindowServer). Introduces a RotationBackend abstraction (RotationCapability .readOnly/.experimental/.unavailable + protocol) so the UI/scene model stay ready, with ReadOnlyRotationBackend as the default everywhere: reads orientation via public CGDisplayRotation, refuses writes. Menu "Screen rotation" now shows the live orientation, the label "Rotation changes are not safely supported on this macOS version", and an "Open Display Settings…" fallback. Scene apply skips rotation (still applies everything else) and surfaces a non-fatal note in the Scenes tab. The experimental SkyLight rotation backend (opt-in, helper-isolated, never in an App Store build) lands next. All schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New ColorProfileService (public ColorSync, App-Store-safe): lists installed RGB display profiles,
reads a display's current profile, assigns one (validated with ColorSyncProfileCreateWithURL +
ColorSyncProfileVerify), and resets to factory ({DeviceDefaultProfileID: kCFNull}). Displays are
targeted by their persistent ColorSync device UUID (= CG display UUID), never by index. A separate
"Colour profile" menu row (distinct from Colour mode) shows the current profile name + a picker with
a Factory Default option; displays without a ColorSync device show "Unavailable". Gated by a
FeatureFlags.iccProfileWrite flag (on — public API).
Verified on hardware (external, even while asleep — ColorSync is preference-based): set returns true
and applies, the change persists across separate processes, and factory reset reverts cleanly. Both
app flavors build. Open: built-in panel's ColorSync device doesn't resolve via the CG UUID (shows
Unavailable for now) — to be solved when the built-in is online (it went clamshell/offline mid-test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elper-isolated) Per the safety spec, adds an opt-in rotation writer that is OFF by default and compiled out of the public-API-only / App Store build: - SkyLightDisplayRotator (experimental module): the ONLY corroborated ABI SLSSetDisplayRotation(CGDirectDisplayID, Int32), runtime-resolved (absent symbol → unavailable, not a crash), called inside a CG configuration transaction (knoll usage). No alternate signatures, no IOKit fallback. - `opendisplay _rotate-exp <sel> <0|90|180|270>`: the short-lived helper process. Gated behind OPENDISPLAY_EXPERIMENTAL_ROTATION=1; validates the angle, requires the target be active, non-mirrored and not the only active display; calls the rotator; polls CGDisplayRotation to confirm; verifies no other display moved; rolls back on mismatch. - ExperimentalRotationBackend (#if !PUBLIC_API_ONLY): shells to that helper so a client-side crash kills only the helper. Selected by AppModel only when FeatureFlags.experimentalRotation is set (UserDefaults, default off); otherwise ReadOnlyRotationBackend stays the default. - Recovery marker: AppModel writes a pending marker before a rotation and clears it after; on next launch an uncleared marker triggers Reconnect All to restore a safe layout. - Menu: when enabled, the Screen rotation row offers a 0/90/180/270 picker with an "Experimental" badge; otherwise it stays read-only with the Open Display Settings fallback. Verified: the gate refuses without the env opt-in, and the active-display guard refuses an asleep target. All four schemes build (public-API-only excludes it); make test green. The live 0→90→180→270 acceptance matrix is for the user to run with the flag on and displays awake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ile) MenuActionRow gains an optional trailing value, shown right-aligned on the collapsed row: the Screen rotation row shows the current angle, Colour mode shows the active preset, and Colour profile shows the current profile name (the latter two once read). Addresses the "show the active state on the row" UI items. Builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s restart Root cause of two reported bugs: the managed-offline list (displays turned off via the app) was in-memory only. When the app restarted (and the private SkyLight disable did NOT revert on an abrupt termination, as confirmed live), the restarted app lost all knowledge of the off display — so it showed no off-card to turn it back on, AND the watchdog (which re-enables the built-in from that list when zero displays are active) had nothing to recover, leaving "no displays at all" once the external was unplugged. Fix: persist managedOffline to <AppSupport>/OpenDisplay/managed-offline.json (atomic JSON), saved on every change. On launch it's reloaded and reconciled against the live topology — entries whose display is back online + active are dropped (they returned on their own); the rest stay as recoverable off-cards. Launch also runs the always-one-active invariant once, so an app that starts into a stranded zero-active state immediately re-enables the built-in. OfflineDisplay is now Codable; the debug dump lists managed-offline displays. Verified: re-enabling a stuck built-in via SkyLight brings it back (online 1→2); a persisted offline entry round-trips and is kept through reconcile; the app launches cleanly with both displays restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rotation failure recovery: the pending-rotation marker now records the display + its pre-rotation
("safe") angle, not just a flag. setRotation restores that angle if the helper fails, and on launch an
uncleared marker restores the recorded display to its safe angle (then Reconnect All), so a crash
mid-rotation can't leave a display stuck. Verified: a written marker is detected and cleared on launch.
ICC / control caches across hot-plug, sleep/wake, reconnect: the ICC profile itself survives inherently
(ColorSync stores it by the stable device UUID), but the cached control values (brightness, DDC preset,
input, colour-profile name, dim) could go stale when a display leaves and returns. refresh() now prunes
those caches to the present displays — only reassigning a cache that actually has a stale key — so a
reconnected display re-reads fresh state.
All four schemes build; make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 Settings toggle: AppModel.experimentalRotationEnabled is a published, UserDefaults-persisted flag, and the rotation backend is now computed from it so toggling takes effect immediately (no relaunch). A "Labs" section in Settings → Diagnostics exposes it (full build only) with a clear warning. #2 Bundle the helper: the experimental rotation backend runs the opendisplay CLI as an isolated helper, so the CLI must live inside the .app. It can't be built within the app's Xcode target (a Swift tool's module outputs conflict with the app's — both a copy-dependency and a shared-scheme build fail), so it's bundled via a standalone script: scripts/bundle-helper.sh (also `make bundle-helper`) builds the app + CLI separately and copies the CLI to Contents/Helpers/opendisplay. The CLI gains an @executable_path/../Frameworks rpath so it resolves the app's embedded frameworks from there, and the backend looks in Contents/Helpers (bundled) and beside the .app (dev). Verified: the bundled helper runs from inside OpenDisplay.app (`list` works → rpath resolves), and the gate still refuses without the opt-in env var. All four schemes build; make test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nents Settings moves from a 3-tab shell to a NavigationSplitView sidebar (Displays · Arrange · Scenes · Health & Recovery): "Displays" is a selection list feeding a per-display DisplayDetailView, "Arrange" is promoted to its own item, and diagnostics/recovery/Labs are unified under "Health & Recovery". The menu-bar popover is slimmed to the fast, frequent controls (one unified brightness slider, volume when reported, status chips, quick actions), deferring detail to Settings. Adds design-system components (Badge, InlineBanner, Layout, MenuBarControls) and expands Tokens. See Docs/InterfaceRedesign.md. Also ignore local build/ output and .claude/ session config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied a multi-lens performance/cleanup audit (every finding verified against the
always-one-active-display safety invariant; safety paths untouched):
Concurrency / Apple Silicon — keep private SPI and slow I/O off the main thread:
- DisplayServices brightness (private SPI, blocking IPC) reads/writes now run off the
main actor (Task.detached); marked the provider Sendable. No main-run-loop stall on a
built-in brightness drag.
- DDC controller construction (dlopen + IOKit registry enumeration) moved off-main with
an in-flight guard so a first popover-expand never hitches and a display can't bind two
IOAVService handles.
- ICC/ColorSync work (installed-profile iteration + verify) moved off-main and cached in
published state; the menu never iterates ColorSync inside a SwiftUI body anymore. The
post-write read-back ("verify, don't assume") is preserved.
- Per-display EDID fingerprinting batched off-main, then resolved in one registry call.
- Experimental rotation helper awaits via a termination-handler continuation instead of
blocking a cooperative-pool thread on Process.waitUntilExit().
- Input-source and colour-preset DDC writes now coalesce through a per-display drain
(like brightness/contrast), so rapid taps settle in order on the I2C bus.
Algorithmic / render:
- DisplayRegistry gains resolveAll(): N-displays-per-topology-event becomes ONE registry
JSON write instead of N (identical per-display recognize/mint behaviour).
- ResolutionCard caches the mode list once per display and filters locally, replacing
three CGDisplayCopyAllDisplayModes enumerations per render.
- refresh() no longer republishes statusText/diagnostics when unchanged (avoids needless
model-wide SwiftUI invalidation); diagnostics still drive the menu-bar degraded banner.
Lifecycle / memory:
- CoreGraphicsProvider registers its reconfiguration callback opt-in (only the long-lived
observer that consumes changes()), with a retained self-token, closing a callback-vs-
deinit resurrection race; short-lived intent/CLI/rescue providers no longer register.
- DDC controller/handle caches are now pruned when displays disappear, bounding
IOAVService retention across reconnect cycles.
Cleanup:
- Deleted the dead control-provider stack (DDCProvider, NativeControlProvider, the
ControlProvider protocol, the Capability enum + CapabilitySnapshot) — hardware control
actually flows through ExternalDisplayDDC/HardwareControl. Removed two framework targets
from both app bundles.
- Removed unused ODFont, the LifecycleState composite struct, and the never-called
LifecycleProvider.reconnectAll(_:deadline:) requirement; DEBUG-gated OPENDISPLAY_DUMP so
it's excluded from release; flagged the unwired ProviderHealth (DIA-007) with a TODO.
make test: 78/78. All four schemes build; verified live (CLI + headless enumeration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README: add a Features description and an Install section (download the unsigned .app + clear quarantine, or build from source); refresh the stale test count (78) and the "providers land in M0 spike" note now that real providers ship. - CHANGELOG: fold the scaffolding notes into a 0.1.0 developer-preview entry covering the shipped app capabilities and the Apple-Silicon performance pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Bootstraps OpenDisplay from the PRD and the design kit. This PR establishes the repository, the platform-independent safety core (with tests), CI, documentation, and open-source governance — everything that can be built and verified without a Mac. The macOS app, providers, rescue utility, CLI, and SwiftUI design system are scaffolded and fully specified for implementation on a Mac (milestone M0).
What's included
Cross-platform packages (with XCTest suites):
DisplayDomain— models, multi-signal identity scoring, and the lifecycle + transaction state machines (§9.3, §10.5, §13.1).ProviderInterfaces— provider protocols + typed failure semantics (§9.9).TopologyCore— non-bypassableSafetyEngine(safe-surface/preflight) and the serializedTopologyCoordinator(checkpoint → apply → verify → commit/rollback). Provider success is never product success (D-010).SceneEngine— deterministic, idempotent, safely-ordered scene planner (§10.7).AutomationSchema— stable JSON result envelope + selector grammar (§12).SimulatorProvider— in-memory provider exercising every result/fault state.Scaffolding & process: monorepo per §18.3; macOS target stubs (apps, rescue, CLI, 6 providers, design system); CI (Linux+macOS domain tests, SwiftLint); docs (architecture, recovery, decisions, PRD); governance (contributing, security, code of conduct, RFC/issue/PR templates); the design kit preserved as the SwiftUI source of truth.
Tests
Roadmap & tracking
Epics #1–#9 (labeled by milestone M0–M4). Note: GitHub milestone objects couldn't be created via the available tooling, so each epic carries an
M0…M4/Labslabel instead — create the milestone objects in the UI and bulk-assign if you'd like first-class milestones.Epics: #1 Foundations · #2 Registry & identity · #3 Lifecycle & recovery · #4 Topology/modes/scenes · #5 Controls · #6 Automation · #7 UX & accessibility · #8 Diagnostics/security · #9 Distribution & release.
Next (on a Mac)
M0 safety spike: Xcode project,
CoreGraphicsProviderenumeration,ExperimentalLifecycleProviderspike, design-system port, and the rescue utility — proving safe disconnect/reconnect + independent recovery end to end.