Skip to content

Scaffold OpenDisplay: monorepo + platform-independent safety core - #10

Merged
aquitaine merged 58 commits into
mainfrom
claude/trusting-dirac-pewpub
Jun 23, 2026
Merged

Scaffold OpenDisplay: monorepo + platform-independent safety core#10
aquitaine merged 58 commits into
mainfrom
claude/trusting-dirac-pewpub

Conversation

@aquitaine

@aquitaine aquitaine commented Jun 22, 2026

Copy link
Copy Markdown
Owner

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).

⚠️ This session ran on Linux without Swift/Xcode, so the Swift code here has not been compiled locally. It is written to be correct and is verified by the new CI workflow (swift test on Linux + macOS) once this PR runs.

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-bypassable SafetyEngine (safe-surface/preflight) and the serialized TopologyCoordinator (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

  • Safety invariants: block removing the last safe display; rollback on provider failure; rollback when postconditions aren't verified; degrade on failed rollback; first-use confirmation; Reconnect All.
  • Identity confidence thresholds (identical monitors stay below threshold; serial/pairing clear it).
  • Scene idempotency + safe ordering; schema round-trip & forward-compatibility.

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 M0M4/Labs label 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, CoreGraphicsProvider enumeration, ExperimentalLifecycleProvider spike, design-system port, and the rescue utility — proving safe disconnect/reconnect + independent recovery end to end.


claude added 4 commits June 22, 2026 10:07
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
@aquitaine
aquitaine marked this pull request as ready for review June 22, 2026 10:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +170 to +172
let targetInactive = after.observation(for: target)?.isActive != true
let safeSurfaceRemains = safety.safeSurface(in: after, excluding: [target]) != nil
guard targetInactive && safeSurfaceRemains else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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

Copy link
Copy Markdown
Owner Author

Thanks for the review — all three P1 findings on TopologyCoordinator are addressed in b6ca341:

  • Keep blocked preflights non-bypassable — removed the userOverride escape hatch, so .blocked decisions (unhealthy recovery service, removing the last safe display) always terminate before any checkpoint/provider call. A genuine "disconnect all" advanced override needs an independently verified remote recovery surface (a future RFC), not a boolean.
  • Require explicit confirmation handler by default — the coordinator's default confirm handler now cancels instead of silently approving .needsConfirmation (low identity confidence, first-use route, disconnecting the current main). Production callers must supply a real countdown/confirmation handler.
  • Verify providers did not drop extra displays — verification now also rolls back if any previously-active non-target display went inactive, not just when the target fails to disconnect (§9.4).

Added regression coverage: testRollsBackWhenProviderDropsUnrelatedDisplay (new SimulatedFaults.alsoDisconnect) and testDefaultConfirmHandlerCancels. Full suite (42 tests) passes locally on Swift 6.0.3.


Generated by Claude Code

claude added 22 commits June 22, 2026 11:33
…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>
claude added 29 commits June 22, 2026 19:51
…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>
@aquitaine
aquitaine merged commit 32ad9aa into main Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants