Skip to content

feat(macos): implement native screen capture and host input - #158

Draft
hyperb1iss wants to merge 143 commits into
mainfrom
nova/macos-capture-input
Draft

feat(macos): implement native screen capture and host input#158
hyperb1iss wants to merge 143 commits into
mainfrom
nova/macos-capture-input

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Aug 13, 2026

Copy link
Copy Markdown
Owner

💜 What changed

This implements Spec 76 as Hypercolor's native macOS screen capture and host input architecture.

  • Replaces the macOS device_query polling bridge with native CGEventTap keyboard and pointer capture, independent consent, exact physical key identity, media keys, two-axis high-resolution scrolling, and event-driven publication.
  • Adds a transactional ScreenCaptureKit source with Apple's system picker, complete permission and lifecycle state, retained frame ownership, live repicking, source persistence, cadence and cursor policy, SDR and HDR formats, calibrated tone mapping, and Tahoe reference diagnostics.
  • Adds IOSurface and Core Video Metal import, family-aware storage, exact CPU and GPU parity, native reduction, bounded admission, generation fencing, zero-full-frame-copy accounting, and completion-fenced lease retirement.
  • Adds durable single-owner coordination across the app sidecar, direct launchd, Homebrew, and standalone daemon topologies, with typed conflicts, crash-safe handover journaling, rollback, CLI and UI remediation, and event-driven status.
  • Adds the macOS 15.2 product floor, stable signing identities, hardened-runtime entitlements, notarization and staple verification, signed TCC canary tooling, Apple Silicon and Intel CI qualification, and fail-closed release acceptance gates.
  • Extends shared input, REST, WebSocket, LightScript, TypeScript, UI, CLI, OpenAPI, and generated Python contracts without polling or hand-mirrored API types.

Architecture

The implementation is split across dedicated platform crates and existing canonical boundaries:

  • hypercolor-macos-input owns native event-tap acquisition and decoding.
  • hypercolor-macos-capture owns ScreenCaptureKit sessions and retained frame lifetimes.
  • hypercolor-macos-gpu-interop owns IOSurface and Metal import plus native reduction.
  • hypercolor-macos-owner owns durable process topology arbitration and handover state.
  • hypercolor-core owns canonical input folding, demand, publication, processing, and status.
  • hypercolor-daemon owns renderer integration, protected local actions, diagnostics, telemetry, and API publication.

Candidate capture streams, request changes, source selections, and worker replacements commit only after validated first delivery. Old generations stay retained through GPU completion, while stale callbacks and terminal errors are fenced by exact lifecycle identities.

🔮 Deep review and hardening pass

A full multi-lens review of this branch (docs/design/71) drove a hardening pass that is now part of this PR:

  • The app reaps its daemon sidecar on exit, the daemon shuts itself down when the supervising app dies without reaping, and the watchdog reclaims orphaned same-topology daemons instead of crash-looping against them. This closes the black-window failure where a stale sidecar held the port and the ownership guard while a fresh launch burned its restart budget.
  • The daemon derives its launcher authority from live process evidence (launchctl service pids, code-signed parent verification); environment and CLI owner claims must corroborate the evidence. Standalone is the residual category, so cargo, sudo, and supervisor launches all start.
  • Capture transactions rekey to the adopting stage's generation, arm deadlines atomically with the generation check, hand cancel hooks the claim-time generation, and cancel when the last completer drops, so no source-pick or recovery interleaving can strand a waiter or wedge a candidate. Falsifier tests pin each of these.
  • Privacy-bearing actuation requires the protected-control credential everywhere it can start or retarget capture: config writes to the capture domain and the enabling audio and input leaves, full config resets, the macos_screen_parity diagnostic, and the WebSocket interactive-preview open. Credential comparison is constant-time.
  • Secure Keyboard Entry is detected: the rising edge emits an ordered gap that clears held-key state, and the status API reports secure_input_active, so a terminal holding the assertion no longer leaves phantom keys lit.
  • Native scroll reads the 16.16 fixed-point fields through the double accessor (the integer accessor made deltas 65536x too small); wheel units stay in the 1/120-notch contract spec 76 defines end to end.
  • The event tap re-enables after every disable instead of dying permanently after two, the run-loop stop race is closed with a dedicated stop source, the credential-holding webview rejects navigation off the bundled origin, media keys decode inside an autorelease pool, and CapsLock stops inflating anomaly counters.
  • The effect-preset routes are cataloged in the OpenAPI map, which returns the daemon test lane to green.

Receipts on this head: macos-capture 129 passed, launcher authority 10 passed, daemon openapi 4 passed, SDK 183 passed with typecheck and Biome clean, and workspace clippy clean at deny-warnings. One caveat: the render-thread screen-preview test family is timing-sensitive under intra-binary parallelism on high-core machines (46/46 pass single-threaded, and main flakes there too); it is tracked separately and CI's runners pass it.

Unsigned local bundles boot: when the daemon's own signature is ad-hoc, sidecar evidence rests on the structural chain (bundle layout, exact adjacent app binary, live parent code identity) instead of the identifier handshake that only signed builds can satisfy; signed builds keep the full handshake mandatory.

Deferred and tracked as follow-ups rather than fixed here: the consent model for capture-reactive effect applies, MCP tool authentication (both pre-existing surfaces), and the platform-layer restructuring, which now has its own abstraction-level review and spec in docs/design/72-cross-platform-boundary-review.md on main and is deliberately not part of this PR.

Verification

The final reversible implementation received an independent requirement-by-requirement PASS against Spec 76. No remaining source, deterministic-test, CI, packaging, generated-client, or documentation gap was found.

Focused receipts on the final branch include:

  • native macOS capture: 83 passed
  • native macOS input: 7 passed
  • core macOS fixtures: 180 passed
  • graphics acceptance harness: 25 passed
  • packaging contracts: 35 passed
  • signed canary validator: 27 passed
  • strict Clippy across capture, input, core, daemon, interop, app, types, and UI slices
  • canonical Python OpenAPI generation and WebSocket protocol checks
  • workspace formatting and diff checks

The active Apple M3 Pro also completed the required 100-iteration Metal 4 qualification. CPU, wgpu Metal, and direct Metal 4 output matched exactly. Metal 4 GPU reduction p95 was 204,500 ns versus 202,625 ns for wgpu, so the typed production decision is reject because it does not clear the required 10 percent improvement.

📦 Release lane reality

Public CI ships no macOS artifacts and does not update the Homebrew tap: repository runners cannot perform Developer ID signing, so macOS binaries flow through the signed acceptance checkpoint below and tap updates stay manual until a signing-capable lane exists. The AUR lane is unchanged, and RELEASING.md documents the current shape.

Physical acceptance boundary

This remains a draft until the signed physical acceptance bundle exists. Fixtures and builds do not substitute for the release gate.

Still required before a release can claim Spec 76 acceptance:

  • Developer ID signed and notarized Apple Silicon and Intel candidates
  • the signed TCC ownership and lifecycle matrix
  • physical keyboard, pointer, picker, SDR, HDR, teardown, and repick rows
  • Intel IOSurface reuse and managed-storage CPU-oracle parity
  • 30-minute 4K performance runs and the four-hour combined soak

The current development host has Xcode 26.6 and an Apple M3 Pro, but no Developer ID identity or notarization credentials. Opening this PR gives the hosted Intel lane the first available architecture-specific compile and fixture pass without weakening the physical release gate.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: df1d0560-5e48-49f9-a77a-bda64716904a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hyperb1iss
hyperb1iss force-pushed the nova/macos-capture-input branch from b76fbd7 to 854f36f Compare August 13, 2026 15:16
hyperb1iss and others added 28 commits August 15, 2026 13:45
Define the Sequoia-first architecture for ScreenCaptureKit, native host
input, Metal publication, TCC ownership, and Tahoe capabilities.

Carry Claude Opus review findings into explicit dependency, resource,
permission, fidelity, packaging, and acceptance contracts.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
ScreenCaptureKit lifecycle contracts rely on APIs introduced in macOS
15.2, so every build and distribution surface must reject older hosts.

Force Cargo and Tauri to the same floor, teach Homebrew to distinguish
15.0 from 15.2, and gate the curl installer before network or launchd
work.

Co-Authored-By: Nova (OpenAI Codex GPT-5) <noreply@openai.com>
Intel Macs remain a first-class Sequoia target, but the release workflow
only produced and consumed Apple Silicon tarballs.

Add the Intel standalone lane, checksum and Homebrew plumbing, teach both
installers the amd64 artifact name, and pin both native app lanes to the
macOS 26 runner family. Homebrew now avoids respawning a clean ownership
conflict exit.

Co-Authored-By: Nova (OpenAI Codex GPT-5) <noreply@openai.com>
Run every Rust-changing pull request on native Apple Silicon and Intel
Tahoe runners with the repository-pinned Xcode 26 SDK. Audit a finished
Mach-O on both architectures and every macOS release payload so the 15.2
floor cannot drift before upload.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Give every app and standalone Mach-O a checked stable identifier and an
explicit entitlement profile. The signing actor rejects ad-hoc identities,
unknown code objects, signature drift, and unaccepted notarization before
producing stapled app and DMG artifacts or standalone provenance.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Route native app and standalone macOS releases through the checked signing
actor. Release jobs now require Developer ID credentials, notarize exact
binary bits, publish provenance, and verify signatures, requirements,
entitlements, staples, and accepted receipts before upload.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Teach shared configuration about ScreenCaptureKit's persistable source
syntax and select the backend on macOS. Extend the canonical keyboard and
media inventories with total Apple virtual-key mappings so future rows
cannot silently omit a host backend.

Pin the Objective-C framework bindings required by the native capture and
input crates before those platform boundaries land.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Represent pointer scroll as signed Q16.16 values with independent line and
pixel aggregates, lifecycle phases, and momentum phases. Route exact events
through the canonical interaction batch while retaining a per-source
fractional projector for the legacy vertical wheel signal.

Expose exact scroll data to LightScript while preserving the existing wheel
compatibility field.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Decode both Raw Input wheel axes into exact signed Q16.16 Line120 values.
Core now publishes the canonical pointer-scroll event first and follows each
integral vertical projection with one ordered legacy wheel shadow.

Reset fractional projector state with device and session generations so gaps
cannot leak stale motion into a replacement source.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Decode evdev vertical and horizontal wheel axes into exact signed Q16.16
Line120 events. Suppress low-resolution duplicates independently per axis
when a device advertises matching high-resolution events.

Publish integral vertical compatibility shadows and reset fractional state
whenever the evdev source loses continuity.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Add exact two-axis scroll injection across the daemon and UI while retaining
the legacy wheel wire shape for older clients. Core emits one ordered
compatibility shadow only for integral vertical Line120 motion.

Preserve DOM pixel precision, validate both Q16.16 axes with checked bounds,
and reset fractional projection state whenever a browser source reconnects.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Carry exact scroll totals and ordered events through the production
LightScript adapter. Replace optional mouse fields with a discriminated SDK
union while retaining the deprecated wheel member for compatibility.

Sanitize units, phases, deltas, and idle fallbacks so effects receive one
stable shape inside and outside the daemon runtime.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
The Rust input contract already publishes integral 1/120-notch wheel units.
Keep those values intact across the LightScript bridge instead of scaling
state and events into fractional notches.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Define the plain Rust batch vocabulary, consent-specific event masks, media
and pointer decoding, modifier flags, scroll phases, and validated virtual
desktop geometry before introducing framework ownership.

The contract compiles on every host. Core Graphics values stay out of
hypercolor-core's portable folding layer.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Own separate listen-only keyboard and pointer taps on a dedicated Core
Foundation run loop. Decode native keys, media controls, buttons, motion,
scroll phases, and virtual desktop geometry into bounded Rust batches.

Overflow and tap interruption publish ordered state barriers. Stop removes
both tap sources before joining the worker and flushing the final barrier.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Connect the event-tap boundary to canonical held state, exact scroll,
motion, lifecycle status, and generation fencing. A deterministic fixture
covers partial permission, effective masks, epochs, and owner restarts.

Preserve typed tap-disable reasons across the native boundary so stable
failure codes distinguish timeout, user-input disable, and revocation.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Replace daemon startup with the native event-tap source while preserving
keyboard and pointer consent independently. Map macOS permission failures
through MCP health and cover both startup and diagnostic behavior.

Delete the polling bridge, its tests, dependency graph, stale fixture
labels, and lock inventory entry now that every supported host is
event-driven.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Apple's SDK declares content and bounding rectangles in surface points,
while dirty rectangles arrive in pixels. Preserve that boundary so capture
validation never double-scales damage or applies content scale twice.

Co-Authored-By: Nova (Codex GPT-5) <noreply@openai.com>
Model ScreenCaptureKit frames as validated plain Rust metadata backed by an
opaque retained surface. Reject unknown formats, invalid plane arithmetic,
malformed geometry, and incomplete color metadata before publication.

Keep lifecycle-only statuses independent from image payloads and advance
sequence numbers for complete frames only.

Co-Authored-By: Nova (Codex GPT-5) <noreply@openai.com>
Screen capture must compile without Servo's heavyweight renderer stack.
Make the existing context bridge opt-in and bind the core Servo feature
to that exact edge so native capture can use IOSurface imports alone.

Co-Authored-By: Nova (Codex GPT-5) <noreply@openai.com>
Validate ScreenCaptureKit samples into plain Rust frame metadata while
retaining the originating CVPixelBuffer and its IOSurface lifetime. Keep the
framework callback bounded with latest-value replacement and per-reason drop
counters.

Drive Apple's picker from the main thread and stage source changes in a
candidate SCStream. A repick commits only after its first complete frame, so
cancellation, malformed samples, and candidate failures preserve the active
stream.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
Keep CVPixelBuffer locking, plane-address validation, and unlock symmetry
inside the capture crate. Expose a BGRA row-copy operation that preserves
source and destination padding without leaking Core Video types.

Use cross-platform pixel fixtures so CPU publication shares the validated
contract before signed hardware acceptance runs.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
Wrap picker ownership in dispatch2's MainThreadBound. The session handle can
move with the core input source. Picker access and teardown still run on the
process main thread.

Pin the Send and Sync contract with a compile-time integration test.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
Retain the committed ScreenCaptureKit filter while zero demand stops active
and candidate streams. Reactivation stages a fresh stream from that local
filter without prompting or reopening Apple's picker.

Promote a repicked filter only when its first complete frame activates.
Candidate failure therefore preserves the last known-good selection.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
Decode sRGB, Rec.709, Rec.2020, and linear BGRA samples into linear light.
Convert Display P3 and Rec.2020 primaries to sRGB, compress out-of-gamut
results, and publish canonical RGBA8 bytes with preserved alpha.

Reject PQ and HLG at the SDR seam. HDR cannot fall through as ordinary BGRA.
Golden fixtures cover channel order, wide-gamut behavior, and rejection.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
Use a condition variable so conversion workers sleep until a frame arrives.
Keep a bounded wait only for teardown responsiveness. Superseded frames
continue to replace in constant space.

Cover ready delivery and timeout behavior without timer polling.

Co-Authored-By: Nova (GPT-5.6 Codex) <noreply@openai.com>
The native media worker only exists on Linux and Windows. Gate its private
shutdown and failure-publication helpers so macOS lint does not compile dead
branches that cannot execute there.

Co-Authored-By: Nova (OpenAI Codex) <noreply@openai.com>
Bridge retained ScreenCaptureKit frames into core through an admitted BGRA8
conversion worker and a generation-fenced latest publication. Candidate
workers start parked so failed thread creation preserves the live graph.

Add deterministic fixture coverage for demand activation, publication,
teardown, reconfiguration, and stale-generation retirement.

Co-Authored-By: Nova (OpenAI Codex) <noreply@openai.com>
hyperb1iss and others added 20 commits August 15, 2026 13:45
Separate privacy-bearing capture and input authority from loopback locality.
Control credentials and trusted in-process transports can reach protected
REST routes and WebSocket channels, while ordinary local lighting control
keeps its credentialless compatibility.

Reject sensitive subscriptions before demand registration and preserve the
trusted authority through WebSocket command dispatch when API keys are off.

Co-Authored-By: Nova (GPT-5 Codex) <noreply@openai.com>
Import signing passwords through a bounded Security.framework helper so
certificate and keychain secrets never enter process arguments. Reject raw
Apple ID password notarization and retain keychain-profile support locally.

Keep Apple release credentials, signed artifacts, physical acceptance, and
Homebrew promotion in the proprietary build system. Public CI now validates
short-lived unsigned app fixtures and cannot select them for a release.

Co-Authored-By: Nova (GPT-5.4) <noreply@openai.com>
Replace historical surface accounting with per-live admission tokens so
IOSurface reuse and pinned generations retain exactly their native and heap
allocations. Admit transient lease peaks before allocation and free index
storage before releasing its charge.

Preserve the GPU-only path and make additional live surfaces depend on real
byte capacity instead of a fixed identity count.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Carry the complete owner topology, epoch, and process identity through
handover. App sidecars stop only through their matching retained child with
SIGTERM. Launchd and Homebrew stops stay bound to exact service identity
under the owner publication lock.

Fail closed when replay lacks stop authority, preserve version-one journals,
and converge safely around newer same-topology publications. Reopen the app
watchdog only after a terminal rollback proves the app sidecar is active.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Keep each capture admission owner alive beside its direct wgpu, Core Video,
and native Metal wrapper. Reimports replace ownership under the cache lock,
while explicit clearing releases only cache-held generations.

Route retirement and Metal recovery clear every screen cache through one
operation. Submitted GPU work keeps its own lease until completion, so cache
turnover never shortens the GPU resource lifetime.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Publish lifecycle, health, freshness, pressure, and invalidation epoch from
one locked observation. Terminal worker events clear every branch under one
monotonic epoch. Recoverable failures retain last-good output.

Fence prepared publications across invalidation and clear compositor output
plus native GPU caches before a fresh publication can latch.

Co-Authored-By: Nova (GPT-5) <noreply@openai.com>
Bind each daemon runtime to the canonical owner incarnation with a
private per-user session artifact. Protected local control now requires
the rotating session credential instead of treating loopback as identity.

Publish only after the flock winner is established, expose only the
public session ID, and clear the exact session after every normal exit.
Separate lifecycle control, latest complete frames, and recoverable
diagnostics so frame pressure cannot erase terminal state. Bind async decode
to a monotonic invalidation generation and serialize lifecycle transitions
through the same generation authority.

Co-Authored-By: Gibbs (Codex) <noreply@openai.com>
Keep the deprecated owner argument beside new environment metadata during
the compatibility window, and require exact launcher corroboration before
ownership. Move raw direct installs into a verified Rust transaction that
stages immutable units before stopping a healthy daemon.

Co-Authored-By: Huygens (Codex) <noreply@openai.com>
Serve the desktop shell from bundled Tauri assets and publish daemon
routes only after health and ownership verification. Protected capture
REST and WebSocket traffic receives a per-session control credential
without granting remote documents application IPC.

Bind the daemon's final API sockets before publishing the private
session. A drop-ordered runtime authority keeps the exact listeners,
owner guard, and attestation coherent across startup failures, shutdown,
and unwinding.

Screen pixels remain on the existing GPU transport. Tauri carries
control metadata only.

Co-Authored-By: Godel (GPT-5.6) <noreply@openai.com>
Co-Authored-By: Nova (GPT-5.6) <noreply@openai.com>
The daemon now resolves its macOS owner from live process inspection
(launchctl service pids, code-signed app-sidecar parent verification)
instead of trusting the HYPERCOLOR_MACOS_OWNER environment claim or the
--macos-owner flag; both remain claims that must corroborate the derived
authority, and disagreement is fatal. Launchd and Homebrew service
definitions gain the matching owner environment.

Standalone is the user-directed residual category: no shell allowlist
gates it, because a parent-binary name grants no authority (any launcher
can interpose `sh -c`) and the allowlist broke cargo, sudo, and
supervisor launches. Path comparison treats unresolvable paths as
unattested rather than fatal, so bundle-shaped checks cannot abort
startup on layouts (standalone, Homebrew, launchd) that have no app
binary sibling, and the bundle-layout gate runs before the sibling probe
it guards.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The stream transaction machinery moves out of native.rs into
native/transactions.rs (claim-once cells, deadline scheduler, waiters)
and native/lifecycle.rs, and the core capture control plus the TCC
canary adopt the transaction API in place of raw channel receivers.

Transaction cells now carry a mutable generation. When a source pick or
interrupted-recovery restage adopts an in-flight request, the adopted
cell is rekeyed to the new stage epoch; previously the cell stayed
keyed to the superseded epoch, every generation-filtered arm, cancel,
and claim missed it, the fresh candidate was insta-cancelled, and the
core waiter either timed out spuriously on the old epoch's deadline or
hung forever. Rekeying retires the old epoch's deadline in the same
breath and bumps the deadline revision so in-flight timeout callbacks
die on the revision check.

Deadline arming validates the stage generation inside the cell's own
critical section (arm_for_generation): an arm validated under the
stream-state lock and preempted across an adoption rekey would
otherwise re-install a deadline whose timeout hook targets the
superseded stage and no-ops, wedging the adopted candidate.

The render thread's capture-demand reconciler keeps retrying a failed
demand application on every tick and paces only the warning: delaying
the retry itself would hold capture inactive for the pacing interval
after a transient failure, which the preview latency tests reject.

Cancel hooks receive the generation the cell held at claim time
instead of capturing a staging-time epoch that adoption makes stale, so
a waiter cancel after adoption tears down the stage that actually owns
the transaction. A completer drop guard cancel-claims the cell when the
last completer clone drops, so no code path can strand a waiter; the
guard deliberately skips the cancel hook, and claim retires hooks
outside the cell lock so a hook that captured a completer cannot
deadlock.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Tray quit runs app.exit(0), which terminates without unwinding, so the
ManagedDaemon Drop kill never fired and the detached (process_group(0))
daemon survived as an orphan holding :9420 and the ownership guard. A
fresh app launch then probed the stale daemon's health, failed owner
verification five times, gave up, and rendered a black window.

Three coupled fixes close every path:

The app run loop handles RunEvent::Exit and reaps the managed daemon
while the process is still alive: watchdog suppressed, graceful
SIGTERM, a ten-second wait sized to the daemon's own shutdown budget
(API drain, device blanking, persistence flush), hard kill as the
fallback. Quit during the startup window, before a child is bound,
falls back to a SIGTERM by pid.

The daemon arms a parent-death watch when the supervisor hands it
HYPERCOLOR_SUPERVISED_PARENT_PID: a change in getppid means the app
died without reaping (crash, SIGKILL), and the daemon shuts itself down
instead of orphaning. This is the unix mirror of the Windows job-object
kill-on-close coupling.

The watchdog routes all three "someone else holds our guard" signals
into reclaim instead of the restart-budget crash loop: the terminal
conflict exit (a same-owner contender loses guard arbitration within
milliseconds), the owner-publication mismatch, and the session-verify
failure. When the owner record names an app-sidecar pid this supervisor
never spawned, the guard is actually contended, and the live process
matches the recorded executable identity (canonicalized on both sides),
the orphan is asked to terminate and the guard release is awaited
before respawning. Reclaims charge the restart budget so a pathological
reclaim loop still trips the circuit breaker. Spec 77 invariant 8
holds: the single-instance app is the owning topology reaping its own
orphan.

ManagedDaemon::Drop now kills unless the child has provably exited, so
a try_wait error cannot leak a live process. The app-shell recovery
predicate rearm and the webview hardening ride along: navigation in
the credential-holding webview is restricted to the bundled tauri
origin, and the private secure-input release selector warns once per
process if WebKit ever renames it.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
…control

POST /config/set could start screen capture, retarget the display, flip
the microphone, or enable keyboard capture from any local unprivileged
process with no credential, bypassing the gated picker that guards the
identical capture.source mutation; POST /diagnose actuated a real
screenshot-reference capture the same way; and the WebSocket
interactive_preview_open verb raised real screen, audio, and
interaction capture demand at the plain control tier, which
unauthenticated loopback satisfies.

All three now require the protected-control credential. Config writes
gate the whole capture domain plus the enabling and device-retargeting
leaves of audio and input (audio.enabled, audio.device, input.enabled,
input.keyboard, input.mouse) and full resets, which rewrite them; DSP
tuning and interaction routing policy stay credential-free so a keyless
install keeps its sliders. Diagnose gates only requests that include
the macos_screen_parity check, and the default check set stays open.
The remaining preview verbs manage an already-authorized session and
stay at the control tier. Route-level rejection tests cover the three
endpoints, and the config integration tests ride the trusted
in-process API like the other protected routes.

Credential and API-key comparison switches to subtle's constant-time
equality so match position cannot leak through timing; length still
leaks, which is standard, since credential lengths are fixed and
public. Audio device enumeration moves off the
request thread.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Secure Keyboard Entry (a terminal holding the secure-input assertion)
starves the event tap of keyboard events while pointer events keep
flowing, so keys held at that moment stayed pressed forever and
keyboard-reactive effects stayed lit. The 250ms health tick now polls
IsSecureEventInputEnabled next to the existing TCC poll, emits an
ordered SessionInterrupted gap on the rising edge (clearing held-key
state), and publishes the observation into the session diagnostics so
status snapshots read a cached value instead of calling Carbon from the
render thread at frame rate. The flag surfaces on the platform status
and the daemon status API, which also starts deriving audio
availability from registered sources instead of probing host hardware;
no pids or app names are logged.

Native scroll deltas were 65536x too small: the reader used the integer
accessor on the 16.16 fixed-point ScrollWheelEventFixedPtDeltaAxis
fields, which rounds to whole notches and discards the raw bits the
fold pipeline expects. It now reads the double accessor and scales back
to Q16.16. Downstream units stay exactly as spec 76 defines them
(mouse.wheel carries 1/120-notch units end to end); the keystrike
effect, which still multiplied raw line120 by a per-notch factor, now
converts to notches first.

The event tap re-enables on every disable, including repeated ones: a
disabled tap fires no callbacks, so the old give-up-after-two policy
was permanent capture death with no recovery trigger. Retry cadence is
bounded by the window server's own disable timeout, and repeats stay
visible through diagnostics and the Degraded state.

A dedicated run-loop stop source closes the shutdown race where
CFRunLoopStop lands after the worker's stopping check but before
CFRunLoopRun begins (a no-op that left stop() joining forever): the
signaled source is serviced on loop entry and stops it from inside.

The NSEvent media-key bridge runs inside an autorelease pool, and
CapsLock's lock-state flag no longer counts its release re-report as an
impossible edge.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
host_driven_animation now includes every HTML-sourced effect on macOS,
including ones tagged webgl or canvas2d that previously kept their own
requestAnimationFrame loops. With the native GPU interop path those
effects render one frame per host tick like the rest of the catalog;
other platforms keep the tag-based exclusion unchanged.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The branch's every_static_router_operation_is_cataloged test surfaced a
pre-existing gap: the effect-preset routes (list and apply) have been in
the router since before v0.3.2 but were never added to the
hand-maintained catalog, keeping the daemon test lane red.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The macOS icon script becomes a one-line delegation to the canonical
brand pipeline (assets/brand/build.py app-icon), which now emits the
Pillow-written icns alongside the six Tauri icon assets and copies them
into the app crate. The old qlmanage/sips/iconutil path was already
dead: its source SVG no longer exists in the repo. The regenerated
icon.png and icon.icns pick up the brand-master artwork with the ~5%
safe-margin inset; the new icns drops the 1x 16pt and 32pt members
(macOS downscales from the retained @2x set).

The app bundle recipe gains an app-bundle-binaries stage so bundling
builds the daemon and CLI sidecars (plus the SMBus broker and elevated
helper on Windows) instead of staging whatever was left in
target/release, and the packaging tests pin the launchd and Homebrew
launcher definitions exactly, including the new owner environment.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
RELEASING.md now states what the tag lane actually does: no macOS
artifacts and no Homebrew tap updates from public CI, because
repository runners cannot perform Developer ID signing; signed macOS
artifacts flow through the acceptance checkpoint and tap updates are
manual until a signing-capable lane exists. HOMEBREW_TAP_TOKEN is
marked unused.

Spec 77 marks invariants 1 and 3 as target-state until H3.5 lands,
naming the legacy CPU publication fallback as the one temporary
mitigation so the GPU-only claim stops being false while the fallback
is live. AGENTS.md documents the hypercolor-macos-owner crate in the
project map and dependency graph.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The missing-device recovery test bounded background CoreAudio discovery
at two seconds, which a fully loaded machine (the whole suite in
parallel, or concurrent builds) starves reliably while solo runs finish
in well under one. The bound only asserts that discovery terminates, so
it now carries headroom instead of encoding a latency expectation the
test never meant to make.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
@hyperb1iss
hyperb1iss force-pushed the nova/macos-capture-input branch from 854f36f to c1d78b8 Compare August 15, 2026 23:39
hyperb1iss and others added 9 commits August 15, 2026 18:17
Unsigned local builds are ad-hoc signed, so their designated requirement
is a bare cdhash with no identifier chain, and the signed parent/daemon
requirement handshake can never succeed. The sidecar evidence then fell
through to standalone, the supervisor's app-sidecar claim failed
corroboration, and an unsigned bundle could not boot its daemon at all:
on a host without a Developer ID identity there was no way to run the
app.

When the daemon's own requirement is ad-hoc shaped, sidecar evidence now
rests on the structural chain alone: bundle layout, the exact adjacent
app binary as parent, and the parent's live code identity executing that
binary. A daemon whose requirement carries the identifier chain never
takes this path, so signed builds keep the full handshake mandatory and
fail closed.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The deep review that drove this branch's hardening pass lives with the
PR it reviewed: verdict, both live symptoms, every finding with its
receipt, the structural assessment of the ownership layer, and the
addendum recording what the fix pass landed and which of the review's
own claims the verification round corrected.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Sidecar evidence expected the launcher at Contents/MacOS/Hypercolor,
but Tauri names the .app folder after the product while keeping the
cargo binary name for the executable, so every real bundle ships
hypercolor-app and the expectation was unsatisfiable, signed or not.
The supervisor's app-sidecar claim then failed corroboration and the
bundled app crash-looped to a blank window.

The parent check now accepts the bundle's app binary names, with the
canonical file name matched byte-for-byte: on the default
case-insensitive APFS volume the product-cased candidate would
otherwise resolve onto the lowercase CLI binary sitting in the same
directory.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The CSP introduced with the trusted-UI session binding listed explicit
schemes in connect-src but omitted 'self', and connect-src does not fall
back to default-src. Inside the webview the SPA lives on
tauri://localhost, so the wasm-bindgen loader's fetch of the WebAssembly
module was refused by the policy, the UI never booted, and the window
stayed black even with a fully healthy supervised daemon. Browsers
hitting the daemon-served copy over http were unaffected, which is what
made the webview look uniquely broken.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Tauri merges tauri.macos.conf.json over the base config, so the macOS
webview enforces that file's loopback-scoped connect-src, not the base
one; adding 'self' only to the base config changed nothing in the
bundle. Both CSPs now allow same-origin connects, and the packaging
tests pin the corrected directives.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The app-wide config was fetched exactly once at wasm init, with every
non-auth error silently discarded. When the webview boots while the
daemon is still binding, or outlives a daemon restart (the supervised
sidecar makes that routine), that one fetch dies on a dead port and
nothing ever retries: the Settings page shows its loading skeleton
forever with no console output, while every hint-driven resource heals
on the WebSocket reconnect. Observed live: a session whose boot burst
fetched devices, scenes, and effects but never issued GET
/api/v1/config, because the daemon respawned five minutes after the
first instance and the webview predated it.

The fetch now keys on the socket generation, so it refires on every
WebSocket open, healing boot races and daemon restarts through the same
reconnect that refreshes everything else. The swallowed error arm now
logs a console warning so the next silent failure is visible.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
The Input and Capture sections rendered the daemon's diagnostic
snapshot verbatim: source identifiers (macos_host_input, cg_event_tap),
state-machine vocabulary (configured / consented / demanded /
freshness), capability owners, Tahoe HDR capability dumps, and
requires_app_ui error codes. None of that means anything to a person
configuring their lights, and an unsupported-on-macOS media source
could push "run Hypercolor on Linux or Windows" into the input health
banner as if it were actionable.

Each section now shows at most one plain sentence: capturing, ready,
or a warning with the daemon's remediation when there is one. The
permission rows (Input Monitoring, Screen Recording) and the restart
action remain the only calls to action, reworded in plain English. The
per-source health cards, platform detail dumps, and their helpers are
deleted outright; the daemon status API still carries the full
diagnostics for anyone debugging.

The input rollup now only listens to interaction sources. Media,
network, and audio sources live in their own domains, so their issues
can no longer degrade input health or leak remediations into the input
section; a regression test pins the unsupported-media case.

The macOS daemon-owner panel no longer renders an always-on card with
the active owner, epoch, and a "local only" badge. It appears only
when there is a real decision or event: two installs fighting over the
lights, or recovery from an interrupted switch, both described in
plain words.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Ad-hoc signatures carry a per-build cdhash designated requirement and a
mangled per-build identifier, so macOS TCC treats every rebuild as a
brand-new app: Screen Recording and Input Monitoring grants die on each
just app-bundle while System Settings keeps showing the old build's
toggle as enabled. Signing with a certificate gives the bundle a stable
identity (identifier plus certificate leaf), so grants survive rebuilds.

just app-bundle now resolves its signing identity through
scripts/macos-dev-signing-identity.sh: an exported
APPLE_SIGNING_IDENTITY wins, then a valid local "Hypercolor Dev"
certificate, then explicit ad-hoc with a warning. The script prints the
certificate hash rather than the name because a duplicate cert with the
same label makes codesign reject the name as ambiguous. Non-macOS hosts
resolve to ad-hoc and are unaffected.

docs/development/DEV_SETUP.md documents the development environment for
all three platforms: Linux system libraries and udev rules, the macOS
signing certificate setup with the TCC rationale, and the Windows
toolchain plus PawnIO hardware support. CONTRIBUTING.md links to it.
Release signing and notarization are unchanged and stay with
mac-installer.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
Signing the dev bundle with a real certificate surfaced two failures an
ad-hoc bundle never hits. Hardened-runtime library validation refused
the Homebrew dylibs Servo links (freetype and friends carry a different
team identity), killing the daemon at dyld load; the entitlements now
include disable-library-validation, which release signing needs for the
same reason. And Tauri signs the daemon sidecar with a filename-derived
identifier, while the app-sidecar ownership handshake requires the
designated requirement to open with the
tech.hyperbliss.hypercolor.sidecar identifier and share its certificate
tail with the app, so the daemon exited with an uncorroborated launcher
claim. scripts/macos-dev-postsign.sh now re-signs the daemon with the
sidecar identifier after the Tauri build and reseals the bundle, the
same fix-up the release lane's manifest signing performs.

Verified live: the certificate-signed bundle boots, the daemon claims
app-sidecar ownership through the requirement handshake (not the ad-hoc
structural fallback), and deep-strict verification passes.

Co-Authored-By: Nova (Claude Fable 5) <noreply@anthropic.com>
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.

1 participant