From d7e4990ccfe009d6e87d42c03217c54bb5ce6db4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 02:20:22 -0700 Subject: [PATCH 001/144] docs(macos): specify native capture and input 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) Co-Authored-By: Claude Opus --- .../76-macos-screen-capture-and-host-input.md | 2612 +++++++++++++++++ 1 file changed, 2612 insertions(+) create mode 100644 docs/specs/76-macos-screen-capture-and-host-input.md diff --git a/docs/specs/76-macos-screen-capture-and-host-input.md b/docs/specs/76-macos-screen-capture-and-host-input.md new file mode 100644 index 000000000..a4825c371 --- /dev/null +++ b/docs/specs/76-macos-screen-capture-and-host-input.md @@ -0,0 +1,2612 @@ +# 76 - macOS Screen Capture and Host Input + +**Status:** Implementation-ready, revision 26; Claude Opus PASS +**Author:** Nova +**Date:** 2026-08-10 +**Platform floor:** macOS 15.2 Sequoia +**Build SDK:** macOS 26 Tahoe or newer +**Architectures:** Apple Silicon and Intel +**New crates:** `hypercolor-macos-input`, `hypercolor-macos-capture` +**Changed crates:** `hypercolor-core`, `hypercolor-daemon`, +`hypercolor-macos-gpu-interop`, `hypercolor-app`, `hypercolor-types`, +`hypercolor-windows-input`, `hypercolor-leptos-ext`, `hypercolor-cli`, +`hypercolor-ui`, `sdk/packages/core` +**Depends on:** specs 14, 57, 71, 72, and 73 +**Supersedes:** the unimplemented macOS portions of specs 14 and 71, plus the +temporary macOS `device_query` bridge retained by spec 72 + +## 1. Mission + +Give Hypercolor production-grade screen capture, keyboard input, and pointer +input on macOS without creating a second input pipeline or reducing the product +ceiling. + +The completed platform path is: + +```text +CGEventTap + -> hypercolor-macos-input + -> canonical InteractionData and InteractionBatch + -> existing routing, privacy, WebSocket, SDK, and effect contracts + +ScreenCaptureKit + -> retained CVPixelBuffer and IOSurface + -> hypercolor-macos-capture + -> exact screen publication plan + -> Metal texture import + -> SparkleFlinger and spatial reduction +``` + +The implementation must feel native to Sequoia, exploit useful Tahoe +capabilities, and preserve one coherent cross-platform contract. macOS is a +producer and execution target for the architecture established by specs 71 and 73. It is not a reason to fork those contracts. + +## 2. Product policy + +### 2.1 Deployment and SDK policy + +Hypercolor raises its macOS deployment target from 11.0 to 15.2. + +The exact 15.2 floor is deliberate. Sequoia 15.0 provides the ScreenCaptureKit +HDR stream presets and dynamic-range selection. Sequoia 15.2 adds stream active +and inactive callbacks, content-filter introspection, and the display-space +screenshot API. Those lifecycle callbacks remove guesswork from source health +and make 15.2 the clean minimum for the complete design. + +Every macOS artifact is built against the macOS 26 SDK. Runtime availability +checks protect Tahoe-only calls. No weak-linking maze preserves Big Sur through +Sonoma, and no compatibility helper keeps the old 11.0 product floor alive. + +The supported matrix is: + +| Host | Support level | Capture range | GPU path | +| -------------------------------------- | ----------------------------------- | ------------- | ----------------------------------------------------- | +| Apple Silicon, macOS 15.2 through 15.x | first class | SDR and HDR | IOSurface and Metal | +| Intel, macOS 15.2 through 15.x | first class | SDR | IOSurface and Metal | +| Apple Silicon, macOS 26+ | first class plus Tahoe capabilities | SDR and HDR | Metal, with benchmark-gated Metal 4 work when exposed | +| Intel, macOS 26+ | first class plus Tahoe capabilities | SDR | IOSurface and Metal | +| macOS 15.0 or 15.1 | unsupported | none | none | +| macOS 14 and earlier | unsupported | none | none | + +Apple documents ScreenCaptureKit HDR capture as Apple Silicon only. Intel +Sequoia remains a supported SDR target instead of silently receiving an +ineffective HDR configuration. + +### 2.2 Tahoe capability policy + +Tahoe support is a runtime capability set, not a separate backend: + +```rust +pub struct MacosTahoeCapabilities { + pub host_architecture: MacosArchitecture, + pub translated_process: bool, + pub content_tone_mapping_info: bool, + pub metal4: bool, +} + +pub struct MacosTahoeSelectionCapabilities { + pub source_id: MacosScreenSourceId, + pub capture_session_generation: u64, + pub hdr_capture: bool, + pub dual_range_screenshots: bool, +} + +pub enum MacosArchitecture { + AppleSilicon, + Intel, +} +``` + +The host record is stable for one process and active Metal device. +`host_architecture` describes native host hardware, not the executable slice. +An x86_64 process under Rosetta 2 reports `AppleSilicon` with +`translated_process: true`; a native process reports `false`. Resolution uses +the native host architecture and `sysctl.proc_translated`, then records the +running slice separately in diagnostics. The section 2.1 support rows use host +architecture, while storage selection and Metal 4 use active `MTLDevice` family +probes. The remaining booleans are runtime API and active-hardware probes, not +inferences from the OS major. `content_tone_mapping_info` requires the callable +Tahoe Core Graphics API. `metal4` is true only when the active `MTLDevice` +exposes every Metal 4 facility used by the prototype. + +Selection capabilities are `None` before a source is selected and until its +first complete frame confirms the configured and delivered dynamic range. The +record is then published with the exact source identity and capture-session +generation. `hdr_capture` describes that selected source and delivered stream. +`dual_range_screenshots` additionally requires the Tahoe screenshot API for the +same filter. Repick or stream replacement creates a new record; a record whose +source or session generation does not match the active stream is diagnostic +history only and cannot select behavior. + +Tahoe diagnostics resolve from the host and current selection records: + +- An HDR-capable selected source must supply paired SDR and HDR screenshots from + `SCScreenshotConfiguration`, plus `CGContentToneMappingInfo` reference output. +- An SDR-only Tahoe selection, including Intel, supplies one SDR screenshot with + `CGContentToneMappingInfo`. It reports HDR and paired range as unsupported and + never relabels an SDR image as HDR. +- A Tahoe host or selection missing an expected capability reports the failed + runtime probe as a platform defect. It does not silently select a weaker + diagnostic. + +Neither API replaces `SCStream` for continuous capture. The live path remains +ScreenCaptureKit streaming because Tahoe does not introduce a better continuous +acquisition primitive. + +Metal 4 evaluation is required on every active device whose runtime probe +exposes the required facilities. The evaluation builds a direct Metal 4 +capture-reduction prototype using command allocators and residency sets, then +compares it with the existing wgpu Metal path on the same fixtures and hardware. +Metal 4 is not an Intel Tahoe acceptance requirement when the active device does +not expose it. The Metal 4 path ships only when it preserves exact output parity +and improves a named production metric by at least 10 percent at p95. Qualifying +metrics are capture-to-publication latency, CPU time, GPU reduction time, or +retained bytes. An architecture fork that does not clear that bar buys +maintenance without capacity and does not ship. + +## 3. Verified baseline + +### 3.1 Host input today + +The daemon currently constructs `InteractionInput` on macOS. That bridge polls +`device_query` every 10 milliseconds and has confirmed contract gaps: + +- it reports no Input Monitoring authorization state; +- it has no physical key code or macOS keymap; +- it derives press and release edges from snapshots and loses native repeat; +- it publishes no pointer button or wheel events; +- it leaves pointer mode unset and normalized coordinates at zero; +- it captures keyboard and pointer state together when either consent toggle is + enabled; and +- it cannot report event-tap disable, session interruption, or revocation. + +The bridge is the last macOS consumer of `device_query`. This spec deletes the +dependency and the bridge after the native source passes parity. + +### 3.2 Screen capture today + +The shared capture vocabulary already contains: + +- `ScreenCaptureBackend::MacosScreenCaptureKit`; +- `PlatformGpuApi::Metal`; +- `ScreenPhysicalGpuDeviceIdentity::MetalRegistryId`; +- owner-backed opaque platform GPU surfaces; +- exact descriptor-keyed publication plans; +- source, topology, session, resource, and plan generations; +- byte and compute admission; +- explicit geometry, colorimetry, dynamic range, and cursor policy; and +- capture-source reselection hooks. + +macOS still resolves to `CapturePlatform::Unsupported`, and daemon startup +constructs no macOS screen source or native execution target. SparkleFlinger's +screen target preparer is currently wired only for Windows D3D11. + +The existing `hypercolor-macos-gpu-interop` crate proves the audited IOSurface +to Metal to wgpu import boundary for Servo frames on Apple-family devices. Its +current descriptor hardcodes `MTLStorageModeShared` in +`src/macos.rs::metal_texture_descriptor`, so Intel is not proven by the existing +path. Screen capture extends that crate through a feature, following the Windows +capture and GPU interop split, and W4 makes storage selection family-aware for +both importers. The implementation must not duplicate the importer in core. + +### 3.3 Packaging and privacy today + +The desktop app bundles `hypercolor-daemon` as an external sidecar. Native input +and capture sources currently open inside the daemon process. macOS Transparency, +Consent, and Control grants are attached to a signed code identity, so the final +owner of Input Monitoring and Screen Recording cannot be chosen from source +layout alone. + +The current app metadata also describes keyboard input with +`NSAppleEventsUsageDescription`. Apple Events permission controls automation of +other applications. It does not authorize `CGEventTap` listening. The key is +wrong unless Hypercolor separately sends Apple Events. + +The first implementation wave therefore proves TCC ownership in a signed +package before placing irreversible weight on either process topology. + +## 4. Goals and non-goals + +### 4.1 Goals + +The design delivers: + +1. Native, event-driven keyboard and pointer capture through a passive session + event tap. +2. Independent keyboard and pointer consent and event masks. +3. ScreenCaptureKit display, window, application, and multi-window selection + through Apple's system picker. +4. Exact native acquisition with descriptor-keyed derived publications. +5. A CPU-correct fallback and a zero-full-frame-copy IOSurface and Metal path. +6. SDR correctness on every supported Mac and HDR capture on supported Apple + Silicon. +7. Explicit TCC state, remediation, revocation, and source health. +8. Signed packaging, macOS pull-request CI, diagnostics, and physical + acceptance. +9. Tahoe dual-range diagnostics, content-aware tone mapping, and a measured + Metal 4 decision. + +### 4.2 Non-goals + +The first complete release does not: + +- capture system audio or microphone audio through ScreenCaptureKit; +- synthesize or inject keyboard or pointer events into macOS; +- claim per-device identity from `CGEventTap`; +- bypass the system content-sharing picker with a custom picker; +- capture the login window, lock screen, secure input, or another user session; +- support macOS 15.1 or earlier; +- serialize private `SCContentFilter` objects as restore tokens; +- expose raw screen frames or raw host events to network clients without the + existing consent and routing gates; or +- force Metal 4 into production without a measured win. + +Per-device keyboard and pointer identity would require an `IOHIDManager` path. +That is a separate product feature because it changes permissions, hotplug, +device identity, and event arbitration. The session source in this spec uses +the stable identity `macos:session`. + +## 5. Non-negotiable invariants + +1. Consent and demand remain separate. Permission can be granted while the + native tap or stream is closed. +2. A system prompt appears only after an explicit user action. Restored config, + daemon startup, and background effect demand may preflight but never prompt. +3. Keyboard and pointer capture honor independent booleans all the way to the + `CGEventMask`. Disabling one kind makes those events invisible to Hypercolor. +4. The event tap is listen-only. Hypercolor never suppresses, alters, or + reinjects a host event. +5. Capture acquisition preserves the selected source's native pixel ceiling. + Consumer extents remain exact independent branches. +6. No implementation adds a fixed resolution, FPS, refresh-rate, queue, or + architecture ceiling to hide a bottleneck. +7. Every width, height, stride, plane length, queue slot, and derived + publication is checked and admitted before allocation. Framework-owned + IOSurface pools use the two-phase reservation and reconciliation contract in + section 11.1 because ScreenCaptureKit chooses their exact allocation size. +8. A byte claim lives exactly as long as the backing memory or imported resource + it accounts for. Replacing a plan does not release pinned generations early. +9. The ScreenCaptureKit callback validates, retains, publishes latest value, and + returns. It performs no scaling, color conversion, reduction, encoding, or + blocking daemon work. +10. The render thread samples immutable latest-value state in constant time. It + never calls AppKit, Core Graphics permission APIs, or ScreenCaptureKit. +11. Source, topology, capture session, resource, and plan generations stay + distinct. A stale frame cannot enter a newer source or publication epoch. +12. Pixel geometry and color are explicit. Retina scale, content rect, screen + origin, pixel format, color space, transfer function, dynamic range, and + cursor composition never travel as assumptions. +13. HDR is converted through an explicit scene-referred working path and tone + mapped for LED output. Clipping extended values to `[0, 1]` is a defect. +14. One broken native source degrades that source. It does not crash the daemon + or roll back an unrelated input source. +15. Source teardown emits synthetic releases and clears held state before a new + generation can publish. +16. The packaged app sidecar, direct launchd daemon service installed by + `hypercolor service enable`, Homebrew service installed by + `brew services start hypercolor`, and terminal-launched standalone daemon + are separate TCC topologies. Diagnostics and remediation name the exact + owner; the UI never claims that granting one code identity grants another. + +## 6. Process topology and TCC canary + +### 6.1 Preferred topology + +The preferred topology keeps native sources in the daemon: + +```text +Hypercolor.app + -> supervises signed hypercolor-daemon sidecar + -> owns CGEventTap + -> owns SCStream + -> publishes input and retained IOSurfaces in process +``` + +This path has the smallest latency and simplest lifetime model. The canary must +prove that the sidecar's stable designated requirement receives durable TCC +grants across app relaunch, daemon restart, and signed application update. + +### 6.2 Canary matrix + +Wave 0 produces a minimal signed package using the production bundle identifier, +sidecar embedding, signing shape, hardened runtime, and release launch path. It +tests keyboard listening, pointer listening, picker presentation, and streaming +as four independently scored capabilities without landing production +integration. + +Every canary row and TCC persistence claim uses a Developer ID Application +signature with stable identifiers, timestamped hardened-runtime signatures, +and accepted Apple notarization. Ad-hoc builds may exercise pure fixtures and +native mechanics, but their changing code-directory hashes are explicitly out +of scope for grant persistence, update survival, designated-requirement checks, +and signed acceptance. + +The matrix covers: + +- a fresh TCC database; +- grant, deny, later grant, revoke while live, and grant after revocation; +- grant while the TCC-owning process remains live, with preflight and resource + creation checked before and after an owner restart; +- app launch, supervised daemon restart, full app relaunch, and signed update; +- direct launchd daemon installation, login start, service restart, and signed + binary update under the `tech.hyperbliss.hypercolor` label; +- Homebrew installation, `brew services` login start and restart, and signed + binary update under the `homebrew.mxcl.hypercolor` label; +- the packaged app and direct launchd service installed together in both enable + orders, with deterministic owner arbitration across repeated logins; +- the app, direct launchd service, and Homebrew service installed in every pair + and all together, with one selected owner across repeated logins; +- standalone daemon launch from the terminal; +- System Settings identity and displayed process name; +- system picker presentation and stream creation in the same process; +- keyboard, pointer, and screen capture enabled independently; and +- Apple Silicon and Intel on Sequoia 15.2 and Tahoe 26. + +Each row records the responsible audit token, bundle identifier, executable +path, code-signing designated requirement, prompt text, System Settings entry, +and resulting API state. + +Each capability keeps the preferred daemon topology only if: + +1. The packaged sidecar receives stable grants under a recognizable Hypercolor + identity. +2. Grants survive relaunch and a normally signed update. +3. Revocation is observable without process restart. +4. Any picker-created `SCContentFilter` remains in the process that owns its + `SCStream`; filters never cross an IPC boundary or become restore tokens. +5. Standalone behavior is explicit and does not poison the packaged grant. +6. The direct launchd service either receives stable grants under its own + designated requirement or delegates each protected capability to the + authenticated app broker. It never borrows Terminal or app authorization. +7. The Homebrew service receives stable grants only for capabilities its own + signed canary passes. It has no implicit app-broker delegation. A broker path + would require a distinct verified reverse-bootstrap service in the generated + Homebrew plist; until then, a failed Homebrew capability directs the user to + select the packaged app owner. + +The picker and stream criterion is a hard macOS constraint, not a canary +preference. If a headless sidecar cannot present the system picker, the app owns +both picker and stream. The daemon may still own keyboard and pointer taps when +their own rows pass. A screen failure never moves input ownership, and an input +failure never moves screen ownership. + +The canary is a hard architecture gate. Spec implementation may proceed on pure +types and fixtures while it runs, but each native capability's process owner is +not finalized until its evidence exists. + +At most one daemon topology may own protected capabilities in one user session. +The existing `SingleInstance` guard in `hypercolor-daemon/src/main.rs` remains +the final process arbiter. macOS augments it with a mode-0600 per-user owner +record next to the guard. The winning daemon records its owner variant, audit +token identity, executable path, designated-requirement hash, process ID, and +epoch. A losing app sidecar, direct launchd service, or terminal process writes +a typed `macos_daemon_owner_conflict` contender record instead of silently +succeeding. A launchd contender exits zero so its `KeepAlive` rule with +`SuccessfulExit = false` does not respawn it. A sidecar exits with the typed +nonzero owner-conflict code, which the app supervisor classifies as terminal and +never feeds into its watchdog restart loop. A terminal contender returns the +same nonzero code to its caller. + +The winning daemon starts the native record watch before constructing the input +graph, regardless of input or capture configuration. It publishes the active +owner and conflict on the daemon system-status surface, mirrors the conflict in +any constructed `SourcePlatformStatus`, and emits one ownership bus event. It +coalesces an identical active owner, active epoch, contender owner, executable, +and designated-requirement tuple until either ownership or contender identity +changes. Repeated identical writes cannot create another state transition or +bus event. The record is diagnostic only and cannot override the guard or +authorize a peer. + +The UI and CLI name the active owner and offer `choose_daemon_owner`, which +enables one autostart topology and disables every other installed daemon +autostart transactionally, including `brew services` when present. A login race +can affect startup order but never the selected owner, published state, or +remedy. + +The transaction coordinator is the surviving local app or CLI process, never +the daemon being replaced. It validates the selected launcher and builds a +versioned handover journal containing transaction ID, requested and prior owner, +prior autostart states, allowed rollback operations, phase, active and contender +epochs, and any pending standalone PID. The mode-0600 journal is a separate file +beside the owner record. Before the first mutation, the coordinator writes the +journal with atomic replacement, file `fsync`, and parent-directory `fsync`. +Every completed phase is persisted the same way. + +A dedicated, stable coordination lock file serializes both artifacts. Every +winning daemon, contender, coordinator, and recovery path takes its exclusive +lock for one owner-record or journal read-modify-write, releases it immediately +after the durable replacement, and reacquires it for the next write. No path +holds the lock across a transaction phase, process stop or start, guard wait, +supervisor operation, or incoming-daemon recovery. Locking the replaceable owner +record or journal inode is forbidden because atomic replacement would detach the +lock from later writers. + +For app-sidecar, direct-launchd, and Homebrew incumbents, the coordinator +disables nonselected autostarts, flushes and stops the outgoing daemon, waits at +most 10 seconds for the single-instance guard to release, then starts the +selected topology. Guard-release or startup timeout restores the previous +autostart configuration and prior owner from the durable journal. + +A terminal-launched incumbent has no supervisor or service manager and never +terminates itself. The coordinator returns the typed `stop_standalone_owner` +remedy with the authoritative active PID and asks the user to stop that terminal +process with Ctrl-C or `kill -TERM`. No autostart mutation occurs yet. The +coordinator waits through the guard's native notification for up to 60 seconds; +handover remains pending while the standalone owner is live, continues after +the guard frees, and returns the same pending remedy on timeout. The pending +intent remains in the journal, so the next local coordinator invocation resumes +it rather than asking the user to choose again. + +External-owner mode is a persisted app setting. When launchd or Homebrew is the +selected daemon owner, app startup suppresses sidecar creation and connects its +UI to the external daemon on `:9420`. An unavailable selected owner produces an +offline-owner state and never silently spawns the sidecar. Only a later +`choose_daemon_owner` selecting `AppSidecar`, or an explicit owner-preference +reset, clears external-owner mode. + +The incoming daemon emits `MacosDaemonOwnershipChanged` after it acquires the +guard and publishes its owner epoch. The app or CLI coordinator returns the +handover success or failure synchronously. WebSocket clients reconnect and read +`SystemStatus.macos_daemon_ownership` as the authoritative outcome. When +rollback restarts the prior owner, that daemon emits the restored ownership +event after reacquiring the guard. + +Recovery reads and advances the separate journal under the shared coordination +lock, releasing the lock before it executes the recovered operation. The next +app or CLI coordinator completes or reverses any nonterminal phase before +accepting a new choice. An incoming daemon also runs a pre-runtime recovery +phase before binding network sockets or constructing sources. It may only +execute the typed, path-free operations already present in the validated +journal. If it is the requested owner and holds the guard, it completes and +commits the handover. If it is the prior owner after rollback, it records +rollback completion. Any other owner leaves the journal pending and publishes +recovery-required status. No startup path accepts an arbitrary executable or +command from the record. + +### 6.3 Broker fallback + +If a capability fails its preferred-topology criteria, an app-bundled broker +owns only that capability while the daemon keeps all generic semantics. The +screen broker always owns picker and stream together: + +```text +tech.hyperbliss.hypercolor.capture-broker LaunchAgent + -> owns SCContentSharingPicker and its SCStream + -> optionally owns keyboard and/or pointer CGEventTap when their canary rows require it + -> accepts authenticated local XPC connections from the app and daemon + -> transfers plain input envelopes and IOSurface XPC objects + +hypercolor-daemon sidecar + -> validates broker epoch and sequence + -> imports IOSurface into the existing publication plan +``` + +The fallback is designed now so the canary can select it without a second +architecture exercise: + +- The broker protocol is versioned and contains no core or AppKit types. +- Hypercolor bundles + `Contents/Library/LaunchAgents/tech.hyperbliss.hypercolor.capture-broker.plist` + and registers it with `SMAppService.agent(plistName:)` only when the canary + selects broker ownership. The Aqua-session LaunchAgent runs the signed app + executable in broker mode and advertises the + `tech.hyperbliss.hypercolor.capture-broker` Mach service. +- The broker owns `NSXPCListener(machServiceName:)`. The app UI and daemon use + `NSXPCConnection(machServiceName:)`; no anonymous endpoint crosses a file + descriptor or command line. +- The listener accepts only the same user and Hypercolor's signed designated + requirement, checked from the connection audit token and Foundation's code + signing requirement support. +- A supervised sidecar receives a random session capability from the app over + an inherited descriptor after the app sends the same capability to the broker + over authenticated XPC. The daemon must prove it in its first broker message. +- A direct launchd daemon cannot inherit from the app. When broker delegation is + selected, its LaunchAgent declares the one-operation Mach service + `tech.hyperbliss.hypercolor.daemon-bootstrap`. The daemon owns an + `NSXPCListener` for that service. The broker connects through launchd, and + both peers verify same-user audit tokens and the exact opposite executable's + designated requirement. The broker generates a fresh random capability, + sends it over that mutually authenticated reverse connection, and binds it to + the daemon epoch. The daemon must present it on its first connection to the + broker. Successful proof closes the bootstrap listener for that epoch. + Daemon restart rotates the capability, and a stale daemon cannot reuse an + earlier proof. +- Broker start always runs the reverse bootstrap before opening protected + channels. Broker connection loss or broker epoch advance invalidates the old + capability and makes the daemon reopen its bootstrap listener without + changing daemon epoch. A restarted broker completes mutual verification, + supplies a new capability bound to its broker epoch and the existing daemon + epoch, and closes that listener only for the lifetime of the new broker + connection. The broker-only restart remedy therefore restores service without + restarting the daemon, while every in-flight message from the old broker + epoch remains fenced. +- Neither bootstrap puts a capability in arguments, environment variables, or + files. If the launchd daemon starts before the broker, protected sources stay + in `NeedsUserAction` until an authenticated broker completes the reverse + bootstrap. +- `IOSurfaceCreateXPCObject` transfers an owning reference without making the + surface globally discoverable. The daemon reconstructs it with + `IOSurfaceLookupFromXPCObject` and releases the XPC object after taking its own + retained reference. +- Every message carries broker epoch, capture session generation, sequence, and + exact descriptor. Reconnect advances the broker epoch and fences all old + messages. +- Input messages use a bounded ordered ring. Screen frames use keyed + latest-value replacement. Neither channel can grow without bound. +- Backpressure drops superseded screen frames. It never blocks the + ScreenCaptureKit callback or reorders discrete input events. +- Connection loss stops only the capabilities owned by the broker. It publishes + synthetic releases for a brokered input kind, invalidates brokered screen + freshness, and preserves healthy in-process capabilities. + +The broker exists only for capabilities whose signed canary proves it +necessary. W0 must prove that the registered LaunchAgent receives a recognizable +TCC identity and can present the picker in the active Aqua session. There is no +runtime option that lets two processes compete for the same capability. + +## 7. Permission and lifecycle model + +### 7.1 Protected resources + +| Capability | TCC service | Preflight and request | Metadata | +| ------------------------------------ | --------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Keyboard listening | Listen Event / Input Monitoring | `CGPreflightListenEventAccess`, `CGRequestListenEventAccess` | no Apple Events key | +| Pointer listening | none for passive mouse events | event-tap construction and health | none | +| Screen frames and source enumeration | Screen Capture / Screen Recording | ScreenCaptureKit access and system picker | `NSScreenCaptureUsageDescription` | +| Apple application automation | Apple Events | not used by this design | remove `NSAppleEventsUsageDescription` unless another feature proves use | + +Apple's ScreenCaptureKit framework overview explicitly directs macOS apps to +add `NSScreenCaptureUsageDescription` with the reason screen recording is +needed. Section 23 cites that requirement directly; the app metadata test is a +platform requirement, not an inferred prompt customization. + +Core Graphics may create a tap while silently clearing unauthorized keyboard +bits from its mask. Hypercolor therefore never infers keyboard authorization +from successful tap creation. Keyboard preflight, keyboard tap validation, and +pointer tap health are separate observations. + +`NSMicrophoneUsageDescription` remains because audio-reactive effects use the +microphone through the audio input stack. ScreenCaptureKit explicitly sets +system audio and microphone capture to false. + +Hypercolor's app, sidecar, standalone daemon, and broker are hardened-runtime +code but are not App-Sandboxed. Passive `CGEventTap` listening and the plain +launchd Mach service names in section 6 depend on that premise. Adding +`com.apple.security.app-sandbox` is an architecture change requiring a new input +and broker design, not a packaging hardening toggle. + +### 7.2 Lifecycle states + +The generic `SourceStatus` remains the external contract. Each macOS adapter +also owns a more precise internal state machine: + +```rust +pub enum MacosProtectedSourceState { + Disabled, + NeedsUserAction, + PermissionDenied, + NeedsProcessRestart, + NeedsSelection, + ReadyIdle, + Starting, + Live, + Interrupted, + Revoked, + Failed, +} +``` + +The macOS status payload publishes one state for keyboard, one for pointer, and +one for screen. Pointer uses the same vocabulary for lifecycle consistency but +never reports permission states. The generic combined interaction +`SourceStatus` is a deterministic rollup: a demanded live kind keeps the source +live, a demanded failed kind remains visible in per-kind details, and no kind's +authorization is inferred from another kind. + +Transitions follow these rules: + +- Enabling config performs a non-prompting preflight and publishes the result. +- An explicit UI or CLI `authorize` action may call the request API. +- A newly granted right that the active process cannot consume enters + `NeedsProcessRestart`; it never loops tap or stream creation. +- An explicit `pick source` action presents Apple's picker. +- Consumer demand starts a ready source but never triggers a prompt or picker. +- Zero demand closes the tap or stream and returns to `ReadyIdle`. +- Revocation while live stops publication immediately and enters `Revoked`. +- A transient ScreenCaptureKit interruption enters `Interrupted` and attempts a + bounded stateful restart only while demand remains active. +- A source that needs a new selection enters `NeedsSelection`; it does not fall + back to a different display silently. + +`NeedsProcessRestart` requires positive authorization evidence and a conflicting +resource result. Keyboard enters it only when `CGRequestListenEventAccess` or a +fresh `CGPreflightListenEventAccess` reports granted but a newly created +keyboard tap still lacks its requested key bits or fails with a permission +classification. Screen enters it only when the system picker has delivered a +filter or shareable-content enumeration succeeds, but a fresh stream fails with +a permission classification. A denied request with no positive evidence stays +`PermissionDenied`. W0 records these predicates before and after owner restart +on Sequoia and Tahoe so OS-specific behavior becomes a fixture, not folklore. + +The supervisor restart action is explicit and scoped to the TCC-owning process. +For an in-process sidecar capability, the app stops and relaunches only the +daemon after its current state is flushed. For an app-owned capability, the UI +offers a full app relaunch. For a direct launchd daemon owner, the UI and CLI +offer `hypercolor service restart`, which unloads and reloads only the +`tech.hyperbliss.hypercolor` user agent after state is flushed. A direct launchd +daemon delegated to the app broker restarts only that broker. For a Homebrew +service owner, the UI and CLI offer `brew services restart hypercolor`, which +targets only `homebrew.mxcl.hypercolor`. Terminal-launched standalone mode +reports the exact command-level remediation and does not terminate itself. + +If the canary cannot prove stable grants for the direct launchd daemon, service +mode may use a registered and authenticated app broker for protected sources. +When no qualifying broker is installed or active, the source publishes +`NeedsUserAction` with an `app_broker_required` remedy. It never prompts under +the launchd identity and then instructs the user to grant a different process. + +Retries are event-driven by permission changes, picker callbacks, topology +notifications, stream delegate callbacks, configuration changes, or explicit +user action. There is no browser polling loop and no background prompt loop. + +### 7.3 Source selection and persistence + +Apple's system picker is authoritative. The app enables only the modes the +request supports and excludes Hypercolor's own windows where appropriate. + +The `capture.source` grammar on macOS is: + +```text +auto +primary_display +display: +session_scoped +``` + +The display UUID is the canonical string produced from +`CGDisplayCreateUUIDFromDisplayID`; the numeric `CGDirectDisplayID` is a runtime +lookup value and is never persisted as identity. `auto` resolves through the +existing policy, while `primary_display` follows the current main display. A +missing persisted display UUID enters `NeedsSelection` rather than selecting a +different display. Window, application, and multi-window choices persist only +as `session_scoped` plus a redacted diagnostic label and enter +`NeedsSelection` after relaunch. + +The validator accepts only this grammar. The resolver owns display UUID lookup +and picker session state. Hypercolor does not archive `SCContentFilter`, +`SCWindow`, or private framework state. + +Picker cancellation preserves the current stream when repicking. Cancellation +with no current source leaves `NeedsSelection`. Picker failure publishes the +native error domain and code through structured remediation. + +## 8. Native host input + +### 8.1 Crate boundary + +The new `hypercolor-macos-input` crate owns: + +- Core Graphics permission functions; +- event-tap creation and teardown; +- the dedicated `CFRunLoop` thread; +- native event decoding; +- virtual desktop geometry snapshots; and +- native interruption and failure classification. + +The crate has `unsafe_code = "allow"`, denies undocumented unsafe blocks, and +uses macOS-only modules plus cross-platform stubs. Its public API exposes plain +Rust values and no Core Foundation pointers. Pure key mapping and event folding +compile and test on every host. + +`hypercolor-core` owns canonical held state, event ordering, recent-key policy, +motion aggregates, source generations, synthetic releases, and status mapping. +The dependency runs from core to the platform crate, never the reverse. + +### 8.2 Native event vocabulary + +```rust +pub struct MacosInputConfig { + pub keyboard: bool, + pub pointer: bool, + pub epoch: u64, + pub clock: Arc u64 + Send + Sync>, +} + +pub enum MacosInputEvent { + Key { + virtual_keycode: u16, + pressed: bool, + autorepeat: bool, + }, + ModifierFlags { + virtual_keycode: u16, + flags: MacosModifierFlags, + }, + Button { + button: MacosPointerButton, + pressed: bool, + }, + Motion { + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, + }, + Wheel { + fixed_delta_x: i64, + fixed_delta_y: i64, + unit: MacosScrollUnit, + phase: MacosScrollPhase, + momentum_phase: MacosScrollPhase, + }, + MediaKey { + nx_key_type: u16, + pressed: bool, + repeat: bool, + }, + StateGap { + reason: MacosInputGapReason, + }, +} + +pub struct MacosInputBatch<'a> { + pub epoch: u64, + pub at_ms: u64, + pub events: &'a [MacosInputEvent], + pub virtual_desktop: MacosVirtualDesktop, +} +``` + +The interop crate stamps the batch immediately before draining its bounded +queue by calling core's injected monotonic clock. The sink folds the whole +batch under one canonical interaction lock so held state and discrete edges +cannot describe different instants. + +### 8.3 Event tap + +The source creates separate keyboard and pointer session event taps with +`kCGEventTapOptionListenOnly` on one dedicated run-loop thread. Separate taps +are required because Core Graphics can silently remove unauthorized keyboard +bits from a combined mask while leaving pointer bits active. Each callback +performs only fixed-cost field reads and a non-blocking bounded enqueue. + +The keyboard mask includes: + +- key down; +- key up; and +- flags changed; and +- system-defined events required for media keys. + +The pointer mask includes: + +- moved and every dragged variant; +- left, right, and other button down and up; and +- scroll wheel. + +The two masks are constructed from the two config booleans. A keyboard-only +source creates no pointer tap. A pointer-only source creates no keyboard tap, +does not request Input Monitoring, and may enter `Live` while keyboard state is +`PermissionDenied`. + +The tap callback recognizes timeout and user-input disable notifications. It +publishes `StateGap`, clears canonical held state, reenables the tap once, and +records a counter. Repeated disable inside a rolling health window degrades the +source instead of spinning. Teardown signals the run loop, removes the source, +invalidates the tap, joins the worker, and only then advances the session +generation. + +### 8.4 Keyboard semantics + +`CGKeyCode` is treated as the physical location code for the active Apple +keyboard family. Logical characters and the active keyboard layout never drive +the canonical physical inventory. + +`keymap.rs` gains a macOS virtual-keycode column beside Linux evdev and Windows +scan codes. `MEDIA_KEYS` gains a macOS `NX_KEYTYPE_*` column beside Linux evdev +and Windows virtual-key codes. Total inventory tests prove that every canonical +physical and media key maps either to a macOS code or to an explicit unsupported +entry. Left and right modifiers remain distinct. + +macOS media keys arrive as `NX_SYSDEFINED` event type 14, subtype 8, with their +key type, press state, and repeat bit packed in the native data fields. The +decoder accepts only subtype 8, validates the packed fields, and routes the +result through the shared media inventory. Other system-defined events remain +counted diagnostics and never become guessed keys. + +Key down uses the native autorepeat field: + +- first down becomes `Pressed`; +- autorepeat down becomes `Repeated` without reentering held or recent state; +- key up becomes `Released`; and +- an impossible up or repeat is preserved as a diagnostic counter while + canonical state remains consistent. + +Modifier keys arrive through `flagsChanged`, whose event shape does not directly +name press or release. Core derives the edge from the specific key's mask and +its per-key held state, not from the aggregate flags alone. Caps Lock receives a +dedicated fixture because it is a locking modifier rather than an ordinary held +key. + +Secure input and secure desktop transitions may create missing edges. Any tap +disable, permission loss, session lock, worker exit, or source stop emits one +ordered `StateGap`, which synthesizes releases for every held key and button. + +### 8.5 Pointer semantics + +Core Graphics supplies global display-space coordinates. The backend snapshots +the union of active display bounds, including negative origins, and publishes: + +- raw signed global coordinates; +- normalized coordinates across the current virtual desktop; +- native deltas; +- accumulated distance; and +- velocity through the existing frame delta contract. + +Display reconfiguration advances a pointer-topology generation and resets the +motion baseline. The first event in a new topology establishes position without +manufacturing a large delta. + +Button numbers map into the canonical pointer vocabulary with left, right, +middle, and stable numbered extras. + +Scroll decoding reads `kCGScrollWheelEventIsContinuous`, both 16.16 fixed-point +axis fields, both point-delta fields, scroll phase, and momentum phase. The +fixed-point values are authoritative; point deltas are retained as diagnostic +cross-checks. A non-continuous event arrives as 16.16 notches. Core multiplies +that signed fixed-point value by 120 with checked arithmetic to produce Q16.16 +`Line120` units, where one integral unit is exactly 1/120 notch. Projecting to +`wheel_hi_res` divides by 65536 and carries the signed fractional remainder +across events. No slow wheel movement is lost. + +A continuous event has pixel units and never enters `wheel_hi_res`, because +macOS defines no universal pixels-per-notch conversion. The canonical input +vocabulary gains a two-axis `PointerScroll` event and `ScrollAggregate` with +explicit `Line120` or `Pixels` units, scroll phase, and momentum phase. Existing +effects keep their vertical `wheel_hi_res` compatibility signal for physical +wheel movement. New effects can consume exact horizontal, trackpad, phase, and +momentum data without a guessed scale. Coalescing adds only like units and +preserves phase boundaries. + +## 9. ScreenCaptureKit acquisition + +### 9.1 Crate boundary + +The new `hypercolor-macos-capture` crate owns: + +- ScreenCaptureKit classes, protocols, and delegate callbacks; +- Core Media and Core Video sample validation; +- retained `CVPixelBuffer` ownership; +- IOSurface extraction and identity; +- stream configuration and lifecycle; +- display and content-filter topology; +- screen permission classification; and +- pure cross-platform fixtures for metadata and state transitions. + +The crate follows the same audit posture as the other platform capture crates. +It exposes no Objective-C object in its public contract. The native owner is an +opaque `Arc` whose only safe operations are metadata inspection, CPU mapping, +and handoff to the macOS GPU interop crate. + +`hypercolor-macos-gpu-interop` first moves its existing Servo-only dependencies +and module behind a `servo-context` feature. It then gains an independent +`screen-capture` feature that depends on the capture crate and exposes a +core-agnostic `MacosScreenBridge`. The bridge imports and validates native Metal +resources but names no core trait or type. The capture crate never depends on +wgpu or core. + +`hypercolor-core`'s `servo-gpu-import` feature gains +`hypercolor-macos-gpu-interop?/servo-context`, mirroring its Linux and Windows +feature edges, so macOS Servo imports keep compiling after the split. + +The daemon owns a local `MacosScreenTargetPreparer` wrapper around that bridge +and implements core's `ScreenNativeTargetPreparer` for the wrapper. The +dependency edges are exact: + +- core depends unconditionally on the capture crate; +- macOS GPU interop stays optional in core and is enabled there only by + `servo-gpu-import`; +- the daemon's `screen-capture` feature depends on core and macOS GPU interop; +- the interop crate depends on capture only through its own `screen-capture` + feature; and +- no interop crate depends on core. + +### 9.2 Platform frame vocabulary + +```rust +pub struct MacosCaptureFrame { + pub epoch: u64, + pub sequence: u64, + pub display_time: u64, + pub storage_extent: MacosPixelExtent, + pub planes: Arc<[MacosCapturePlane]>, + pub pixel_format: MacosCapturePixelFormat, + pub color: MacosCaptureColorimetry, + pub geometry: MacosCaptureGeometry, + pub damage: Arc<[MacosPixelRect]>, + pub cursor_composed: bool, + pub surface: MacosCaptureSurface, +} + +pub struct MacosCapturePlane { + pub index: u32, + pub extent: MacosPixelExtent, + pub bytes_per_row: usize, + pub length_bytes: u64, +} + +pub struct MacosCaptureSurface { + pub iosurface_id: u32, + pub allocation_bytes: u64, + owner: Arc, +} + +pub enum MacosCapturePixelFormat { + Bgra8, + Argb2101010, + Rgba16Float, + Yuv420VideoRange, + Yuv420FullRange, + Yuv44410BiPlanar, +} +``` + +The actual retained type keeps the `CVPixelBuffer` alive. An IOSurface pointer +is derived only while that owner is live. Retaining only a borrowed pointer from +the callback is forbidden. + +The callback copies small attachment values into Rust storage and retains the +pixel buffer before returning. It never keeps the full `CMSampleBuffer` merely +for convenience. + +### 9.3 Stream configuration + +The system picker produces the content filter. Hypercolor then configures one +video-only stream: + +- `capturesAudio = false`; +- `captureMicrophone = false`; +- `captureResolution = Best`; +- width and height equal the selected source's resolved native pixel extent; +- `sourceRect` is expressed in content points and `destinationRect` is + expressed in output pixels; +- `preservesAspectRatio = true`; +- `scalesToFit = false` for native display capture; +- `minimumFrameInterval` reflects negotiated acquisition cadence, with zero + allowed when a native-refresh consumer explicitly requests it; +- `showsCursor` follows the resolved cursor policy; +- `showMouseClicks = false`; +- the stream name identifies Hypercolor; and +- queue depth is admitted as native in-flight memory. + +The native display ceiling is calculated with checked arithmetic as +`ceil(contentRect.width * pointPixelScale)` by +`ceil(contentRect.height * pointPixelScale)`. Window, application, and +multi-window selections use the same point-to-pixel rule over their resolved +content bounds. The configuration and every delivered frame validate scale, +content scale, source points, destination pixels, and resulting storage extent +as separate units. + +ScreenCaptureKit advises that queue depth should not exceed eight. Hypercolor +uses the framework's full default depth of eight and pre-admits its +conservative residency bound. It does not silently shrink the queue to fit a +machine. Failure to reserve the depth returns a typed resource error without +lowering extent or cadence. A future explicit queue control must remain visible +in configuration, status, and benchmark dimensions. + +The stream requests one native acquisition for the resolved source epoch. +Every exact logical branch resolves independently against that frame. An +ultrawide and a portrait branch never create a component-wise maximum surface. +Equal resolved physical work may share after equality is proven. + +### 9.4 Frame validation and metadata + +The sample callback accepts only screen output with: + +- a valid and ready `CMSampleBuffer`; +- a complete `SCFrameStatus`; +- a `CVPixelBuffer` image buffer; +- a supported pixel format; +- checked nonzero storage extent, plane count, plane extent, stride, and + length; +- an IOSurface-backed pixel buffer for the native path; and +- well-formed ScreenCaptureKit attachment dictionaries. + +The adapter maps these attachment keys into canonical metadata: + +- display time; +- display scale factor; +- content scale; +- content rect; +- dirty rects; +- screen rect; and +- bounding rect for multi-window content. + +ScreenCaptureKit rect attachments are in logical points. Storage and +destination extents are in pixels. The adapter uses the delivered scale-factor +and content-scale attachments to convert rects, applies outward rounding for +coverage, clips only after conversion, and rejects a frame whose converted +bounds exceed its plane storage. Dirty rect fixtures include fractional Retina +origins so a point value can never be mistaken for a pixel value. + +`Idle`, `Blank`, `Suspended`, `Started`, and `Stopped` frames update lifecycle +telemetry but do not masquerade as complete image data. A malformed present +attachment drops the frame with a per-reason counter. A documented optional +attachment may be absent and maps to an explicit unknown or full-frame value. + +The adapter derives: + +- stable source identity from the picker result and resolved display set; +- topology generation from content style, display membership, physical origin, + logical rect, scale, and native extent; +- capture session generation from each `SCStream` instance; +- resource generation from storage descriptor changes; and +- frame sequence from complete frames only. + +For window, application, and multi-window filters, the 15.2 active and inactive +delegate callbacks drive selected-content liveness. Inactive means every +selected window is closed or otherwise unavailable; active marks its return. A +display filter records these callbacks as telemetry only and stays `Live` +unless frame delivery, display topology, or `didStopWithError` proves a real +loss. `didStopWithError` classifies permission, source disappearance, +interruption, and backend failure into structured status. + +### 9.5 Cursor policy + +ScreenCaptureKit can compose or omit the cursor, but it does not provide the +clean separate cursor-shape contract used by Windows Desktop Duplication. + +The macOS source advertises `composed_or_hidden` cursor capability: + +- include policy sets `showsCursor = true` and marks the frame composed; +- exclude policy sets `showsCursor = false`; and +- a consumer requiring a clean separate cursor rejects the source as + incompatible. + +The pointer input stream is not used to reconstruct a screen cursor. Its timing, +shape, visibility, hotspot, and secure-input behavior are not equivalent. + +### 9.6 Topology and recovery + +Display changes, Spaces changes, window closure, application exit, sleep, +wake, and source repicking are control-plane events. + +The source follows these rules: + +- A display mode, scale, rotation, origin, or membership change advances + topology generation and transactionally replans exact branches. +- A storage format or stride change advances resource generation. +- A new stream advances capture session generation. +- Repicking preserves the old stream until the new filter, configuration, + admission, and first complete frame succeed. +- Window or application disappearance enters `NeedsSelection` after the 15.2 + inactive signal confirms no selected content remains. +- Sleep and session lock stop or suspend delivery, clear freshness, and resume + only after the protected session becomes active. +- Every callback checks epoch before publication, so a late frame from a stopped + stream is dropped. + +Recovery is bounded by state transitions and native notifications. Repeated +blind timer restart is forbidden. + +## 10. CPU correctness path + +The CPU path is both a bring-up oracle and a supported fallback when Metal +import is unavailable. + +The ScreenCaptureKit callback retains and replaces one bounded latest native +frame slot, then returns. A dedicated `hypercolor-macos-screen-capture` worker, +mirroring the Windows screen worker, takes the newest retained generation. The +worker locks the `CVPixelBuffer` read-only, validates every plane, stride, +extent, and length, and copies or converts only into already admitted exact +publication backing. It publishes one immutable latest CPU frame for the render +thread to latch in constant time. Superseded native frames drop without +conversion. The lock is never held across renderer work or consumer +publication. + +Stopping or replacing the source first closes the worker input, then joins the +worker, then retires any converted latest frame and its byte claim. A worker +completion carries source and capture-session generations, so conversion begun +for an old stream cannot publish into its replacement. + +Supported CPU inputs are: + +- `BGRA` BGRA8 SDR; +- `l10r` ARGB2101010 HDR; +- `RGhA` RGBA16Float HDR; +- `420v` two-plane video-range YUV 4:2:0; +- `420f` two-plane full-range YUV 4:2:0; and +- `xf44` two-plane 10-bit YUV 4:4:4. + +Each YUV frame carries the delivered matrix, range, transfer function, +primaries, and chroma siting. Missing metadata is an unsupported descriptor, +not permission to guess BT.709 or full range. The CPU oracle maps and converts +each plane independently before the shared linear color transform. + +The first screen milestone may ship BGRA8 SDR before later waves complete, but +the feature described by this spec is not complete until every listed format, +the native GPU path, and HDR acceptance pass. CPU fallback is not a reason to +lower capture resolution or FPS. It reports pressure and lets the existing +adaptive render policy choose work only through explicit product controls. + +CPU and GPU outputs use the same golden fixture suite. A platform path that +cannot match the canonical transform within the format's tolerance does not +become active. + +## 11. IOSurface and Metal path + +### 11.1 Ownership and admission + +The native frame owner retains the `CVPixelBuffer`, which retains the IOSurface +storage. `PlatformGpuSurface` retains that owner until every downstream +publication drops. + +The shared byte coordinator charges: + +- the full ScreenCaptureKit queue reservation and every observed queue surface; +- overlapping old and candidate stream generations; +- native import metadata and any normalization target; +- exact derived publication textures; and +- CPU fallback planes when they coexist with native storage. + +ScreenCaptureKit owns queue allocation and does not expose an IOSurface before +the first callback. Native queue memory therefore uses two-phase admission: + +1. Before stream start, Hypercolor reserves eight times a checked conservative + per-surface bound derived from native extent, requested format, plane layout, + and platform alignment, plus stream metadata. +2. The first complete frame reads `IOSurfaceGetAllocSize`, validates every + plane against that allocation, and atomically rebases the pool reservation + to eight times the observed allocation before retaining the frame. If the + coordinator cannot cover an increase, the callback drops the frame, the + control plane stops the stream, and the source enters + `macos_screen_resource_exhausted`. +3. Every later unique IOSurface repeats exact validation. A larger allocation + rebases the pool claim before retention. If the coordinator cannot cover the + increase, the callback drops the frame, the control plane stops the stream, + and the source enters `macos_screen_resource_exhausted`. +4. Metrics report reserved bytes, exact observed pool bytes, retained frame + bytes, and reservation variance separately. After all live pool slots are + observed, the exact pool claim must equal their summed allocation sizes. + +The operating system may allocate the first pool before Hypercolor can measure +it. The conservative reservation is the only exception to exact pre-allocation +admission. Hypercolor never retains or imports an over-budget surface, and a +candidate stream must reserve alongside every pinned old generation. + +All Hypercolor-owned claims are acquired before fallible allocation and retire +only when the actual backing owner drops. A stopped stream may still have +pinned frames, so stopping the source alone does not release their claims. + +### 11.2 Import and execution target + +SparkleFlinger's Metal-backed wgpu device registers a +`ScreenNativeExecutionTarget` with: + +- `PlatformGpuApi::Metal`; +- the `MTLDevice.registryID` as `MetalRegistryId`; +- the device's maximum 2D texture dimension; and +- a daemon-owned native target preparer wrapping `MacosScreenBridge`. + +The daemon-owned preparer calls `MacosScreenBridge`, which validates physical +GPU identity, IOSurface descriptor, pixel format, every plane, usage, and +allocation before creating one `MTLTexture` per plane with +`newTextureWithDescriptor:iosurface:plane:`. It wraps the Metal textures through +`wgpu-hal` and `create_texture_from_hal` on the same device. Packed RGB uses one +texture. Bi-planar YUV uses two textures and an explicit conversion kernel. + +Storage mode is not hardcoded or left at the descriptor default. The direct +IOSurface importer queries `MTLDevice.supportsFamily(MTLGPUFamilyApple1)`. An +Apple-family device requests `MTLStorageModeShared`; every non-Apple family +requests `MTLStorageModeManaged`, because shared texture storage is unavailable +for non-Apple-family textures. The bridge records the predicate, requested mode, +created texture's actual mode, and any rejection. + +The bridge has two native importer candidates. Apple-family devices try direct +`newTextureWithDescriptor:iosurface:plane:` first, then +`CVMetalTextureCacheCreateTextureFromImage`. Non-Apple devices try the Core +Video texture cache first, then the direct managed IOSurface importer. The Core +Video path consumes the retained `CVPixelBuffer`, creates one `CVMetalTexture` +per plane, retains each wrapper through GPU completion, and validates that each +resulting `MTLTexture` names the expected IOSurface, plane, format, and extent. +Both candidates must remain zero-copy and pass the same coherency oracle. A +candidate that returns nil, selects an incompatible mode, copies, or fails +parity is rejected before the next candidate runs. + +Apple-family textures are expected to use `MTLStorageModeShared`; Intel +discrete textures are expected to use `MTLStorageModeManaged`. If both importer +candidates fail, the source reports `macos_screen_metal_import_failed` with both +bounded native results. + +No `synchronizeResource` operation runs before GPU sampling. That operation +makes GPU writes visible to the CPU and is the wrong direction for a +ScreenCaptureKit-produced IOSurface. Import-side coherency relies on the +framework's complete-frame callback, retained `CVPixelBuffer` ownership through +command-buffer completion, and the driver contract exercised by the W4 probe. +The probe alternates incompatible byte patterns across every reused queue slot, +samples immediately and after sustained load, checks the imported texture's +IOSurface and plane identity, and compares GPU output with a locked CPU oracle. + +`synchronizeResource` appears only on managed GPU-to-CPU readback resources in +the parity fixture, after the GPU writes and before CPU mapping. Startup records +device family, actual storage mode, importer, probe result, and every mismatch. +If neither direct IOSurface import nor `CVMetalTextureCache` lets Intel sample a +ScreenCaptureKit pixel buffer coherently without a full-frame copy, Intel native +acceptance fails and the release is blocked until a coherent GPU mechanism +lands. A runtime CPU fallback may diagnose the failure, but it cannot satisfy +the first-class Intel claim or the zero-full-frame-copy contract. + +Imported textures are cached by the complete storage identity: + +```text +capture session generation ++ resource generation ++ IOSurface ID ++ plane ++ width and height ++ pixel format ++ storage mode ++ Metal registry ID +``` + +Frame sequence is content identity, not storage identity. Reusing an IOSurface +for a later frame reuses the wrapper while advancing content sequence. + +### 11.3 Synchronization + +ScreenCaptureKit delivers a complete `CVPixelBuffer` to the callback queue. +The initial native path treats callback delivery as producer completion, then +submits all Metal and wgpu work on the renderer's device queue without a CPU +readback. + +If live validation shows producer and consumer overlap on reused IOSurfaces, +the implementation adds an explicit synchronization primitive at the interop +boundary. It must not paper over a race with a per-frame CPU wait. Any added +primitive records wait time and storage identity so stalls are diagnosable. + +### 11.4 Native reduction + +The source IOSurface feeds the exact publication DAG: + +1. normalize geometry and source color once; +2. apply cursor policy exactly once; +3. share equal physical reduction descriptors; +4. derive exact surface and zone branches; and +5. publish immutable owner-backed textures. + +The steady-state native path performs no full-frame CPU copy. Damage metadata +may skip work only when the output algorithm proves incremental equivalence. +Absence of damage never changes output correctness. + +## 12. Color and HDR + +### 12.1 SDR + +SDR capture uses BGRA8 and explicit source color-space metadata. The pipeline +decodes the transfer function, converts into Hypercolor's linear working space, +performs spatial reduction there, applies temporal smoothing and color tuning, +then encodes for LED output. It does not treat every BGRA byte as sRGB merely +because the storage format is eight bit. + +### 12.2 HDR on Sequoia + +On supported Apple Silicon, Hypercolor starts from +`SCStreamConfigurationPresetCaptureHDRStreamCanonicalDisplay`. Canonical HDR is +the correct source because LED output is not the captured display. Hypercolor +reads the resulting configuration and first complete frame back, then records +the actual dynamic range, pixel format, color space, matrix, range, and chroma +siting. A preset is a requested configuration, not evidence that one exact +format arrived. + +`RGhA` half-float preserves extended linear values and maps directly to an +`Rgba16Float` GPU texture, so it is preferred when the resolved preset supplies +it. A machine or framework path that supplies `l10r`, `420v`, `420f`, or `xf44` +is identified exactly and routed through its packed or multi-plane conversion +kernel. Unsupported format does not fall through as BGRA. + +The shared capture vocabulary gains the pixel formats and color metadata needed +to represent these inputs without `Other` strings. All format ranking and +descriptor equality matches become exhaustive. + +### 12.3 LED tone mapping + +HDR reduction must preserve SDR contrast and roll highlights into the LED +device's available headroom. The working contract carries: + +- source reference white; +- source content headroom when available; +- transfer function and primaries; +- target LED white point, reference white, and calibrated peak; +- user exposure; and +- tone-mapping algorithm revision. + +`CaptureConfig` supplies the target and user inputs through five additive, +serde-defaulted fields: + +```rust +pub target_led_white_x: f32, // default 0.3127 +pub target_led_white_y: f32, // default 0.3290 +pub target_led_reference_white_nits: f32, // default 203.0 +pub target_led_peak_nits: f32, // default 406.0 +pub exposure_ev: f32, // default 0.0 +``` + +The default chromaticity is D65. Hypercolor's nominal calibration maps resolved +source reference white to a 203-nit target and reserves one full stop of output +headroom through a 406-nit peak. These values are tone-mapping coordinates, not +claims about unmeasured hardware. White-point components must be finite and +strictly inside the CIE xy chromaticity triangle: `x > 0`, `y > 0`, and +`x + y < 1`. Target reference white must be finite and within +`1.0..=5_000.0` nits. Peak luminance must be finite and within +`1.0..=10_000.0` nits and strictly greater than target reference white. +Exposure must be finite and within `-8.0..=8.0` EV. Invalid API or +configuration values are rejected rather than clamped. + +At zero exposure, the SDR path maps resolved source reference white to +normalized `1.0`, matching the existing Windows and Linux capture paths. The HDR +path maps resolved source reference white to +`target_led_reference_white_nits / target_led_peak_nits`. Its default normalized +value is `0.5`, and its highlight shoulder maps values above source reference +white into the remaining `0.5..=1.0` range. The target reference-white and peak +fields govern only the HDR shoulder. Measured device profiles may replace the +target white point, target reference white, and peak. The user's explicit +exposure remains authoritative. Source reference white, content headroom, +transfer function, and primaries continue to come from the resolved frame +metadata. The algorithm revision is an internal fixture and cache key. + +An SDR/HDR mode change begins at a frame boundary and interpolates the complete +old and new tone-mapping curves with a monotonic smoothstep over 250 ms. The +curve is applied per source sample in linear light before spatial reduction and +the shared temporal smoothers. A new mode change during that interval starts +from the current interpolated curve and restarts both the blend and its marker +for a full 250 ms from the new frame boundary. The marker is active exactly +while the current blend is active, including every restarted interval. + +The marker remains private transition state and does not enter +`ScreenPublicationMetadata` or any published payload. Each macOS frame threads +`suppress_scene_cut_bypass: bool` directly beside the existing history-reset +flag at both smoothing seams. `PreparedTemporalSmoother::stage` gains the +parameter beside `reset_history`; `downscale_frame` gains it beside +`reset_smoother` and forwards it into +`TemporalSmoother::stage_for_elapsed_grid`. The public `TemporalSmoother::apply`, +`apply_for_elapsed`, and `apply_for_elapsed_grid` wrappers keep their existing +signatures and forward `false` internally. The macOS source passes `true` +exactly while its current blend is active. Every Windows, Linux, and +non-transition caller passes `false`. + +When suppression is true, `PreparedTemporalSmoother` skips the +`scene_cut_detected` reset gate in `input/screen/smooth.rs`, and +`TemporalSmoother` skips its mean-difference scene-cut bypass in the same file. +Ordinary exponential smoothing still follows its configured policy, so it may +extend the visible settling time but cannot turn the deliberate curve blend +into a scene-cut snap. + +The transition never changes source reference white, infers scene brightness, +or feeds output luminance back into exposure, so it is deterministic curve +handover rather than auto exposure. At zero exposure after the interval, SDR +reference white is exactly `1.0` and default HDR reference white is exactly +`0.5`. + +The default algorithm is reference-white based. It preserves ordering and +contrast at and below source reference white within each dynamic range, rolls +HDR highlights smoothly, and applies gamut compression before device encoding. +Clipping, global normalization by the brightest pixel, and frame-to-frame +auto-exposure pumping are rejected. + +CPU and GPU implementations share vectors for SDR white, saturated primaries, +wide-gamut colors, diffuse HDR, specular peaks, gradients, and scene cuts. + +### 12.4 Tahoe diagnostics and calibration + +On an HDR-capable Tahoe selection, `SCScreenshotConfiguration` captures paired SDR +and HDR images from the same selected filter for a diagnostic parity report. On +an SDR-only Tahoe selection, it captures one SDR reference image and records HDR and +paired range as unsupported. Both reports compare reference white, gamut +conversion, and final zone colors. Only the paired report compares highlight +rolloff across ranges. + +Core Graphics snapshots use `CGContentToneMappingInfo` with +reference-white-based tone mapping, explicit preferred dynamic range, and +content average light level where known. That CPU result is a platform +reference, not the live GPU implementation. The live kernel remains explicit +and testable across platforms. + +## 13. Core and daemon integration + +### 13.1 Platform selection + +`CapturePlatform` gains `MacosScreenCaptureKit`. Config validation accepts it +only for a macOS build. The 15.2 floor is a build-time guarantee enforced by +`.cargo/config.toml`, Tauri's minimum system version, CI availability auditing, +and the finished Mach-O minimum OS check. The binary cannot launch on an older +host, so `hypercolor-types` gains no runtime OS-version dependency. + +Daemon startup constructs: + +- `MacosHostInput` when input is enabled and either native kind is allowed; +- `MacosScreenCaptureInput` when screen capture is configured; +- shared byte and compute capacity from the existing coordinators; and +- the Metal native execution target when SparkleFlinger runs on Metal. + +The old `InteractionInput` construction and macOS `device_query` dependency are +deleted in the same wave that makes native input the default. The removal +includes the workspace and core dependency entries, `input/interaction`, its +`input/mod.rs` export, daemon startup wiring, `interaction_input_tests.rs`, the +legacy case in `input_tests.rs`, stale backend labels in shared fixtures, and +the public backend list in `input/traits.rs`, plus the deleted lock-order entry +in `docs/design/32-lock-ordering.md`. The same lock-ordering edit adds +`MacosHostInput::shared` for the canonical batch fold and +`MacosScreenCaptureInput::latest_frame` for the bounded native and converted +latest-value handoff. Neither lock is held while acquiring `input_manager`, +calling native APIs, joining a worker, or running renderer work. There is no +hidden fallback to privacy-buggy polling. + +### 13.2 Live reconfiguration + +The existing input graph transaction owns config changes: + +- changing keyboard or pointer consent builds a candidate event mask and swaps + tap generation transactionally; +- changing source or cursor policy stages a candidate stream and exact plan; +- changing capture cadence updates `SCStreamConfiguration` when the source and + storage descriptor remain compatible; +- changing target LED white point, target reference white, calibrated peak, or + exposure validates a candidate tone-mapping configuration and atomically + swaps CPU constants and GPU uniforms at a frame boundary without reopening + the native stream; +- changing extent branches replans derived publications without reopening the + native stream unless native source geometry changes; and +- disabling a source stops its native worker after the replacement graph is + committed. + +Failure preserves the last known-good graph unless the previous permission or +source has become invalid. Invalidation clears freshness immediately. + +### 13.3 Status and metrics + +`hypercolor-core::input::status` owns the platform state structs so the adapters +can publish them without depending on daemon API types. The source status +surface publishes the state directly rather than asking clients to reconstruct +it from generic issues: + +```rust +pub enum MacosCapabilityOwner { + AppSidecar, + App, + LaunchdService, + HomebrewService, + Broker, + Standalone, +} + +pub struct MacosDaemonOwnerConflict { + pub active: MacosCapabilityOwner, + pub contender: MacosCapabilityOwner, + pub observed_at_ms: u64, +} + +pub struct MacosInputPlatformStatus { + pub keyboard: MacosProtectedSourceState, + pub pointer: MacosProtectedSourceState, + pub keyboard_tcc: MacosAuthorizationState, + pub keyboard_owner: MacosCapabilityOwner, + pub pointer_owner: MacosCapabilityOwner, + pub owner_conflict: Option>, +} + +pub struct MacosScreenPlatformStatus { + pub state: MacosProtectedSourceState, + pub tcc: MacosAuthorizationState, + pub owner: MacosCapabilityOwner, + pub selection: MacosSelectionState, + pub tahoe_selection: Option, + pub owner_conflict: Option>, +} + +pub enum SourcePlatformStatus { + MacosInput(MacosInputPlatformStatus), + MacosScreen(MacosScreenPlatformStatus), +} +``` + +`SourceStatus` gains `platform: Option>`. Every +constructor, writer update, and retired snapshot carries or clears `platform` +explicitly. The daemon's `api/system.rs::InputSourceStatus` +gains `platform: Option`, where the daemon-local +serde enum is tagged as `macos_input` or `macos_screen` and derives `ToSchema`. +`input_source_status` maps the core enum field by field, including +`tahoe_selection` on the daemon-local `macos_screen` variant and +`owner_conflict` on both macOS variants. This diagnostic payload stays +daemon-local, matching the existing system-status boundary; the web UI +deserializes a tolerant local subset. REST and OpenAPI fixtures cover both +variants, absence on other platforms, and unknown future fields. + +The owner arbiter is daemon state, not input-source state. `AppState` owns its +latest snapshot from startup even when no source exists, and +`api/system.rs::SystemStatus` gains: + +```rust +pub enum MacosCapabilityOwnerApi { + AppSidecar, + App, + LaunchdService, + HomebrewService, + Broker, + Standalone, +} + +pub struct MacosDaemonOwnerConflictApiStatus { + pub active: MacosCapabilityOwnerApi, + pub contender: MacosCapabilityOwnerApi, + pub observed_at_ms: u64, +} + +pub struct MacosDaemonOwnershipApiStatus { + pub active_owner: MacosCapabilityOwnerApi, + pub owner_epoch: u64, + pub conflict: Option, +} +``` + +`SystemStatus` adds +`macos_daemon_ownership: Option`. The field is +`None` off macOS and present from daemon startup on macOS. A +`HypercolorEvent::MacosDaemonOwnershipChanged` event carries the same bounded +snapshot over the existing events WebSocket channel. The daemon-local API enums +use snake-case serde names, derive `ToSchema`, and map the core owner and +conflict types field by field. The UI and CLI consume the +system field and event, so `choose_daemon_owner` remains reachable with input +and capture disabled. Per-source conflict fields are convenience mirrors only. +`protocol/websocket-v1.json` gains the +`macos_daemon_ownership_changed_v1` JSON payload contract on the `events` +channel with `"schema_version": 1`. Its event name is +`macos_daemon_ownership_changed`, its required fields are `active_owner` and +`owner_epoch`, and its optional `conflict` field defaults to `null`. +`crates/hypercolor-daemon/src/api/ws/tests.rs` loads the manifest and pins the +new entry's schema version, channel, event name, required fields, and optional +default beside the existing JSON payload conformance tests. REST, OpenAPI, bus, +manifest-generated WebSocket, and no-source startup fixtures cover the surface. +The OpenAPI change regenerates the vendored Python models for `SystemStatus` and +`InputSourceStatus`. Both new fields are additive and optional, and +`python-generate-check` and `python-ws-protocol-check` must return no diff after +regeneration. + +The source status surface also adds structured macOS fields: + +- TCC owner process and designated-requirement hash; +- native host architecture, executable slice, and Rosetta translation state; +- authorization state and last transition; +- selected content style and diagnostic label; +- stream active, inactive, or stopped state; +- source, topology, session, resource, and plan generations; +- pixel format, dynamic range, color space, scale, and native extent; +- queue depth, admitted native bytes, and pinned generations; +- frames received, published, superseded, malformed, stale, and dropped by + reason; +- event-tap timeout disables, user-input disables, reenables, and gaps; +- callback, retain, import, conversion, reduction, and publication timing; and +- CPU fallback or native path with exact fallback reason. + +High-cardinality labels such as IOSurface ID, window title, and application name +stay in bounded diagnostics rather than metrics labels. + +### 13.4 Shared scroll contract + +Two-axis scroll is a cross-platform contract, not a macOS-only event. The +shared vocabulary in `hypercolor-types::event` gains: + +```rust +pub enum PointerScrollUnit { + Line120, + Pixels, +} + +pub enum PointerScrollPhase { + None, + MayBegin, + Began, + Changed, + Stationary, + Ended, + Cancelled, +} + +InputEvent::PointerScroll { + source_id: String, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: PointerScrollUnit, + phase: PointerScrollPhase, + momentum_phase: PointerScrollPhase, +} + +pub struct ScrollAggregate { + pub line120_x_q16_16: i64, + pub line120_y_q16_16: i64, + pub pixel_x_q16_16: i64, + pub pixel_y_q16_16: i64, +} +``` + +Signed Q16.16 integers preserve fractional motion while keeping `InputEvent` +`Eq` and its JSON representation deterministic. `Line120` uses 1/120 notch as +its integral unit, while `Pixels` uses one pixel. `MouseWheel` remains +deserializable and published for compatibility through the next API major, but +W2 migrates every platform producer to `PointerScroll`; no platform producer +emits both for one native event. After canonical folding, core's +`LegacyWheelProjector` uses a per-source signed remainder accumulator and emits +one `MouseWheel` shadow with a fresh daemon sequence immediately after each +nonzero integral vertical `Line120` projection. The shadow never contributes to +held state or aggregates, so compatibility cannot double-count motion. +Core projects vertical line motion into `wheel_hi_res` with the remainder rule +from section 8.5. `InteractionBatch` gains `scroll: ScrollAggregate`, includes +all four totals in emptiness and every coalescing path, and saturates on +overflow. Phase and momentum remain on ordered events; event coalescing never +crosses their boundaries. + +The effect path changes end to end: + +- `LightScriptInputEventPayload` maps `PointerScroll` to `kind: "scroll"` with + floating `deltaX`, `deltaY`, `unit`, `phase`, and `momentumPhase` fields. Its + existing `MouseWheel` mapping continues to publish the core-generated legacy + `kind: "wheel"` shadow. +- `LightScriptMousePayload` adds a `scroll` object with `line120X`, `line120Y`, + `pixelX`, and `pixelY` aggregate fields while retaining `wheel` as vertical + motion in integral 1/120-notch units for existing effects. The value equals + `line120Y` projected through the section 8.5 signed-remainder rule. +- `sdk/packages/core` turns `MouseInputEvent` into a discriminated union with a + typed scroll member, retains the wheel member as deprecated through the next + API major, and exposes the new aggregates on `MouseInputState`. +- The WebSocket `input_events` envelope stays at schema 1 because + `TimedInputEventPayload.event` is intentionally opaque, retains unknown JSON, + and changes no envelope field. New tests prove an older schema-1 decoder + round-trips the unknown `pointer_scroll` kind and updated clients deserialize + it exactly. + +Every host follows one producer rule: + +- macOS multiplies non-continuous Q16.16 notch values by 120 into `Line120` and + emits pixel Q16.16 with native phase and momentum for continuous gestures; +- Linux maps each `REL_WHEEL_HI_RES` and `REL_HWHEEL_HI_RES` integer as + `value << 16` in `Line120`, using `(value * 120) << 16` for low-resolution + counterparts; +- Windows maps each signed `RI_MOUSE_WHEEL` and `RI_MOUSE_HWHEEL` delta as + `value << 16` in `Line120` inside `hypercolor-windows-input`, instead of + dropping horizontal wheel data; and +- browser injection accepts two-axis `Line120` or pixel Q16.16 values, maps its + legacy vertical `delta_hi_res` shape as `value << 16` in `Line120`, and uses + `None` phases when the sender supplies no lifecycle. + +The inbound `input_inject` wire adds this tagged edge beside the legacy edge: + +```rust +BrowserInputEdgeWire::Scroll { + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: PointerScrollUnitWire, + phase: PointerScrollPhaseWire, + momentum_phase: PointerScrollPhaseWire, +} +``` + +`unit` is required. Both phase fields default to `none` when absent. The daemon +defines `MAX_INPUT_SCROLL_Q16_16` as `MAX_INPUT_WHEEL_DELTA << 16`, validates +both axes with checked absolute-value arithmetic, and rejects values outside +that inclusive bound before conversion into a core edge. The UI's +`InputInjectEdge` mirrors the same tagged shape and enum spellings. The legacy +`Wheel { delta_hi_res: i32 }` variant remains accepted through the next API +major, retains its existing `MAX_INPUT_WHEEL_DELTA` validator, and maps to a +vertical `PointerScroll` as `delta_hi_res << 16` in `Line120` with `None` +phases. Integration tests deserialize both inbound shapes, prove their exact +canonical events and legacy projections, and serialize the UI mirror back to +the daemon contract. + +Pure parity fixtures assert sign, axis orientation, units, legacy projection, +serde shape, WebSocket round-trip, LightScript payloads, SDK parsing, and +coalescing for all four producer families. + +## 14. User experience and API + +The existing input settings page gains native macOS state and actions: + +- `Enable keyboard input`; +- `Enable pointer input`; +- `Authorize Input Monitoring`; +- `Enable screen capture`; +- `Authorize Screen Recording`; +- `Choose screen source` or `Change screen source`; +- `Enable app broker for service mode` when the launchd service cannot own the + protected capability; +- `Choose active daemon owner` when another installed topology holds the + single-instance guard; +- selected source and dynamic range; +- an advanced LED tone-mapping panel with D65 white-point coordinates, target + reference-white nits, calibrated peak nits, exposure EV, and + `Reset calibration`. Reset restores the two white-point coordinates, target + reference white, and peak to their defaults while preserving the user's + explicit exposure; +- active consumer count; and +- exact remediation with a deep link to the relevant System Settings pane. + +When the platform publishes `NeedsProcessRestart`, the UI offers `Restart +capture owner` and names the process that will restart. It never renders the +state as another permission denial. Keyboard, pointer, and screen cards read +their published platform states directly; they do not infer authorization, +selection, or ownership from generic status text. + +The UI distinguishes: + +- configured but not authorized; +- authorized and idle because no effect demands data; +- authorized but needing a source selection; +- direct launchd service awaiting installation, registration, or startup of the + authenticated app broker; +- another daemon topology active, with both active and attempted owners named; +- selected external daemon owner offline, with the selected owner and its local + start action named; +- granted but requiring a process restart; +- live; +- interrupted; +- revoked; and +- unsupported hardware capability such as Intel HDR. + +The REST control plane keeps source status read-only and exposes explicit action +endpoints for authorization and picker presentation. The existing capture pick +endpoint routes to the macOS system picker. WebSocket events announce state +changes so the UI never polls. + +`choose_daemon_owner` is never a daemon REST action. A browser-only session +receives `requires_app_ui` and cannot mutate autostart or stop a process. Inside +`Hypercolor.app`, the same UI invokes a native Tauri command implemented by the +local app coordinator. The CLI invokes the local coordinator directly. Both +paths consume the durable journal and never proxy the operation through the +daemon's network listener. + +The app-broker action registers the bundled broker through `SMAppService`, +waits for the reverse bootstrap, and retries only the requested protected +source. A Homebrew or CLI-only installation without `Hypercolor.app` prints the +typed `app_broker_required` remediation with the required app install and launch +action. It does not pretend the direct launchd service can self-install an app +broker. + +The CLI gains equivalent explicit commands where the process topology permits +them and prints which process owns the grant. A headless command that cannot +present the picker returns a typed `requires_app_ui` remediation instead of +attempting private UI. + +## 15. Security and privacy + +The macOS implementation is a privacy-sensitive subsystem and follows these +rules: + +1. Defaults remain off. +2. Prompts and picker presentation require explicit local user actions. +3. Raw frames and raw host events remain process-local unless an existing + consented consumer route explicitly exposes a derived form. +4. Logs never contain key names, typed text, window titles, application names, + raw pixels, or screenshot paths by default. +5. Diagnostics redact selected content labels unless the request is local and + authenticated under the existing daemon policy. +6. The system picker is mandatory for user-selected content. +7. Hypercolor excludes its own UI from display capture when ScreenCaptureKit + can express the exclusion without changing the selected source. +8. Lock, logout, fast-user-switch, secure input, and TCC revocation clear held + input state and invalidate screen freshness. +9. The broker fallback authenticates the peer by audit token and code signing, + not merely by filesystem permissions or claimed process ID. +10. The app ships `NSScreenCaptureUsageDescription` with direct language about + lighting effects. The unrelated Apple Events purpose string is removed. +11. Daemon-owner selection, process handover, and autostart mutation require the + local app or CLI coordinator. No REST, WebSocket, MCP, or other network + client can invoke them. Pre-runtime daemon recovery may execute only a + previously journaled typed operation and cannot create a new owner choice. + +## 16. Failure taxonomy + +Native errors map into stable codes rather than formatted strings: + +```text +macos_input_permission_denied +macos_input_permission_revoked +macos_input_process_restart_required +macos_input_tap_create_failed +macos_input_tap_disabled_timeout +macos_input_tap_disabled_user_input +macos_input_run_loop_exited +macos_screen_permission_denied +macos_screen_permission_revoked +macos_screen_process_restart_required +macos_screen_selection_required +macos_screen_picker_cancelled +macos_screen_picker_failed +macos_screen_source_inactive +macos_screen_source_disappeared +macos_screen_stream_stopped +macos_screen_frame_malformed +macos_screen_format_unsupported +macos_screen_iosurface_unavailable +macos_screen_resource_exhausted +macos_screen_gpu_identity_mismatch +macos_screen_metal_import_failed +macos_screen_hdr_unsupported +macos_broker_authentication_failed +macos_broker_disconnected +macos_daemon_owner_conflict +macos_daemon_owner_offline +``` + +User-action remedies use a separate stable vocabulary: + +```text +authorize_input_monitoring +authorize_screen_recording +restart_app_sidecar +restart_app +restart_launchd_service +restart_homebrew_service +restart_broker +restart_standalone +stop_standalone_owner +start_app_sidecar +start_launchd_service +start_homebrew_service +select_screen_source +requires_app_ui +app_broker_required +choose_daemon_owner +``` + +`app_broker_required` means the direct launchd service cannot own the requested +protected capability and no authenticated broker has completed reverse +bootstrap. `requires_app_ui` means the owner is valid but the next action, such +as presenting the system picker or registering the broker, must run in +`Hypercolor.app`. `restart_homebrew_service` invokes +`brew services restart hypercolor` for the recorded Homebrew owner. +`stop_standalone_owner` names the authoritative standalone PID and requires +user-directed Ctrl-C or `SIGTERM`; it never grants another process termination +authority. +`macos_daemon_owner_offline` means the persisted external owner is selected but +does not hold the guard. Its remedy is topology-specific: `start_app_sidecar` +invokes the app supervisor, `start_launchd_service` invokes +`hypercolor service start`, and `start_homebrew_service` invokes +`brew services start hypercolor`. A browser receives `requires_app_ui` for all +three actions. Only the local app or CLI coordinator executes them. +`choose_daemon_owner` means two or more installed autostart topologies contended +for the single-instance guard and requires one explicit owner choice. + +Every issue states whether retry is automatic, requires a user action, requires +source reselection, or is terminal for the current configuration. Raw native +domain and code are preserved as bounded diagnostic fields. + +## 17. Diagnostics and development tools + +The platform crates ship examples or CLI hooks that exercise production +boundaries without starting the full daemon: + +- `dump_macos_input` prints redacted event kinds, physical codes, pointer + geometry, generation, and health counters. It never prints logical text. +- `dump_macos_frame` captures a bounded frame count and prints descriptor, + attachments, color metadata, IOSurface allocation, and timing. +- `capture_macos_screenshot_reference` runs Tahoe paired SDR and HDR diagnostics + for an HDR-capable selected source and the single SDR reference diagnostic for + an SDR-only selected source. Before first-frame capability resolution, it + reports that source capability is pending and captures nothing. +- `probe_macos_tcc_owner` records the canary evidence for the current signed + process topology. +- `bench_macos_reduction` compares CPU, wgpu Metal, and qualifying Metal 4 + reduction with identical fixtures. + +Tools default to metadata only. Writing pixels requires an explicit output path +and prints the privacy implication before the write. + +## 18. Verification strategy + +### 18.1 Pure input tests + +Cross-platform tests cover: + +- total macOS physical key inventory; +- total macOS media-key inventory and subtype-8 decoding; +- left and right modifiers; +- Caps Lock transitions; +- native repeat classification; +- press, release, and impossible-edge behavior; +- independent keyboard and pointer masks; +- pointer normalization with negative display origins; +- topology changes and first-event baseline reset; +- pointer-only `Live` while Input Monitoring is denied; +- buttons, two-axis line wheel, continuous pixel scroll, scroll phase, and + momentum phase; +- signed 16.16 remainder accumulation where repeated sub-unit wheel events + produce the exact expected `wheel_hi_res` total; +- bounded queue overflow and ordered `StateGap`; +- timeout disable, user-input disable, revocation, stop, and synthetic releases; +- epoch fencing after restart; and +- source status mapping. + +### 18.2 Pure capture tests + +Fixture tests cover: + +- every complete and non-complete `SCFrameStatus`; +- missing, malformed, and valid attachments; +- checked extent, stride, plane, and allocation arithmetic; +- BGRA8, ARGB2101010, RGBA16Float, YUV420 video range, YUV420 full range, + YUV44410 bi-planar, and unsupported formats; +- content rect, display scale, content scale, negative screen origin, and + multi-window bounding rect; +- point-to-pixel conversion with fractional Retina origins and outward + rounding; +- dirty rect validation; +- cursor composed and hidden capability matching; +- source, topology, session, resource, and plan generation fencing; +- absent Tahoe selection capabilities before first frame, exact publication + after first frame, and stale selection capability rejection after repick; +- stale callback after stop or repick; +- transactional source replacement and picker cancellation; +- queue-depth reservation, first-frame exact rebase, larger-surface rejection, + reservation variance, and pinned old generation; +- display-filter inactive telemetry without a false liveness transition; +- window and application inactive liveness transitions; +- CPU and GPU color parity; +- SDR, HDR, wide gamut, tone mapping, and scene-cut vectors; +- D65 and measured white points, the nominal 203-nit reference white and + 406-nit peak, measured calibration, exact zero-exposure SDR reference white + at `1.0`, and exact default HDR reference white at `0.5`; +- specular-peak and rolloff vectors with peak strictly above target reference + white; +- deterministic 250 ms SDR/HDR curve handover measured at publication with + `smoothing = 1.0` and `exposure_ev = 0.0`, including a second mode change + during the first transition, no scene-cut bypass, and exact final values; +- Windows and Linux call sites always pass `suppress_scene_cut_bypass = false` + and preserve their existing scene-cut behavior in fixtures for both + `PreparedTemporalSmoother` and `TemporalSmoother`; +- exposure limits plus nonfinite, out-of-range, and invalid cross-field + calibration rejection; and +- calibration reset restoring all four target calibration fields while + preserving `exposure_ev`. + +### 18.3 Integration tests + +`hypercolor-core` gains a `macos-native-fixtures` feature. Behind it, +`MacosHostInput::new_deterministic_fixture(MacosInputFixtureBackend)` injects +preflight and request results, effective event masks, tap callbacks, and owner +restart results. `MacosScreenCaptureInput::new_deterministic_fixture( +MacosScreenFixtureBackend)` injects picker outcomes, authorization evidence, +stream callbacks, complete frames, and owner restart results. The fixture +backends implement the same narrow platform interfaces as production and never +call TCC, present UI, or require a display. + +Repository integration tests prove: + +- config accepts macOS capture and rejects it on other platforms; +- daemon startup wires native input and capture with exact consent; +- disabling pointer capture produces no pointer registration; +- denied keyboard permission does not prevent pointer-only liveness; +- zero demand owns no tap or stream; +- active demand opens once and idle demand closes once; +- app sidecar and direct launchd contenders cannot both win the single-instance + guard; the loser records `macos_daemon_owner_conflict`, and the winner + publishes both owner variants through system status and the ownership event + even when every input source is disabled. Direct and Homebrew launchd losers + exit zero under `KeepAlive.SuccessfulExit = false`, the sidecar loser returns + its non-restartable typed code, and repeated identical records yield one state + transition and one bus event; +- managed-owner handover stops the incumbent, waits at most 10 seconds, starts + the selected owner, persists or clears external-owner mode, and rolls back on + stop, guard-release, or startup failure; +- coordinator termination after each mutating phase leaves a durable journal; + the next local coordinator or incoming daemon pre-runtime recovery resumes or + reverses the exact transaction, preserves the last viable owner, and commits + every phase through atomic replacement plus file and parent-directory + `fsync`; +- winning-daemon, contender, coordinator, and recovery writes interleave under + the stable coordination lock without losing the owner record or separate + journal. Tests prove each lock hold covers exactly one read-modify-write and + no wait, supervisor operation, transaction phase, or atomic replacement can + strand a lock on an obsolete inode; +- malformed journals, unknown operation variants, and operations carrying a + path, executable, command, or argument vector are rejected without mutation; +- standalone-owner handover performs no autostart mutation, returns + `stop_standalone_owner`, proceeds after user-driven guard release, and remains + pending after its 60-second wait expires; +- the incoming or restored daemon publishes the ownership event, while the + surviving coordinator returns the synchronous result and reconnect reads the + matching system status; +- daemon-owner choice is absent from REST, OpenAPI, WebSocket, and MCP control + surfaces. Browser-only invocation returns `requires_app_ui`, while the native + app command and local CLI complete the same journaled choice; +- an unavailable persisted external owner publishes + `macos_daemon_owner_offline` with the matching `start_app_sidecar`, + `start_launchd_service`, or `start_homebrew_service` remedy and never starts a + different topology; +- revocation updates status and freshness without daemon restart; +- a grant requiring relaunch publishes `NeedsProcessRestart` and invokes only + the explicit supervisor action; +- exact descriptors remain independent; +- resolved Tahoe selection capabilities enter the core and daemon-local + `macos_screen` status with matching source and capture-session generations, + while preselection and stale generations publish `None`; +- Metal target matching uses registry ID and rejects a mismatch; +- imported packed and multi-plane ownership outlives the callback and releases + with the final publication; +- Apple-family shared and non-Apple managed storage probes produce CPU-oracle + byte parity with correct import-side coherency and readback synchronization; +- direct IOSurface and Core Video texture-cache candidates follow their + per-family order, preserve plane identity, and collapse dual failure into + `macos_screen_metal_import_failed`; +- the existing Servo IOSurface importer selects shared storage on Apple-family + devices and managed storage on non-Apple-family devices with parity on both; +- the CPU worker supersedes stale native frames, publishes only matching source + and session generations, and joins before claims retire; +- `PointerScroll` round-trips through serde and the schema-1 WebSocket envelope, + maps into LightScript, parses in the SDK, and preserves legacy `wheel`; +- browser injection accepts and validates both the legacy `wheel` edge and new + two-axis Q16.16 `scroll` edge, while the UI serializes the matching forms; +- screen and interaction WebSocket privacy gates remain unchanged; +- packaged, direct launchd, Homebrew, terminal, and broker restart remedies + target only the recorded TCC owner; +- the supervised-sidecar broker bootstrap rejects a missing inherited + capability, while the direct launchd reverse bootstrap mutually verifies + audit tokens and designated requirements, accepts no inherited descriptor, + rotates its capability on daemon or broker restart, rejects a stale epoch, + and recovers after the broker-only restart remedy without restarting the + daemon; and +- the app-side broker protocol, when selected by the canary, rejects an + unauthenticated peer and stale epoch. + +### 18.4 CI + +Every Rust-touching pull request gains jobs pinned to GitHub's `macos-26` Apple +Silicon image and `macos-26-intel` Intel image. Both set +`MACOSX_DEPLOYMENT_TARGET=15.2` for Cargo, build scripts, and the Tauri bundle. +The workflow pins one repository-declared Xcode 26 minor, prints +`xcodebuild -version` and `xcrun --show-sdk-version`, and fails before build if +the SDK major is not 26. + +The repository sets +`MACOSX_DEPLOYMENT_TARGET = { value = "15.2", force = true }` in the `[env]` +table of `.cargo/config.toml`, so an inherited shell value cannot lower the +floor, and sets Tauri's `bundle.macOS.minimumSystemVersion` to `15.2`. The +`build-native-app` macOS matrix uses `macos-26` and `macos-26-intel`; +`build-release` gains `macos-arm64` on `macos-26` and `macos-amd64` on +`macos-26-intel`, producing standalone artifacts for both first-class +architectures. Pull-request jobs build at least one final Mach-O executable per +architecture and inspect its minimum OS. Both release +lanes select the same declared Xcode version, enforce the SDK-major gate, +inherit the deployment target, and inspect every finished Mach-O minimum OS +before uploading an artifact. + +macOS release jobs reject `APPLE_SIGNING_IDENTITY = "-"` and any missing signing +secret. Every Mach-O receives an explicit architecture-independent `codesign -i` +identifier from a checked signing manifest. The required project-owned mapping +is: + +| Code object | Identifier | Entitlements | +| -------------------------------------- | ------------------------------------- | ------------------------------------------- | +| `Hypercolor.app` | `tech.hyperbliss.hypercolor` | `crates/hypercolor-app/entitlements.plist` | +| embedded `hypercolor-daemon-*` sidecar | `tech.hyperbliss.hypercolor.sidecar` | `packaging/macos/daemon.entitlements.plist` | +| standalone `hypercolor-daemon` | `tech.hyperbliss.hypercolor.daemon` | `packaging/macos/daemon.entitlements.plist` | +| standalone `hypercolor` | `tech.hyperbliss.hypercolor.cli` | none | +| standalone `hypercolor-app` | `tech.hyperbliss.hypercolor.app-host` | `crates/hypercolor-app/entitlements.plist` | +| standalone `hypercolor-tray` | `tech.hyperbliss.hypercolor.tray` | none | + +The sidecar and standalone daemon identifiers are intentionally distinct, so +packaged and direct launchd grants cannot satisfy each other's TCC checks. Intel +and Apple Silicon sidecar file names differ by target suffix but share the one +`.sidecar` identifier and designated requirement. The broker runs inside the +signed app executable and uses the app identifier. Bundled dylibs and any future +Mach-O must also have a stable manifest entry with an explicit entitlements file +or `none`; an unlisted object fails release. + +The daemon entitlement profile carries the six keys currently present in +`crates/hypercolor-app/entitlements.plist` forward verbatim. Audio input, JIT, +and unsigned executable memory are hardened-runtime capabilities needed by +microphone capture and Servo. USB, network client, and network server are +App-Sandbox resource keys; they do not gate those capabilities while Hypercolor +remains non-sandboxed and are not the basis for any access claim in this spec. +They stay in the profile to preserve current signed behavior. The sidecar and +standalone daemon both receive the exact profile. A missing or divergent profile +is a release failure. + +The release job Developer ID Application-signs every object with hardened +runtime, secure timestamps, and the expected team identifier. The app bundle is +signed from the inside out. No signing invocation may derive an identifier from +a file name. + +One repository script, `scripts/sign-macos-artifacts.sh`, is the signing actor +for CI and local release-ready builds. The order is exact: + +1. Stage the target-suffixed sidecar. +2. Sign that staged source with + `codesign -i tech.hyperbliss.hypercolor.sidecar` before `cargo tauri build`. +3. Run `cargo tauri build --bundles app` without treating Tauri's nested signing + pass as final. The combined `dmg,app` invocation is forbidden. +4. Discover every Mach-O inside the completed app, reapply its manifest + identifier and entitlements inside out, and sign `Hypercolor.app` last. +5. Submit the app for notarization, staple it, and validate the staple. +6. Run a separate DMG packaging command that consumes that exact signed and + stapled app, then sign, notarize, staple, and validate the DMG. +7. Sign the standalone artifact set from the same manifest and submit those + exact binary bits in a notarization ZIP. + +The accepted standalone receipt ships in release provenance even though the +tar container cannot carry a staple. Release verification runs only after the +post-bundle signing pass. It discovers every Mach-O in each artifact instead of +checking a fixed list, runs `codesign --verify --strict`, extracts and compares +its manifest identifier and designated requirement, runs `xcrun stapler +validate` on both the `.app` and DMG, normalizes and compares `codesign -d +--entitlements :-` output with the manifest profile, and requires accepted +notarization before upload. + +The Apple Silicon job runs: + +```text +cargo check for the workspace +clippy with warnings denied for changed shared and macOS crates +nextest for macOS platform fixtures, core input, and daemon integration +``` + +The Intel job compiles and runs pure SDR fixtures plus synthetic direct and +Core Video texture-cache import, storage-mode probing, queue-slot reuse, and +readback parity on every pull request. It begins by requiring +`MTLCreateSystemDefaultDevice` to return a non-Apple-family device that can +create the fixture IOSurface textures. A missing or nonconforming device fails +runner qualification; the native fixture never silently skips. Before this job +becomes required, an equivalent self-hosted Intel Tahoe runner replaces a +hosted label that cannot meet the precondition. + +Hosted or self-hosted pull-request results are regression evidence only. They +do not satisfy the section 11.2 Intel coherency and zero-copy release gate, +which requires the signed physical hardware matrix in section 18.5. TCC flows +and the 30-minute 4K60 SDR performance contract also run only in signed physical +acceptance. Before the workflow pin lands, a temporary `workflow_dispatch` +smoke job must run on both labels and record runner architecture, Xcode, SDK +major, Metal device name, registry ID, and family probes. If a label is +unavailable, never existed, loses the required SDK or Metal device, or later +ends, an equivalent required self-hosted runner must be online before the +affected support claim remains in a release. + +A separate availability check rejects unguarded Tahoe symbols in the Sequoia +artifact and inspects the built deployment target. A compile-only success does +not substitute for the native Intel import fixture. + +### 18.5 Signed physical acceptance + +Release acceptance uses the signed packaged app, not `cargo run` alone. + +The Apple Silicon matrix covers Sequoia 15.2 and current Tahoe with: + +- fresh grant, deny, later grant, revoke, and regrant; +- app launch, direct launchd daemon service, Homebrew service, and + terminal-launched standalone daemon; +- app and service autostart installed together, with explicit owner switching + and stable arbitration across login; +- keyboard-only, pointer-only, and both; +- modifiers, repeat, extra pointer buttons, trackpad phases, and high-resolution + scrolling; +- primary and secondary displays; +- negative origins, Retina and non-Retina mixes, rotation, and display hotplug; +- display, window, application, and multi-window picker modes; +- picker cancel and live repick; +- SDR display capture; +- HDR display capture and SDR/HDR transitions. The exact 250 ms smoothstep and + endpoints are measured at publication with `smoothing = 1.0` and + `exposure_ev = 0.0`; a second run with default smoothing proves scene-cut + bypass remains suppressed and no output step occurs at either boundary; +- Spaces, full-screen applications, minimized and closed windows; +- sleep, wake, lock, unlock, fast user switching, and logout; +- 30 Hz, 60 Hz, 120 Hz, and native-refresh demand where hardware supports it; +- 1080p, 4K, 5K, portrait, and ultrawide sources; and +- a four-hour combined input and HDR capture soak. + +The Intel matrix covers Sequoia 15.2 and current Tahoe with the same lifecycle +and SDR rows available on that hardware. It also requires native IOSurface byte +parity against the CPU oracle and the same 4K60 SDR duration, latency, and +memory contracts as Apple Silicon. The existing Servo IOSurface importer must +select managed storage on the Intel device and achieve exact CPU-oracle byte +parity under queue-slot reuse. Intel Tahoe runs the single SDR reference +diagnostic with tone-mapping metadata. HDR, paired range, and Metal 4 are +expected to report unsupported when the active hardware does not expose them, +not to emit SDR under an HDR label or fail a first-class SDR acceptance row. + +## 19. Performance contracts + +The feature is accepted only when each contract holds on every platform and +process topology named by that contract: + +1. Native 4K60 SDR capture sustains demand for 30 minutes without lowering + extent or cadence, unbounded memory growth, callback timeout, or stale-frame + accumulation. +2. Native 4K60 HDR capture meets the same contract on supported hardware. +3. Native 4K120 SDR capture sustains the same contract on hardware whose + selected display and ScreenCaptureKit path support 120 Hz. Native-refresh + demand is measured at the display's reported refresh without an internal + Hypercolor cap. +4. Intel native 4K60 SDR meets the same duration, latency, exact-byte, and + zero-full-frame-copy contracts as Apple Silicon SDR. +5. The native GPU path performs zero full-frame CPU copies in steady state. +6. ScreenCaptureKit callback work stays below 1 millisecond at p99 excluding + scheduler preemption. Retain and enqueue are reported separately. +7. The newest complete frame reaches the native publication stage within one + source frame interval at p95 and two intervals at p99. +8. This spec establishes a 1 millisecond p95 total input-stage budget with + screen, audio, and interaction active. Measurement starts immediately before + `InputManager::sample_all()` reads the first source and ends after the final + `InputData` snapshot is assembled for the frame. The screen measurement is + the constant-time latest-value latch only. CPU validation and conversion run + on the dedicated capture worker and are reported separately as + capture-to-converted-publication latency. +9. Host input callback entry to canonical event publication stays below 2 + milliseconds at p95 and 5 milliseconds at p99. +10. An active broker topology meets the same end-to-end screen and input + latency budgets as in-process ownership. Benchmarks also report XPC encode, + transit, decode, and IOSurface handoff separately so IPC cannot disappear + inside the total. +11. Steady-state retained bytes reconcile exactly with admitted native queue, + import, and publication claims. +12. Replanning or repicking may temporarily overlap old and candidate resources + only when the byte coordinator admits both generations. +13. CPU fallback reports its measured capacity and resource pressure. It never + rewrites a request to make a benchmark green. + +Benchmarks report source pixels, output pixels, bytes, dynamic range, queue +depth, display refresh, and publication branches. A single blessed 1080p number +cannot hide superlinear work. + +## 20. Implementation waves + +### W0: signed TCC canary + +1. Pull the W1 Developer ID signing prerequisite forward, then build the + production-shaped signed and notarized canary. +2. Run the full ownership matrix. +3. Record the preferred-daemon or app-broker decision with receipts. +4. Benchmark end-to-end and per-hop latency for every capability that requires + XPC. +5. Freeze each capability's process boundary before native session integration. + +Exit: every capability has a process topology that satisfies section 6, and +each designated requirement is documented. + +### W1: platform floor and shared vocabulary + +1. Raise the Tauri and distribution minimum to 15.2. Add + `depends_on macos: ">= :sequoia"` to + `packaging/homebrew/hypercolor.rb` and + `packaging/homebrew/hypercolor-app.rb`. The cask adds an exact 15.2 + `preflight` block. The formula defines a custom `Requirement` with a + `satisfy` block so Homebrew rejects 15.0 and 15.1 before download. Add a + numeric `sw_vers -productVersion` 15.2 floor check to + `scripts/get-hypercolor.sh` before download or launchd mutation. Every check + compares major, minor, and patch as integer components, never as a string or + floating-point number. Formula, cask, and shell tests cover 14.9, 15.0, 15.1, + 15.2, 15.10, 26.0, and 26.10. +2. Update public install docs and packaging design references. +3. Add `MacosScreenCaptureKit` platform selection. +4. Add packed RGB, HDR, bi-planar YUV, range, matrix, and chroma metadata. +5. Add the macOS physical keymap and shared fixture vocabulary. +6. Add workspace dependencies for the required objc2 framework crates. +7. Smoke-test the hosted macOS runner labels and SDKs. Provision the self-hosted + Intel or Apple Silicon replacement first if either label is unavailable, + then pin pull-request, `build-native-app`, and `build-release` runners and + Xcode with SDK-major and finished-artifact deployment-target audits. Add a + `macos-amd64` standalone release row on `macos-26-intel` beside the existing + `macos-arm64` row, matching the existing Linux `amd64` naming and + `get-hypercolor.sh` architecture mapping. Extend `.github/workflows/ci.yml`'s + fixed checksum and release-notes platform loop to `macos-amd64`. Give + `packaging/homebrew/hypercolor.rb` separate ARM and Intel macOS URLs, add + `SHA256_MACOS_AMD64`, and populate it in the workflow substitution. Admit + `macos-amd64` in `scripts/get-hypercolor.sh`, delete its source-only Intel + warning branch, and cover the matching signed artifact, installer, launchd + service, and terminal path in acceptance. Change the formula service from + `keep_alive true` to `keep_alive successful_exit: false`, assert the generated + `homebrew.mxcl.hypercolor` plist semantics, and cover its owner-conflict zero + exit without a respawn loop. +8. Set `MACOSX_DEPLOYMENT_TARGET = { value = "15.2", force = true }` in + `.cargo/config.toml` and set Tauri's minimum system version to 15.2. Build and + inspect one finished Mach-O per architecture in pull-request CI as well as + every release artifact. +9. Add `scripts/sign-macos-artifacts.sh` as the only release signing + orchestrator used by `.github/workflows/ci.yml` and + `scripts/build-mac-installer.sh`. Replace the workflow's ad-hoc + `APPLE_SIGNING_IDENTITY = "-"` release path with Developer ID Application + signing and notarization. Update `scripts/stage-app-bundle-assets.sh` to stage + the sidecar, then have the orchestrator pre-sign it with the explicit + `.sidecar` identifier before `cargo tauri build`. After the app build, the + orchestrator reapplies every manifest identifier inside out and signs the app + last before app notarization and DMG creation. Change the CI macOS bundle + matrix and `scripts/build-mac-installer.sh` default from `dmg,app` to `app`; + the orchestrator runs the separate DMG packaging step only after the app is + stapled. Update `scripts/dist.sh` to + hand every standalone Mach-O to the same manifest-driven actor with stable + identifiers, hardened runtime, and timestamps. Submit their exact bits for + notarization, and make + `scripts/verify-release-artifact.sh` reject missing signatures, mismatched + designated requirements, identifiers, team IDs, unlisted Mach-O files, or + notarization receipts. + Create `packaging/macos/daemon.entitlements.plist` with the exact six Boolean + keys carried by the current app profile: + `com.apple.security.cs.allow-jit`, + `com.apple.security.cs.allow-unsigned-executable-memory`, + `com.apple.security.device.audio-input`, + `com.apple.security.device.usb`, + `com.apple.security.network.client`, and + `com.apple.security.network.server`. + This signing slice is a prerequisite pulled forward before W0 executes. + +Exit: all pure types compile on every platform, Sequoia availability checks +pass, and public support claims agree. + +### W2: native host input + +1. Add `hypercolor-macos-input` with permission and event-tap fixtures. +2. Implement the run-loop worker and native decoder. +3. Fold events into the canonical interaction source. +4. Implement `PointerScroll` across shared serde, every host producer, + WebSocket round-trip, LightScript, browser injection, and the TypeScript SDK. + Correct the existing SDK comments at `sdk/packages/core/src/input/types.ts` + for event `delta` and state `wheel`: both values are integral 1/120-notch + units, not notches and not values divided by 120. +5. Wire independent consent, demand, status, deterministic fixture backends, + and live reconfiguration. +6. Delete the macOS `device_query` bridge, workspace dependency, core + dependency, exports, startup branch, tests, stale fixture labels, and the + obsolete lock-order entry. Add the macOS native input fold lock to the same + lock inventory. + Update spec 72 D9 and its W3 roll-up to record that the final macOS-only + dependency and bridge are gone. +7. Run signed keyboard and pointer acceptance. + +Exit: every input test and signed acceptance row passes with no polling fallback. + +### W3: CPU-correct ScreenCaptureKit source + +1. Add `hypercolor-macos-capture` and frame fixtures. +2. Implement permission preflight, picker callbacks, and source state. +3. Configure and run one native stream. +4. Validate and retain complete frames. +5. Implement BGRA8 CPU publication and exact branch integration. +6. Wire status, API actions, UI remediation, and diagnostics. + +Exit: signed SDR capture is correct across topology, lifecycle, picker, and +permission acceptance. The CPU path matches golden fixtures. + +### W4: IOSurface and Metal publication + +1. Split current Servo dependencies behind the `servo-context` feature and add + the matching macOS edge to core's `servo-gpu-import` feature. + Replace the Servo importer's hardcoded shared storage descriptor with the + same Apple-family shared and non-Apple-family managed predicate, coherency + probe, and readback parity required for screen capture. +2. Add the independent `screen-capture` bridge feature to macOS GPU interop. +3. Define the daemon-owned core trait wrapper and register the Metal target. +4. Import every retained IOSurface plane into wgpu. +5. Implement Apple-family detection, direct IOSurface and Core Video + texture-cache candidates, import coherency and readback probes, wrapper + caching, and two-phase pool admission. +6. Add the macOS capture latest-frame lock to the lock inventory, then run + native reduction, Servo import, and CPU/GPU parity on Apple Silicon and Intel. +7. Prove the steady-state zero-full-frame-copy contract. + +Exit: native SDR capture is the default path and passes the 4K60 soak. + +### W5: HDR and Tahoe capabilities + +1. Implement canonical HDR stream configuration. +2. Add RGBA16Float, ARGB2101010, YUV420 video/full-range, YUV44410, and all + required packed and multi-plane conversion kernels. +3. Implement reference-white-based LED tone mapping. Add the serde-defaulted + `target_led_white_x`, `target_led_white_y`, + `target_led_reference_white_nits`, `target_led_peak_nits`, and `exposure_ev` + fields to `CaptureConfig`, their exact defaults and cross-field validation, + the frame-boundary live-reconfiguration path from section 13.2, and the + advanced controls and reset scope from section 14. Keep CPU constants, GPU + uniforms, golden vectors, and the algorithm revision in one parity contract. + Thread `suppress_scene_cut_bypass` from the private macOS transition state to + `PreparedTemporalSmoother::stage` beside `reset_history` and to + `downscale_frame` beside `reset_smoother`, forwarding the latter into + `TemporalSmoother::stage_for_elapsed_grid`. Keep the public `apply`, + `apply_for_elapsed`, and `apply_for_elapsed_grid` signatures unchanged and + have them forward `false`. Pass `true` only for the complete current macOS + blend and `false` from every Windows, Linux, and non-transition caller. +4. Add paired SDR/HDR screenshots for HDR-capable Tahoe selections, single SDR + reference screenshots for SDR-only Tahoe selections, and Core Graphics + reference output for both. +5. Build and benchmark the Metal 4 reduction prototype on active devices that + expose its required facilities. +6. Adopt or reject Metal 4 using the section 2.2 gate, with artifacts. + +Exit: Apple Silicon HDR acceptance and 4K60 soak pass. Tahoe paired-range +diagnostics ship for HDR-capable selections, and the SDR reference diagnostic +ships for SDR-only selections. Each qualifying active device has a measured +Metal 4 decision. + +### W6: packaging, diagnostics, and release hardening + +1. Finalize purpose strings and remove the incorrect Apple Events string. +2. Invert `hypercolor-app/tests/config_tests.rs` to require the screen-capture + purpose string and forbid the Apple Events string, then update spec 67's + packaging inventory. The same tests parse + `packaging/macos/daemon.entitlements.plist` and assert its exact six-key + profile against the manifest contract in section 18.4. +3. Ship each selected TCC topology and only its required broker capabilities. +4. When direct launchd broker delegation is selected, add the + `tech.hyperbliss.hypercolor.daemon-bootstrap` `MachServices` entry to + `packaging/launchd/tech.hyperbliss.hypercolor.plist`. Update + `scripts/verify-release-artifact.sh` to reject a delegated-service artifact + whose packaged launchd plist template lacks that exact service or exposes it + when delegation is not shipped. Packaging tests also pin the existing + `KeepAlive.SuccessfulExit = false` rule and the launchd owner-conflict zero + exit that prevents a three-second respawn loop. +5. Implement the per-user daemon-owner record, native watch, typed conflict + publication through daemon system status and the ownership bus event, + per-source convenience mirrors, identical-conflict coalescing, and + `choose_daemon_owner` transaction across Tauri app autostart and the CLI + launchd service plus `brew services`. Start the watch before source + construction. Implement the separate durable, versioned handover journal + with atomic replacement, file and parent-directory `fsync`, typed path-free + operations, one stable coordination lock shared by every owner-record and + journal writer, single-read-modify-write lock scope, and crash recovery from + every mutating phase. + Implement the bounded flush, stop, guard-release, selected-owner startup, + synchronous result, and rollback sequence in the surviving local app or CLI + coordinator. Keep owner selection unreachable from REST, WebSocket, MCP, and + every other network surface. Persist external-owner mode, suppress sidecar + startup while it is active, publish the offline-owner status with the + topology-specific local start remedy, implement the pending standalone-stop + remedy without remote termination, and teach the app supervisor that its + typed sidecar owner-conflict exit is non-restartable. +6. Complete CLI, UI, metrics, and diagnostic tools. +7. Regenerate the vendored Python client after the additive optional + `SystemStatus.macos_daemon_ownership` and `InputSourceStatus.platform` + fields land. Add `macos_daemon_ownership_changed_v1` to + `protocol/websocket-v1.json`, regenerate its Python protocol constants, and + require both `python-generate-check` and `python-ws-protocol-check` to return + no diff. +8. Run Apple Silicon and Intel signed acceptance. +9. Run the four-hour combined soak and memory reconciliation. +10. Update compatibility and installation documentation. +11. Update the canonical `AGENTS.md` file, also read through its `CLAUDE.md` + symlink, with both new crates in the crate list and dependency graph and both + audited unsafe opt-outs in the conventions inventory. +12. Update specs 14, 57, 71, and 72 to link to this spec as the macOS authority. + In spec 57, revise the implemented status at line 3 to record that the + hardcoded shared-storage Servo importer was Apple-Silicon-only, then mark its + Intel parity precondition at lines 355-357 discharged only after W4's + family-aware storage selection passes this spec's signed Intel acceptance. + In spec 72, revise D9 at line 893 and the W3 roll-up at line 1028 after the + final `device_query` bridge and dependency are deleted. + +Exit: every section 21 criterion is satisfied. + +## 21. Completion criteria + +The macOS feature is complete when: + +- the product floor is 15.2 everywhere users or packaging can observe it; +- every released macOS code object has its stable Developer ID identifier, + hardened-runtime signature, designated requirement, and accepted notarization; +- the signed TCC owner is proven and stable; +- the single-instance arbiter exposes exactly one active daemon owner and a + typed conflict for every losing installed topology; +- keyboard and pointer input are native, event-driven, independently gated, and + free of `device_query`; +- screen capture uses Apple's system picker and complete lifecycle state; +- CPU and Metal outputs match canonical fixtures; +- the Servo importer selects family-correct storage and passes signed Intel + CPU-oracle byte parity under IOSurface reuse; +- native SDR passes every supported Mac row; +- native HDR and tone mapping pass Apple Silicon rows; +- Tahoe paired-range diagnostics ship for HDR-capable selections, and the SDR + reference diagnostic ships for SDR-only Tahoe selections; +- every active device exposing the required Metal 4 facilities has benchmark + artifacts and a recorded adoption decision; +- no stale generation, pinned allocation, or held input survives teardown; +- pull-request CI covers macOS compilation, lint, and platform fixtures; +- signed physical acceptance and performance contracts pass; and +- specs 14, 57, 71, and 72 link to this spec as the implemented macOS authority; + spec 57 records the family-aware importer and discharged signed Intel parity + precondition, while spec 72 D9 and its W3 roll-up record full `device_query` + retirement. + +## 22. Rejected alternatives + +### Keep `device_query` + +Rejected because polling loses native event fidelity, cannot expose TCC state, +and violates independent keyboard and pointer consent. + +### Use Accessibility permission for input listening + +Rejected because passive listening belongs to Input Monitoring. Accessibility +would grant a broader capability Hypercolor does not need. + +### Use `IOHIDManager` for the first release + +Rejected because per-device identity is outside the current product contract and +would expand hotplug, permission, and key translation work. `CGEventTap` matches +the requested session-level input semantics. + +### Build a custom source picker + +Rejected because Apple's system picker is the privacy and platform integration +contract on supported macOS versions. + +### Capture only a pre-scaled 640x480 or 1080p surface + +Rejected because it permanently destroys source fidelity and violates exact +descriptor and arbitrary-resolution contracts. + +### Start with CPU capture as the permanent macOS path + +Rejected because a full-frame readback and upload cannot meet the intended +native-resolution, high-refresh product ceiling. CPU capture remains an oracle +and fallback. + +### Force every Tahoe system onto a separate Metal 4 renderer + +Rejected because API novelty alone does not pay for a second command stack. The +required prototype and 10 percent gate turn Tahoe capability into measured +capacity rather than branding. + +## 23. Primary sources + +- [ScreenCaptureKit framework and required screen-capture purpose string](https://developer.apple.com/documentation/screencapturekit) +- [Capturing screen content in macOS](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos) +- [System content-sharing picker](https://developer.apple.com/documentation/screencapturekit/sccontentsharingpicker) +- [WWDC23: What's new in ScreenCaptureKit](https://developer.apple.com/videos/play/wwdc2023/10136/) +- [WWDC24: Capture HDR content with ScreenCaptureKit](https://developer.apple.com/videos/play/wwdc2024/10088/) +- [CGPreflightListenEventAccess](https://developer.apple.com/documentation/coregraphics/cgpreflightlisteneventaccess%28%29) +- [CGRequestListenEventAccess](https://developer.apple.com/documentation/coregraphics/cgrequestlisteneventaccess%28%29) +- [CGEventTapCreate](https://developer.apple.com/documentation/coregraphics/cgevent/tapcreate%28tap%3Aplace%3Aoptions%3Aeventsofinterest%3Acallback%3Auserinfo%3A%29) +- [IOSurface](https://developer.apple.com/documentation/iosurface) +- [IOSurfaceCreateXPCObject](https://developer.apple.com/documentation/iosurface/iosurfacecreatexpcobject%28_%3A%29) +- [Metal shared storage](https://developer.apple.com/documentation/metal/mtlstoragemode/shared) +- [Metal managed storage](https://developer.apple.com/documentation/metal/mtlstoragemode/managed) +- [Setting Metal resource storage modes](https://developer.apple.com/documentation/metal/setting-resource-storage-modes) +- [MTLDevice supportsFamily](https://developer.apple.com/documentation/metal/mtldevice/supportsfamily%28_%3A%29) +- [CVMetalTextureCacheCreateTextureFromImage](https://developer.apple.com/documentation/corevideo/1479231-cvmetaltexturecachecreatetexture) +- [NSXPCListener Mach services](https://developer.apple.com/documentation/foundation/nsxpclistener/init%28machservicename%3A%29) +- [SMAppService](https://developer.apple.com/documentation/servicemanagement/smappservice) +- [Hardened Runtime](https://developer.apple.com/documentation/security/hardened_runtime) +- [Notarizing macOS software before distribution](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) +- [Audio Input Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.device.audio-input) +- [Allow JIT-compiled code entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-jit) +- [Allow unsigned executable memory entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory) +- [GitHub-hosted macOS runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [Resetting access to protected resources](https://developer.apple.com/documentation/xcode/resetting-access-to-protected-resources-in-macos) +- [NSAppleEventsUsageDescription](https://developer.apple.com/documentation/bundleresources/information-property-list/nsappleeventsusagedescription) + +Local API availability and exact constants were verified against the installed +macOS 26.5 SDK headers for ScreenCaptureKit, Core Graphics, IOSurface, and Metal. + +## 24. Review history + +| Round | Reviewer | Verdict | Findings | Resolution | +| ----- | ----------- | ------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Claude Opus | NEEDS_CHANGES | 2 blocker, 8 high, 10 medium | All 20 adjudicated in revision 2; architecture, lifecycle, fidelity, resource, Intel, CI, and cleanup contracts revised | +| 2 | Claude Opus | NEEDS_CHANGES | 2 high, 7 medium, 5 low | All 14 adjudicated in revision 3; Intel coherency, shared scroll, status carriage, fixtures, release lanes, broker bootstrap, and cleanup revised | +| 3 | Claude Opus | NEEDS_CHANGES | 5 medium, 3 low | All 8 adjudicated in revision 4; storage selection, Core Video import, lossless scroll units, legacy events, runner availability, and cleanup revised | +| 4 | Claude Opus | NEEDS_CHANGES | 2 medium, 3 low | All 5 adjudicated in revision 5; wheel units, Tahoe architecture capability, dependency edges, inbound injection, and spec 72 cross-links revised | +| 5 | Claude Opus | NEEDS_CHANGES | 4 medium, 5 low | All 9 adjudicated in revision 6; Servo storage, CPU execution, Tahoe selection scope, launchd ownership, broker namespace, CI qualification, lock inventory, deployment floor, and crate inventory revised | +| 6 | Claude Opus | NEEDS_CHANGES | 1 medium, 4 low | All 5 adjudicated in revision 7; launchd broker bootstrap, owner and remedy enums, Intel Servo acceptance, and Rosetta host detection revised | +| 7 | Claude Opus | NEEDS_CHANGES | 2 medium, 3 low | All 5 adjudicated in revision 8; broker restart recovery, Developer ID release signing, daemon-owner arbitration, launchd plist packaging, and Tahoe status publication revised | +| 8 | Claude Opus | NEEDS_CHANGES | 2 medium, 3 low | All 5 adjudicated in revision 9; launchd conflict exits, sidecar identity, exhaustive Mach-O signing, owner-arbitration implementation, and packaged-plist verification revised | +| 9 | Claude Opus | NEEDS_CHANGES | 2 medium, 2 low | All 4 adjudicated in revision 10; deterministic post-Tauri signing, source-independent owner status, app stapling, and local installer parity revised | +| 10 | Claude Opus | NEEDS_CHANGES | 3 medium, 1 low | All 4 adjudicated in revision 11; per-object entitlements, split app and DMG bundling, Intel standalone artifacts, and Python client regeneration revised | +| 11 | Claude Opus | NEEDS_CHANGES | 1 medium, 2 low | All 3 adjudicated in revision 12; Intel artifact consumers, daemon entitlement creation, and non-sandbox entitlement semantics revised | +| 12 | Claude Opus | NEEDS_CHANGES | 2 medium | Both findings adjudicated in revision 13; Homebrew service ownership and pre-install macOS floor enforcement revised | +| 13 | Claude Opus | NEEDS_CHANGES | 1 medium, 2 low | All 3 adjudicated in revision 14; live owner handover, Homebrew requirement mechanics, and component-wise version tests revised | +| 14 | Claude Opus | NEEDS_CHANGES | 2 medium, 2 low | All 4 adjudicated in revision 15; standalone pending handover, persisted external-owner mode, bounded rollback, and incoming-daemon event publication revised | +| 15 | Claude Opus | NEEDS_CHANGES | 2 medium, 1 low | All 3 adjudicated in revision 16; durable handover recovery, local-only owner selection, and offline-owner remediation revised | +| 16 | Claude Opus | NEEDS_CHANGES | 1 medium, 1 low | Both findings adjudicated in revision 17; journal storage and locking plus the WebSocket manifest contract revised | +| 17 | Claude Opus | NEEDS_CHANGES | 1 medium, 1 low | Both findings adjudicated in revision 18; LED target calibration and ownership-event schema conformance revised | +| 18 | Claude Opus | NEEDS_CHANGES | 1 medium, 1 low | Both findings adjudicated in revision 19; one-stop default highlight headroom and calibration-reset scope revised | +| 19 | Claude Opus | NEEDS_CHANGES | 1 medium | The finding was adjudicated in revision 20; full-scale SDR parity and deterministic SDR/HDR transition behavior revised | +| 20 | Claude Opus | NEEDS_CHANGES | 1 medium, 1 low | Both findings adjudicated in revision 21; smoother interaction, measurement conditions, and zero-exposure endpoints revised | +| 21 | Claude Opus | NEEDS_CHANGES | 2 low | Both findings adjudicated in revision 22; marker restart and cross-platform no-op semantics revised | +| 22 | Claude Opus | NEEDS_CHANGES | 1 medium, 1 low | Both findings adjudicated in revision 23; real smoother targets and the private-field-safe metadata builder revised | +| 23 | Claude Opus | NEEDS_CHANGES | 1 medium | The finding was adjudicated in revision 24; the unreachable metadata carrier was replaced with direct smoother parameters | +| 24 | Claude Opus | NEEDS_CHANGES | 1 low | The finding was adjudicated in revision 25; the final smoother seam and wrapper defaults were made exact | +| 25 | Claude Opus | NEEDS_CHANGES | 1 low | The finding was adjudicated in revision 26; spec 57 authority and Intel parity reconciliation were added | +| 26 | Claude Opus | PASS | None | No actionable issue remained at any severity; implementation-ready | From 209eed4f5911cbb0fe88ea85346d11c58df2e943 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 14:59:14 -0700 Subject: [PATCH 002/144] build(macos): enforce the 15.2 deployment floor 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) --- .cargo/config.toml | 1 + crates/hypercolor-app/tauri.conf.json | 2 +- crates/hypercolor-app/tests/config_tests.rs | 12 +++++ .../hypercolor-app/tests/packaging_tests.rs | 51 +++++++++++++++++++ packaging/homebrew/hypercolor-app.rb | 8 +++ packaging/homebrew/hypercolor.rb | 16 ++++++ scripts/get-hypercolor.sh | 43 +++++++++++++++- 7 files changed, 131 insertions(+), 2 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index c7e452304..1fc58b749 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ rustdocflags = ["--cfg", "docsrs"] # sessions. tikv-jemalloc-sys consumes these target-prefixed env vars at build # time when Hypercolor is built for Linux GNU targets. [env] +MACOSX_DEPLOYMENT_TARGET = { value = "15.2", force = true } X86_64_UNKNOWN_LINUX_GNU_JEMALLOC_SYS_WITH_MALLOC_CONF = "background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000,abort_conf:true" AARCH64_UNKNOWN_LINUX_GNU_JEMALLOC_SYS_WITH_MALLOC_CONF = "background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000,abort_conf:true" diff --git a/crates/hypercolor-app/tauri.conf.json b/crates/hypercolor-app/tauri.conf.json index ecb62e082..7679ebdb2 100644 --- a/crates/hypercolor-app/tauri.conf.json +++ b/crates/hypercolor-app/tauri.conf.json @@ -41,7 +41,7 @@ "timestampUrl": "http://timestamp.digicert.com" }, "macOS": { - "minimumSystemVersion": "11.0", + "minimumSystemVersion": "15.2", "hardenedRuntime": true, "entitlements": "entitlements.plist", "infoPlist": "Info.plist", diff --git a/crates/hypercolor-app/tests/config_tests.rs b/crates/hypercolor-app/tests/config_tests.rs index 6a5f9170d..2f7e7eaaa 100644 --- a/crates/hypercolor-app/tests/config_tests.rs +++ b/crates/hypercolor-app/tests/config_tests.rs @@ -173,6 +173,18 @@ fn tauri_config_declares_macos_hardened_runtime_metadata() { } } +#[test] +fn tauri_config_requires_macos_15_2() { + let config = tauri_config(); + let minimum_system_version = config + .get("bundle") + .and_then(|bundle| bundle.get("macOS")) + .and_then(|macos| macos.get("minimumSystemVersion")) + .and_then(serde_json::Value::as_str); + + assert_eq!(minimum_system_version, Some("15.2")); +} + #[test] fn macos_bundle_plists_declare_required_permissions() { let root = manifest_dir(); diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 08981ab55..297b17b97 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -1,3 +1,9 @@ +#[cfg(unix)] +use std::process::Command; + +const CARGO_CONFIG: &str = include_str!("../../../.cargo/config.toml"); +const GET_INSTALLER: &str = include_str!("../../../scripts/get-hypercolor.sh"); +const HOMEBREW_FORMULA: &str = include_str!("../../../packaging/homebrew/hypercolor.rb"); const HOMEBREW_CASK: &str = include_str!("../../../packaging/homebrew/hypercolor-app.rb"); const CI_WORKFLOW: &str = include_str!("../../../.github/workflows/ci.yml"); const JUSTFILE: &str = include_str!("../../../justfile"); @@ -49,6 +55,51 @@ const REQUIRED_PAWNIO_MODULES: &[&str] = &[ "AMDFamily17.bin", ]; +#[test] +fn macos_distribution_surfaces_require_15_2() { + assert!( + CARGO_CONFIG.contains(r#"MACOSX_DEPLOYMENT_TARGET = { value = "15.2", force = true }"#) + ); + assert!(HOMEBREW_FORMULA.contains(r#"depends_on macos: ">= :sequoia""#)); + assert!(HOMEBREW_FORMULA.contains("MacOS.version >= Version.new(\"15.2\")")); + assert!(HOMEBREW_CASK.contains(r#"depends_on macos: ">= :sequoia""#)); + assert!(HOMEBREW_CASK.contains("MacOS.version < Version.new(\"15.2\")")); + assert!(GET_INSTALLER.contains("require_supported_macos")); +} + +#[cfg(unix)] +#[test] +fn curl_installer_compares_macos_versions_by_numeric_component() { + let (_, function_tail) = GET_INSTALLER + .split_once("macos_version_supported() {") + .expect("installer should define macos_version_supported"); + let (function_body, _) = function_tail + .split_once("# ── Argument Parsing") + .expect("version helper should precede argument parsing"); + let script = + format!("macos_version_supported() {{{function_body}\nmacos_version_supported \"$1\""); + + for (version, supported) in [ + ("14.9", false), + ("15.0", false), + ("15.1", false), + ("15.2", true), + ("15.10", true), + ("26.0", true), + ("26.10", true), + ] { + let status = Command::new("bash") + .args(["-c", &script, "--", version]) + .status() + .expect("bash should execute the installer version helper"); + assert_eq!( + status.success(), + supported, + "unexpected support result for macOS {version}" + ); + } +} + #[test] fn homebrew_cask_template_targets_normalized_macos_dmg_names() { assert!(HOMEBREW_CASK.contains(r#"cask "hypercolor-app" do"#)); diff --git a/packaging/homebrew/hypercolor-app.rb b/packaging/homebrew/hypercolor-app.rb index d21b7f864..af5461851 100644 --- a/packaging/homebrew/hypercolor-app.rb +++ b/packaging/homebrew/hypercolor-app.rb @@ -16,8 +16,16 @@ desc "Open-source RGB lighting orchestration" homepage "https://github.com/hyperb1iss/hypercolor" + depends_on macos: ">= :sequoia" + app "Hypercolor.app" + preflight do + if MacOS.version < Version.new("15.2") + raise ::Cask::CaskError, "Hypercolor requires macOS 15.2 or newer." + end + end + zap trash: [ "~/Library/Application Support/hypercolor", "~/Library/Caches/hypercolor", diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index e4e591f09..a3867db80 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -5,12 +5,28 @@ # Auto-updated by CI — do not edit SHA256 sums manually. class Hypercolor < Formula + # Sequoia's symbolic version cannot distinguish 15.0 from the 15.2 floor. + class MacosVersionRequirement < Requirement + fatal true + + satisfy(build_env: false) do + !OS.mac? || MacOS.version >= Version.new("15.2") + end + + def message + "Hypercolor requires macOS 15.2 or newer." + end + end + desc "Open-source RGB lighting orchestration engine" homepage "https://github.com/hyperb1iss/hypercolor" version "VERSION_PLACEHOLDER" license "Apache-2.0" on_macos do + depends_on macos: ">= :sequoia" + depends_on MacosVersionRequirement + if Hardware::CPU.arm? url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" sha256 "SHA256_MACOS_ARM64" diff --git a/scripts/get-hypercolor.sh b/scripts/get-hypercolor.sh index c00734bb3..bdab85670 100755 --- a/scripts/get-hypercolor.sh +++ b/scripts/get-hypercolor.sh @@ -69,6 +69,44 @@ verify_checksum() { ok "Verified SHA256 checksum" } +macos_version_supported() { + local version="$1" + local major minor patch remainder + local major_number minor_number + + [[ "${version}" =~ ^[0-9]+[.][0-9]+([.][0-9]+)?$ ]] || return 2 + IFS=. read -r major minor patch remainder <<< "${version}" + patch="${patch:-0}" + + [[ -n "${major}" && -n "${minor}" && -z "${remainder}" ]] || return 2 + [[ "${major}" =~ ^[0-9]+$ && "${minor}" =~ ^[0-9]+$ && "${patch}" =~ ^[0-9]+$ ]] \ + || return 2 + + major_number=$((10#${major})) + minor_number=$((10#${minor})) + + (( major_number > 15 || (major_number == 15 && minor_number >= 2) )) +} + +require_supported_macos() { + local version status + + command -v sw_vers &>/dev/null || die "sw_vers is required on macOS" + version="$(sw_vers -productVersion)" + + if macos_version_supported "${version}"; then + return + else + status=$? + fi + + if [[ "${status}" -eq 2 ]]; then + die "Could not parse macOS version: ${version}" + fi + + die "Hypercolor requires macOS 15.2 or newer; found ${version}" +} + # ── Argument Parsing ───────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in @@ -100,7 +138,10 @@ detect_platform() { case "${os}" in Linux) os="linux" ;; - Darwin) os="macos" ;; + Darwin) + require_supported_macos + os="macos" + ;; *) die "Unsupported OS: ${os}. Hypercolor supports Linux and macOS." ;; esac From fad831aed81574f01f3a85bedfb209f61cd22b78 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:02:30 -0700 Subject: [PATCH 003/144] build(macos): publish Intel release artifacts 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) --- .github/workflows/ci.yml | 12 ++++++++---- crates/hypercolor-app/tests/packaging_tests.rs | 17 +++++++++++++++++ packaging/homebrew/hypercolor.rb | 5 ++++- scripts/get-hypercolor.sh | 6 +----- scripts/install-release.sh | 1 + 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7238c6526..5683e06b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1129,7 +1129,7 @@ jobs: target/release/bundle/nsis/*.exe crates/hypercolor-app/target/release/bundle/nsis/*.exe - target: macos-arm64 - os: macos-latest + os: macos-26 rust-target: aarch64-apple-darwin bundles: dmg,app artifact-kind: dmg-app @@ -1140,7 +1140,7 @@ jobs: crates/hypercolor-app/target/release/bundle/dmg/*.dmg crates/hypercolor-app/target/release/bundle/macos/*.app - target: macos-x64 - os: macos-15-intel + os: macos-26-intel rust-target: x86_64-apple-darwin bundles: dmg,app artifact-kind: dmg-app @@ -1472,8 +1472,11 @@ jobs: os: ubuntu-24.04-arm rust-target: aarch64-unknown-linux-gnu - target: macos-arm64 - os: macos-latest + os: macos-26 rust-target: aarch64-apple-darwin + - target: macos-amd64 + os: macos-26-intel + rust-target: x86_64-apple-darwin # This lane is cold on every run: it only fires on tag refs, and the cache # action saves on main alone, so no release-* key is ever written. A cold # release build of the Servo stack runs well over half an hour on the @@ -1812,7 +1815,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | - for platform in linux-amd64 linux-arm64 macos-arm64; do + for platform in linux-amd64 linux-arm64 macos-amd64 macos-arm64; do tarball="hypercolor-${VERSION}-${platform}.tar.gz" url="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${tarball}" echo "Downloading ${tarball}..." @@ -1841,6 +1844,7 @@ jobs: run: | sed \ -e "s/VERSION_PLACEHOLDER/${VERSION}/g" \ + -e "s/SHA256_MACOS_AMD64/${{ steps.checksums.outputs.sha256_macos_amd64 }}/g" \ -e "s/SHA256_MACOS_ARM64/${{ steps.checksums.outputs.sha256_macos_arm64 }}/g" \ -e "s/SHA256_LINUX_AMD64/${{ steps.checksums.outputs.sha256_linux_amd64 }}/g" \ -e "s/SHA256_LINUX_ARM64/${{ steps.checksums.outputs.sha256_linux_arm64 }}/g" \ diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 297b17b97..6513e6317 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -23,6 +23,7 @@ const DIAGNOSE_WINDOWS_PS1: &str = include_str!("../../../scripts/diagnose-windo const FETCH_PAWNIO_ASSETS_PS1: &str = include_str!("../../../scripts/fetch-pawnio-assets.ps1"); const INSTALL_BUNDLED_PAWNIO_PS1: &str = include_str!("../../../scripts/install-bundled-pawnio.ps1"); +const INSTALL_RELEASE_SH: &str = include_str!("../../../scripts/install-release.sh"); const INSTALL_PAWNIO_MODULES_PS1: &str = include_str!("../../../scripts/install-pawnio-modules.ps1"); const INSTALL_WINDOWS_SERVICE_PS1: &str = @@ -100,6 +101,22 @@ fn curl_installer_compares_macos_versions_by_numeric_component() { } } +#[test] +fn macos_distribution_covers_arm64_and_amd64() { + for expected in ["macos-arm64", "macos-amd64"] { + assert!(CI_WORKFLOW.contains(&format!("target: {expected}"))); + assert!(GET_INSTALLER.contains(expected)); + assert!(INSTALL_RELEASE_SH.contains(expected)); + assert!(HOMEBREW_FORMULA.contains(expected)); + } + + assert!(CI_WORKFLOW.contains("os: macos-26")); + assert!(CI_WORKFLOW.contains("os: macos-26-intel")); + assert!(CI_WORKFLOW.contains("SHA256_MACOS_AMD64")); + assert!(HOMEBREW_FORMULA.contains("SHA256_MACOS_AMD64")); + assert!(HOMEBREW_FORMULA.contains("keep_alive successful_exit: false")); +} + #[test] fn homebrew_cask_template_targets_normalized_macos_dmg_names() { assert!(HOMEBREW_CASK.contains(r#"cask "hypercolor-app" do"#)); diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index a3867db80..320378aaf 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -30,6 +30,9 @@ def message if Hardware::CPU.arm? url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" sha256 "SHA256_MACOS_ARM64" + elsif Hardware::CPU.intel? + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-amd64.tar.gz" + sha256 "SHA256_MACOS_AMD64" end end @@ -83,7 +86,7 @@ def caveats service do run [opt_bin/"hypercolor-daemon", "--ui-dir", share/"hypercolor/ui"] - keep_alive true + keep_alive successful_exit: false log_path var/"log/hypercolor/hypercolor.log" error_log_path var/"log/hypercolor/hypercolor.log" environment_variables HYPERCOLOR_LOG: "info" diff --git a/scripts/get-hypercolor.sh b/scripts/get-hypercolor.sh index bdab85670..4af443f14 100755 --- a/scripts/get-hypercolor.sh +++ b/scripts/get-hypercolor.sh @@ -155,11 +155,7 @@ detect_platform() { # Validate supported combinations case "${platform}" in - linux-amd64|linux-arm64|macos-arm64) ;; - macos-amd64) - warn "macOS x86_64 binaries not pre-built. Consider building from source." - die "See: https://github.com/${REPO}#building-from-source" - ;; + linux-amd64|linux-arm64|macos-amd64|macos-arm64) ;; *) die "Unsupported platform: ${platform}" ;; esac diff --git a/scripts/install-release.sh b/scripts/install-release.sh index 5e61ee4b1..7df08678f 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -154,6 +154,7 @@ detect_platform() { case "${OS}-${ARCH}" in Linux-x86_64) ARTIFACT_SUFFIX="linux-amd64" ;; Linux-aarch64) ARTIFACT_SUFFIX="linux-arm64" ;; + Darwin-x86_64) ARTIFACT_SUFFIX="macos-amd64" ;; Darwin-aarch64) ARTIFACT_SUFFIX="macos-arm64" ;; *) fatal "Unsupported platform: ${OS} ${ARCH}" ;; esac From 0a0db1d116787a90f21e57aa96fc018c97fb6226 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:07:45 -0700 Subject: [PATCH 004/144] build(macos): gate SDK and deployment targets 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) --- .github/workflows/ci.yml | 123 +++++++++++++++++- .../hypercolor-app/tests/packaging_tests.rs | 34 +++++ scripts/verify-macos-deployment-target.sh | 68 ++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) create mode 100755 scripts/verify-macos-deployment-target.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5683e06b3..cdefe7cab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" RUST_TOOLCHAIN: "1.95.0" + XCODE_VERSION: "26.5" + MACOSX_DEPLOYMENT_TARGET: "15.2" APT_STEP_TIMEOUT: 35m APT_HTTP_TIMEOUT: "20" APT_RETRIES: "5" @@ -235,6 +237,79 @@ jobs: cargo clippy --locked ${{ env.RUST_SHARED_WORKSPACE_ARGS }} --all-targets -- -D warnings + rust-check-macos: + name: Rust macOS / ${{ matrix.label }} + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - label: Apple Silicon + os: macos-26 + expected-arch: arm64 + - label: Intel + os: macos-26-intel + expected-arch: x86_64 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-check-macos + steps: + - uses: actions/checkout@v6 + + - name: Qualify macOS runner and SDK + run: | + set -euo pipefail + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + xcodebuild -version + sdk_version="$(xcrun --show-sdk-version)" + printf 'macOS SDK: %s\n' "${sdk_version}" + test "$(uname -m)" = "${{ matrix.expected-arch }}" + test "${sdk_version%%.*}" = "26" + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: clippy + + - uses: ./.github/actions/rust-build-cache + with: + shared-key: rust-check-macos-${{ matrix.expected-arch }} + workspaces: . -> .cache/hypercolor/target/rust-check-macos + cache-on-failure: "false" + + - name: Install nextest + uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 + with: + tool: cargo-nextest + + - name: Check macOS workspace + run: >- + ./scripts/cargo-cache-build.sh + cargo check --workspace --locked + + - name: Clippy macOS interop + run: >- + ./scripts/cargo-cache-build.sh + cargo clippy --locked -p hypercolor-macos-gpu-interop --all-targets + -- -D warnings + + - name: Run macOS interop fixtures + run: >- + ./scripts/cargo-cache-build.sh + cargo nextest run --locked -p hypercolor-macos-gpu-interop + + - name: Build deployment target fixture + run: >- + ./scripts/cargo-cache-build.sh + cargo build --locked -p hypercolor-cli --bin hypercolor + + - name: Verify deployment target + run: >- + ./scripts/verify-macos-deployment-target.sh + "${CARGO_TARGET_DIR}/debug/hypercolor" + # ── Generated Effects Artifact ──────────────────────────────── generated-effects: name: Generated Effects @@ -1114,7 +1189,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [rust-check-shared, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] + needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] strategy: fail-fast: false matrix: @@ -1161,6 +1236,17 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Qualify macOS runner and SDK + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + xcodebuild -version + sdk_version="$(xcrun --show-sdk-version)" + printf 'macOS SDK: %s\n' "${sdk_version}" + test "${sdk_version%%.*}" = "26" + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} @@ -1340,6 +1426,23 @@ jobs: Move-Item -LiteralPath $dmgFiles[0].FullName -Destination $targetPath -Force } + - name: Verify macOS bundle deployment targets + if: matrix.cask_arch != '' + shell: bash + run: | + app_bundles=() + while IFS= read -r -d '' app_bundle; do + app_bundles+=("${app_bundle}") + done < <( + find target/release/bundle crates/hypercolor-app/target/release/bundle \ + -type d -name '*.app' -print0 2>/dev/null + ) + if [[ "${#app_bundles[@]}" -ne 1 ]]; then + printf 'expected exactly one app bundle, found %s\n' "${#app_bundles[@]}" >&2 + exit 1 + fi + ./scripts/verify-macos-deployment-target.sh "${app_bundles[0]}" + - name: Upload native app bundle uses: actions/upload-artifact@v7 with: @@ -1460,7 +1563,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [rust-check-shared, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] + needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] strategy: fail-fast: false matrix: @@ -1487,6 +1590,16 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Qualify macOS runner and SDK + if: runner.os == 'macOS' + run: | + set -euo pipefail + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + xcodebuild -version + sdk_version="$(xcrun --show-sdk-version)" + printf 'macOS SDK: %s\n' "${sdk_version}" + test "${sdk_version%%.*}" = "26" + - name: Report runner capacity shell: bash run: | @@ -1624,6 +1737,12 @@ jobs: "dist/${dist_name}.tar.gz" \ "dist/${dist_name}.tar.gz.sha256" + - name: Verify macOS deployment targets + if: runner.os == 'macOS' + run: | + dist_name="${{ steps.version.outputs.dist_name }}" + ./scripts/verify-macos-deployment-target.sh "dist/${dist_name}" + - name: Build Debian package if: runner.os == 'Linux' run: | diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 6513e6317..d53bb7aa3 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -32,6 +32,8 @@ const INSTALL_WINDOWS_SMBUS_SERVICE_PS1: &str = include_str!("../../../scripts/install-windows-smbus-service.ps1"); const PACKAGE_DEB_SH: &str = include_str!("../../../scripts/package-deb.sh"); const VERIFY_DEB_SH: &str = include_str!("../../../scripts/verify-deb-package.sh"); +const VERIFY_MACOS_DEPLOYMENT_TARGET_SH: &str = + include_str!("../../../scripts/verify-macos-deployment-target.sh"); const STAGE_APP_BUNDLE_PS1: &str = include_str!("../../../scripts/stage-app-bundle-assets.ps1"); const STAGE_APP_BUNDLE_SH: &str = include_str!("../../../scripts/stage-app-bundle-assets.sh"); const INSTALLER_HOOKS_NSH: &str = include_str!("../installer-hooks.nsh"); @@ -117,6 +119,38 @@ fn macos_distribution_covers_arm64_and_amd64() { assert!(HOMEBREW_FORMULA.contains("keep_alive successful_exit: false")); } +#[test] +fn macos_release_verifier_pins_every_macho_to_15_2() { + assert!(VERIFY_MACOS_DEPLOYMENT_TARGET_SH.contains("xcrun vtool -show-build")); + assert!(VERIFY_MACOS_DEPLOYMENT_TARGET_SH.contains("LC_BUILD_VERSION minos")); + assert!(VERIFY_MACOS_DEPLOYMENT_TARGET_SH.contains("expected 15.2")); + assert!(VERIFY_MACOS_DEPLOYMENT_TARGET_SH.contains("no Mach-O files found")); +} + +#[test] +fn ci_qualifies_both_macos_architectures_with_xcode_26() { + assert!(CI_WORKFLOW.contains("rust-check-macos:")); + assert!(CI_WORKFLOW.contains("os: macos-26")); + assert!(CI_WORKFLOW.contains("os: macos-26-intel")); + assert!(CI_WORKFLOW.contains("XCODE_VERSION: \"26.5\"")); + assert!(CI_WORKFLOW.contains("xcodebuild -version")); + assert!(CI_WORKFLOW.contains("xcrun --show-sdk-version")); + assert!(CI_WORKFLOW.contains("test \"${sdk_version%%.*}\" = \"26\"")); +} + +#[test] +fn ci_audits_pr_and_release_macho_deployment_targets() { + assert!(CI_WORKFLOW.contains("cargo check --workspace --locked")); + assert!(CI_WORKFLOW.contains("cargo nextest run --locked -p hypercolor-macos-gpu-interop")); + assert!(CI_WORKFLOW.contains("cargo build --locked -p hypercolor-cli --bin hypercolor")); + assert_eq!( + CI_WORKFLOW + .matches("./scripts/verify-macos-deployment-target.sh") + .count(), + 3 + ); +} + #[test] fn homebrew_cask_template_targets_normalized_macos_dmg_names() { assert!(HOMEBREW_CASK.contains(r#"cask "hypercolor-app" do"#)); diff --git a/scripts/verify-macos-deployment-target.sh b/scripts/verify-macos-deployment-target.sh new file mode 100755 index 000000000..0395a1cef --- /dev/null +++ b/scripts/verify-macos-deployment-target.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_MAJOR=15 +EXPECTED_MINOR=2 + +die() { + printf 'macOS deployment target check failed: %s\n' "$*" >&2 + exit 1 +} + +version_matches_floor() { + local version="$1" + local major minor patch remainder + + [[ "${version}" =~ ^[0-9]+[.][0-9]+([.][0-9]+)?$ ]] || return 1 + IFS=. read -r major minor patch remainder <<< "${version}" + patch="${patch:-0}" + + [[ -z "${remainder}" ]] || return 1 + (( 10#${major} == EXPECTED_MAJOR && 10#${minor} == EXPECTED_MINOR && 10#${patch} == 0 )) +} + +emit_candidates() { + local target + + for target in "$@"; do + if [[ -d "${target}" ]]; then + find "${target}" -type f -print0 + else + printf '%s\0' "${target}" + fi + done +} + +[[ "$#" -gt 0 ]] || { + printf 'usage: scripts/verify-macos-deployment-target.sh [...]\n' >&2 + exit 2 +} + +for target in "$@"; do + [[ -e "${target}" ]] || die "path does not exist: ${target}" +done + +for command in awk file find xcrun; do + command -v "${command}" >/dev/null 2>&1 || die "missing required command: ${command}" +done + +macho_count=0 +while IFS= read -r -d '' candidate; do + file_kind="$(file -b "${candidate}")" + [[ "${file_kind}" == *Mach-O* ]] || continue + + build_versions="$(xcrun vtool -show-build "${candidate}" \ + | awk '$1 == "minos" { print $2 }')" + [[ -n "${build_versions}" ]] || die "missing LC_BUILD_VERSION minos: ${candidate}" + + while IFS= read -r minimum; do + version_matches_floor "${minimum}" \ + || die "${candidate} has minos ${minimum}; expected 15.2" + done <<< "${build_versions}" + + macho_count=$((macho_count + 1)) + printf 'verified macOS 15.2 deployment target: %s\n' "${candidate}" +done < <(emit_candidates "$@") + +[[ "${macho_count}" -gt 0 ]] || die "no Mach-O files found" +printf 'verified %s Mach-O file(s)\n' "${macho_count}" From 68dfb3b15b5ffaa08e8f5357746fd53ce92e225a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:16:04 -0700 Subject: [PATCH 005/144] build(macos): add manifest-driven signing actor 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) --- .../hypercolor-app/tests/packaging_tests.rs | 52 ++ packaging/macos/daemon.entitlements.plist | 18 + packaging/macos/signing-manifest.tsv | 8 + scripts/sign-macos-artifacts.sh | 582 ++++++++++++++++++ 4 files changed, 660 insertions(+) create mode 100644 packaging/macos/daemon.entitlements.plist create mode 100644 packaging/macos/signing-manifest.tsv create mode 100755 scripts/sign-macos-artifacts.sh diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index d53bb7aa3..4dc35fd98 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -34,6 +34,10 @@ const PACKAGE_DEB_SH: &str = include_str!("../../../scripts/package-deb.sh"); const VERIFY_DEB_SH: &str = include_str!("../../../scripts/verify-deb-package.sh"); const VERIFY_MACOS_DEPLOYMENT_TARGET_SH: &str = include_str!("../../../scripts/verify-macos-deployment-target.sh"); +const SIGN_MACOS_ARTIFACTS_SH: &str = include_str!("../../../scripts/sign-macos-artifacts.sh"); +const MACOS_SIGNING_MANIFEST: &str = include_str!("../../../packaging/macos/signing-manifest.tsv"); +const MACOS_DAEMON_ENTITLEMENTS: &str = + include_str!("../../../packaging/macos/daemon.entitlements.plist"); const STAGE_APP_BUNDLE_PS1: &str = include_str!("../../../scripts/stage-app-bundle-assets.ps1"); const STAGE_APP_BUNDLE_SH: &str = include_str!("../../../scripts/stage-app-bundle-assets.sh"); const INSTALLER_HOOKS_NSH: &str = include_str!("../installer-hooks.nsh"); @@ -151,6 +155,54 @@ fn ci_audits_pr_and_release_macho_deployment_targets() { ); } +#[test] +fn macos_signing_manifest_assigns_every_stable_identity() { + for identifier in [ + "tech.hyperbliss.hypercolor", + "tech.hyperbliss.hypercolor.sidecar", + "tech.hyperbliss.hypercolor.daemon", + "tech.hyperbliss.hypercolor.cli", + "tech.hyperbliss.hypercolor.app-host", + "tech.hyperbliss.hypercolor.tray", + ] { + assert!(MACOS_SIGNING_MANIFEST.contains(identifier)); + } + assert!(MACOS_SIGNING_MANIFEST.contains("hypercolor-daemon-{target}")); +} + +#[test] +fn macos_signing_actor_rejects_ad_hoc_and_unlisted_objects() { + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("ad-hoc signing identities are forbidden")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("matched ${matches} signing manifest entries")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("codesign --verify --strict")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("anchor apple generic")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("notarytool submit")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("stapler validate")); +} + +#[test] +fn macos_daemon_entitlements_preserve_the_six_key_profile() { + let keys = [ + "com.apple.security.cs.allow-jit", + "com.apple.security.cs.allow-unsigned-executable-memory", + "com.apple.security.device.audio-input", + "com.apple.security.device.usb", + "com.apple.security.network.client", + "com.apple.security.network.server", + ]; + assert_eq!( + MACOS_DAEMON_ENTITLEMENTS.matches("").count(), + keys.len() + ); + assert_eq!( + MACOS_DAEMON_ENTITLEMENTS.matches("").count(), + keys.len() + ); + for key in keys { + assert!(MACOS_DAEMON_ENTITLEMENTS.contains(key)); + } +} + #[test] fn homebrew_cask_template_targets_normalized_macos_dmg_names() { assert!(HOMEBREW_CASK.contains(r#"cask "hypercolor-app" do"#)); diff --git a/packaging/macos/daemon.entitlements.plist b/packaging/macos/daemon.entitlements.plist new file mode 100644 index 000000000..4bf59647c --- /dev/null +++ b/packaging/macos/daemon.entitlements.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.device.audio-input + + com.apple.security.device.usb + + + diff --git a/packaging/macos/signing-manifest.tsv b/packaging/macos/signing-manifest.tsv new file mode 100644 index 000000000..d830acad3 --- /dev/null +++ b/packaging/macos/signing-manifest.tsv @@ -0,0 +1,8 @@ +# scoperelative pathidentifierentitlements +app Contents/MacOS/Hypercolor tech.hyperbliss.hypercolor crates/hypercolor-app/entitlements.plist +app Contents/MacOS/hypercolor-daemon-{target} tech.hyperbliss.hypercolor.sidecar packaging/macos/daemon.entitlements.plist +app Contents/MacOS/hypercolor-{target} tech.hyperbliss.hypercolor.cli none +standalone bin/hypercolor-daemon tech.hyperbliss.hypercolor.daemon packaging/macos/daemon.entitlements.plist +standalone bin/hypercolor tech.hyperbliss.hypercolor.cli none +standalone bin/hypercolor-app tech.hyperbliss.hypercolor.app-host crates/hypercolor-app/entitlements.plist +standalone bin/hypercolor-tray tech.hyperbliss.hypercolor.tray none diff --git a/scripts/sign-macos-artifacts.sh b/scripts/sign-macos-artifacts.sh new file mode 100755 index 000000000..5db2fefd4 --- /dev/null +++ b/scripts/sign-macos-artifacts.sh @@ -0,0 +1,582 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${ROOT_DIR}/packaging/macos/signing-manifest.tsv" +APP_ENTITLEMENTS="crates/hypercolor-app/entitlements.plist" +DAEMON_ENTITLEMENTS="packaging/macos/daemon.entitlements.plist" +SIGNING_TMP="" +SIGNING_KEYCHAIN="" +KEYCHAIN_LIST_CHANGED=0 +ORIGINAL_KEYCHAINS=() + +die() { + printf 'macOS signing failed: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/sign-macos-artifacts.sh [options] + +Commands: + validate-manifest + app --target --version --arch [--ci] + standalone --directory --target + +The app command pre-signs the staged daemon sidecar, builds only the Tauri +app bundle, reapplies every manifest signature, notarizes and staples the app, +then creates, signs, notarizes, and staples a separate DMG. + +Signing requires APPLE_SIGNING_IDENTITY and APPLE_TEAM_ID. The identity may +already be installed, or APPLE_CERTIFICATE and APPLE_CERTIFICATE_PASSWORD may +provide a base64-encoded PKCS#12 certificate. Notarization accepts either the +APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD trio or the +APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_API_KEY_PATH trio. +EOF +} + +cleanup() { + if [[ "${KEYCHAIN_LIST_CHANGED}" -eq 1 ]]; then + security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null + fi + if [[ -n "${SIGNING_KEYCHAIN}" && -f "${SIGNING_KEYCHAIN}" ]]; then + security delete-keychain "${SIGNING_KEYCHAIN}" >/dev/null 2>&1 || true + fi + if [[ -n "${SIGNING_TMP}" && -d "${SIGNING_TMP}" ]]; then + rm -rf "${SIGNING_TMP}" + fi +} +trap cleanup EXIT + +require() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +manifest_has() { + local wanted_scope="$1" + local wanted_path="$2" + local wanted_identifier="$3" + local scope relative_path identifier entitlements + + while IFS=$'\t' read -r scope relative_path identifier entitlements; do + [[ -n "${scope}" && "${scope}" != \#* ]] || continue + if [[ "${scope}" == "${wanted_scope}" && "${relative_path}" == "${wanted_path}" && "${identifier}" == "${wanted_identifier}" ]]; then + return 0 + fi + done < "${MANIFEST}" + return 1 +} + +validate_manifest() { + [[ -s "${MANIFEST}" ]] || die "missing signing manifest: ${MANIFEST}" + + local seen + seen="$(mktemp)" + local count=0 + local scope relative_path identifier entitlements extra + while IFS=$'\t' read -r scope relative_path identifier entitlements extra; do + [[ -n "${scope}" && "${scope}" != \#* ]] || continue + [[ -z "${extra:-}" ]] || die "manifest entry has more than four fields: ${scope}/${relative_path}" + case "${scope}" in + app|standalone) ;; + *) die "invalid manifest scope: ${scope}" ;; + esac + [[ -n "${relative_path}" && "${relative_path}" != /* && "${relative_path}" != *..* ]] \ + || die "invalid manifest path: ${relative_path}" + [[ "${identifier}" == tech.hyperbliss.hypercolor* ]] \ + || die "invalid signing identifier: ${identifier}" + if [[ "${entitlements}" != "none" ]]; then + [[ -s "${ROOT_DIR}/${entitlements}" ]] \ + || die "missing entitlements file: ${entitlements}" + fi + if grep -Fqx "${scope}"$'\t'"${relative_path}" "${seen}"; then + die "duplicate manifest path: ${scope}/${relative_path}" + fi + printf '%s\t%s\n' "${scope}" "${relative_path}" >> "${seen}" + count=$((count + 1)) + done < "${MANIFEST}" + + [[ "${count}" -eq 7 ]] || die "expected 7 signing manifest entries, found ${count}" + manifest_has app 'Contents/MacOS/Hypercolor' 'tech.hyperbliss.hypercolor' \ + || die "manifest is missing the app identity" + manifest_has app 'Contents/MacOS/hypercolor-daemon-{target}' 'tech.hyperbliss.hypercolor.sidecar' \ + || die "manifest is missing the daemon sidecar identity" + manifest_has standalone 'bin/hypercolor-daemon' 'tech.hyperbliss.hypercolor.daemon' \ + || die "manifest is missing the standalone daemon identity" + manifest_has standalone 'bin/hypercolor' 'tech.hyperbliss.hypercolor.cli' \ + || die "manifest is missing the standalone CLI identity" + manifest_has standalone 'bin/hypercolor-app' 'tech.hyperbliss.hypercolor.app-host' \ + || die "manifest is missing the standalone app host identity" + manifest_has standalone 'bin/hypercolor-tray' 'tech.hyperbliss.hypercolor.tray' \ + || die "manifest is missing the standalone tray identity" + cmp -s "${ROOT_DIR}/${APP_ENTITLEMENTS}" "${ROOT_DIR}/${DAEMON_ENTITLEMENTS}" \ + || die "daemon entitlements diverge from the app profile" +} + +ensure_signing_tmp() { + if [[ -z "${SIGNING_TMP}" ]]; then + SIGNING_TMP="$(mktemp -d)" + fi +} + +decode_certificate() { + local output="$1" + if printf '%s' "${APPLE_CERTIFICATE}" | base64 -D > "${output}" 2>/dev/null; then + return + fi + printf '%s' "${APPLE_CERTIFICATE}" | base64 --decode > "${output}" 2>/dev/null \ + || die "APPLE_CERTIFICATE is not valid base64" +} + +prepare_signing_identity() { + require codesign + require security + [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]] \ + || die "APPLE_SIGNING_IDENTITY is required" + [[ "${APPLE_SIGNING_IDENTITY}" != "-" ]] \ + || die "ad-hoc signing identities are forbidden" + [[ -n "${APPLE_TEAM_ID:-}" ]] || die "APPLE_TEAM_ID is required" + + if security find-identity -v -p codesigning \ + | grep -F "${APPLE_SIGNING_IDENTITY}" >/dev/null; then + return + fi + + [[ -n "${APPLE_CERTIFICATE:-}" ]] \ + || die "signing identity is not installed and APPLE_CERTIFICATE is missing" + [[ -n "${APPLE_CERTIFICATE_PASSWORD:-}" ]] \ + || die "APPLE_CERTIFICATE_PASSWORD is required" + + ensure_signing_tmp + local certificate="${SIGNING_TMP}/certificate.p12" + local keychain_password + keychain_password="$(uuidgen)" + SIGNING_KEYCHAIN="${SIGNING_TMP}/hypercolor-signing.keychain-db" + decode_certificate "${certificate}" + + security create-keychain -p "${keychain_password}" "${SIGNING_KEYCHAIN}" >/dev/null + security set-keychain-settings -lut 21600 "${SIGNING_KEYCHAIN}" + security unlock-keychain -p "${keychain_password}" "${SIGNING_KEYCHAIN}" + security import "${certificate}" -k "${SIGNING_KEYCHAIN}" \ + -P "${APPLE_CERTIFICATE_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/security >/dev/null + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "${keychain_password}" "${SIGNING_KEYCHAIN}" >/dev/null + + local keychain + while IFS= read -r keychain; do + keychain="${keychain#*\"}" + keychain="${keychain%\"*}" + [[ -n "${keychain}" ]] && ORIGINAL_KEYCHAINS+=("${keychain}") + done < <(security list-keychains -d user) + security list-keychains -d user -s "${SIGNING_KEYCHAIN}" \ + "${ORIGINAL_KEYCHAINS[@]}" >/dev/null + KEYCHAIN_LIST_CHANGED=1 + + security find-identity -v -p codesigning "${SIGNING_KEYCHAIN}" \ + | grep -F "${APPLE_SIGNING_IDENTITY}" >/dev/null \ + || die "imported certificate does not provide APPLE_SIGNING_IDENTITY" +} + +validate_notary_credentials() { + if [[ -n "${APPLE_API_KEY_ID:-}" || -n "${APPLE_API_ISSUER:-}" || -n "${APPLE_API_KEY_PATH:-}" ]]; then + [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" && -s "${APPLE_API_KEY_PATH:-}" ]] \ + || die "notarization requires the complete App Store Connect API key trio" + return + fi + [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] \ + || die "notarization requires Apple ID credentials or an App Store Connect API key" +} + +resolve_rule() { + local wanted_scope="$1" + local wanted_path="$2" + local target="$3" + local scope relative_path identifier entitlements expanded_path + local matches=0 + + RULE_IDENTIFIER="" + RULE_ENTITLEMENTS="" + while IFS=$'\t' read -r scope relative_path identifier entitlements; do + [[ -n "${scope}" && "${scope}" != \#* ]] || continue + expanded_path="${relative_path//\{target\}/${target}}" + if [[ "${scope}" == "${wanted_scope}" && "${expanded_path}" == "${wanted_path}" ]]; then + RULE_IDENTIFIER="${identifier}" + RULE_ENTITLEMENTS="${entitlements}" + matches=$((matches + 1)) + fi + done < "${MANIFEST}" + [[ "${matches}" -eq 1 ]] \ + || die "${wanted_scope}/${wanted_path} matched ${matches} signing manifest entries" +} + +codesign_object() { + local path="$1" + local identifier="$2" + local entitlements="$3" + local args=( + --force + --sign "${APPLE_SIGNING_IDENTITY}" + --identifier "${identifier}" + --options runtime + --timestamp + ) + if [[ "${entitlements}" != "none" ]]; then + args+=(--entitlements "${ROOT_DIR}/${entitlements}") + fi + if [[ -n "${SIGNING_KEYCHAIN}" ]]; then + args+=(--keychain "${SIGNING_KEYCHAIN}") + fi + codesign "${args[@]}" "${path}" +} + +signature_metadata() { + codesign -d --verbose=4 "$1" 2>&1 +} + +signature_requirement() { + codesign -d -r- "$1" 2>&1 | sed -n 's/^designated => /designated => /p' +} + +normalize_entitlements() { + plutil -convert json -o - "$1" | jq -S . +} + +verify_signature() { + local path="$1" + local identifier="$2" + local entitlements="$3" + local metadata requirement actual_entitlements expected_normalized actual_normalized + + codesign --verify --strict --verbose=2 "${path}" + metadata="$(signature_metadata "${path}")" + grep -F "Identifier=${identifier}" <<< "${metadata}" >/dev/null \ + || die "identifier mismatch for ${path}" + grep -F "TeamIdentifier=${APPLE_TEAM_ID}" <<< "${metadata}" >/dev/null \ + || die "team identifier mismatch for ${path}" + grep -F 'flags=0x10000(runtime)' <<< "${metadata}" >/dev/null \ + || die "hardened runtime is missing for ${path}" + grep -F 'Timestamp=' <<< "${metadata}" >/dev/null \ + || die "secure timestamp is missing for ${path}" + + requirement="$(signature_requirement "${path}")" + grep -F "identifier \"${identifier}\"" <<< "${requirement}" >/dev/null \ + || die "designated requirement identifier mismatch for ${path}" + grep -F 'anchor apple generic' <<< "${requirement}" >/dev/null \ + || die "designated requirement anchor mismatch for ${path}" + grep -F "certificate leaf[subject.OU] = \"${APPLE_TEAM_ID}\"" <<< "${requirement}" >/dev/null \ + || die "designated requirement team mismatch for ${path}" + + ensure_signing_tmp + actual_entitlements="${SIGNING_TMP}/actual-entitlements.plist" + : > "${actual_entitlements}" + codesign -d --entitlements :- "${path}" > "${actual_entitlements}" 2>/dev/null || true + if [[ "${entitlements}" == "none" ]]; then + [[ ! -s "${actual_entitlements}" ]] \ + || die "unexpected entitlements on ${path}" + else + expected_normalized="$(normalize_entitlements "${ROOT_DIR}/${entitlements}")" + actual_normalized="$(normalize_entitlements "${actual_entitlements}")" + [[ "${actual_normalized}" == "${expected_normalized}" ]] \ + || die "entitlements mismatch for ${path}" + fi +} + +is_macho() { + file -b "$1" | grep -F 'Mach-O' >/dev/null +} + +assert_scope_files() { + local scope_root="$1" + local wanted_scope="$2" + local target="$3" + local scope relative_path identifier entitlements expanded_path + while IFS=$'\t' read -r scope relative_path identifier entitlements; do + [[ "${scope}" == "${wanted_scope}" ]] || continue + expanded_path="${relative_path//\{target\}/${target}}" + [[ -f "${scope_root}/${expanded_path}" ]] \ + || die "manifest object is missing: ${wanted_scope}/${expanded_path}" + is_macho "${scope_root}/${expanded_path}" \ + || die "manifest object is not Mach-O: ${wanted_scope}/${expanded_path}" + done < "${MANIFEST}" +} + +sign_scope() { + local scope_root="$1" + local scope="$2" + local target="$3" + local app_main="${scope_root}/Contents/MacOS/Hypercolor" + local macho_count=0 + local path relative_path + + assert_scope_files "${scope_root}" "${scope}" "${target}" + while IFS= read -r -d '' path; do + is_macho "${path}" || continue + relative_path="${path#"${scope_root}/"}" + resolve_rule "${scope}" "${relative_path}" "${target}" + macho_count=$((macho_count + 1)) + if [[ "${scope}" == "app" && "${path}" == "${app_main}" ]]; then + continue + fi + codesign_object "${path}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + verify_signature "${path}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + done < <(find "${scope_root}" -type f -print0) + [[ "${macho_count}" -gt 0 ]] || die "no Mach-O objects found in ${scope_root}" + + if [[ "${scope}" == "app" ]]; then + resolve_rule app 'Contents/MacOS/Hypercolor' "${target}" + codesign_object "${scope_root}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + verify_signature "${scope_root}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + fi + + while IFS= read -r -d '' path; do + is_macho "${path}" || continue + relative_path="${path#"${scope_root}/"}" + resolve_rule "${scope}" "${relative_path}" "${target}" + verify_signature "${path}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + done < <(find "${scope_root}" -type f -print0) +} + +verify_scope() { + local scope_root="$1" + local scope="$2" + local target="$3" + local path relative_path + + assert_scope_files "${scope_root}" "${scope}" "${target}" + if [[ "${scope}" == "app" ]]; then + resolve_rule app 'Contents/MacOS/Hypercolor' "${target}" + verify_signature "${scope_root}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + fi + while IFS= read -r -d '' path; do + is_macho "${path}" || continue + relative_path="${path#"${scope_root}/"}" + resolve_rule "${scope}" "${relative_path}" "${target}" + verify_signature "${path}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + done < <(find "${scope_root}" -type f -print0) +} + +notarize() { + local submission="$1" + local receipt="$2" + if [[ -n "${APPLE_API_KEY_ID:-}" ]]; then + xcrun notarytool submit "${submission}" --wait --output-format json \ + --key "${APPLE_API_KEY_PATH}" --key-id "${APPLE_API_KEY_ID}" \ + --issuer "${APPLE_API_ISSUER}" > "${receipt}" + else + xcrun notarytool submit "${submission}" --wait --output-format json \ + --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_APP_SPECIFIC_PASSWORD}" > "${receipt}" + fi + jq -e '.status == "Accepted"' "${receipt}" >/dev/null \ + || die "Apple notarization did not accept ${submission}" +} + +write_object_inventory() { + local scope_root="$1" + local scope="$2" + local target="$3" + local output="$4" + local records + records="$(mktemp)" + local path relative_path requirement + while IFS= read -r -d '' path; do + is_macho "${path}" || continue + relative_path="${path#"${scope_root}/"}" + resolve_rule "${scope}" "${relative_path}" "${target}" + requirement="$(signature_requirement "${path}")" + jq -n \ + --arg path "${relative_path}" \ + --arg identifier "${RULE_IDENTIFIER}" \ + --arg requirement "${requirement}" \ + '{path: $path, identifier: $identifier, designated_requirement: $requirement}' \ + >> "${records}" + done < <(find "${scope_root}" -type f -print0) + jq -s . "${records}" > "${output}" +} + +sign_dmg() { + local dmg="$1" + local args=(--force --sign "${APPLE_SIGNING_IDENTITY}" --timestamp) + if [[ -n "${SIGNING_KEYCHAIN}" ]]; then + args+=(--keychain "${SIGNING_KEYCHAIN}") + fi + codesign "${args[@]}" "${dmg}" + codesign --verify --strict --verbose=2 "${dmg}" + local metadata + metadata="$(signature_metadata "${dmg}")" + grep -F "TeamIdentifier=${APPLE_TEAM_ID}" <<< "${metadata}" >/dev/null \ + || die "team identifier mismatch for ${dmg}" + grep -F 'Timestamp=' <<< "${metadata}" >/dev/null \ + || die "secure timestamp is missing for ${dmg}" +} + +build_app_artifacts() { + local target="$1" + local version="$2" + local arch="$3" + local ci="$4" + + prepare_signing_identity + validate_notary_credentials + for command in cargo ditto file find hdiutil jq plutil sed xcrun; do + require "${command}" + done + + local staged_sidecar="${ROOT_DIR}/target/bundle-stage/binaries/hypercolor-daemon-${target}" + resolve_rule app "Contents/MacOS/hypercolor-daemon-${target}" "${target}" + [[ -f "${staged_sidecar}" ]] || die "staged daemon sidecar is missing: ${staged_sidecar}" + codesign_object "${staged_sidecar}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + verify_signature "${staged_sidecar}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" + + local tauri_args=(tauri build --bundles app --config tauri.bundle.conf.json --target "${target}") + [[ "${ci}" -eq 1 ]] && tauri_args+=(--ci) + ( + cd "${ROOT_DIR}/crates/hypercolor-app" + cargo "${tauri_args[@]}" + ) + + local target_dir profile_dir app dmg_dir dmg app_zip app_receipt dmg_receipt inventory + target_dir="$( + cd "${ROOT_DIR}/crates/hypercolor-app" + cargo metadata --format-version 1 --no-deps | jq -r '.target_directory' + )" + profile_dir="${target_dir}/${target}/release" + app="${profile_dir}/bundle/macos/Hypercolor.app" + dmg_dir="${profile_dir}/bundle/dmg" + dmg="${dmg_dir}/Hypercolor-${version}-${arch}.dmg" + [[ -d "${app}" ]] || die "Tauri app bundle is missing: ${app}" + + sign_scope "${app}" app "${target}" + ensure_signing_tmp + app_zip="${SIGNING_TMP}/Hypercolor-app.zip" + app_receipt="${SIGNING_TMP}/app-notarization.json" + dmg_receipt="${SIGNING_TMP}/dmg-notarization.json" + inventory="${SIGNING_TMP}/app-signing-inventory.json" + ditto -c -k --keepParent "${app}" "${app_zip}" + notarize "${app_zip}" "${app_receipt}" + xcrun stapler staple "${app}" + xcrun stapler validate "${app}" + verify_scope "${app}" app "${target}" + write_object_inventory "${app}" app "${target}" "${inventory}" + + local dmg_stage="${SIGNING_TMP}/dmg-stage" + mkdir -p "${dmg_stage}" + ditto "${app}" "${dmg_stage}/Hypercolor.app" + ln -s /Applications "${dmg_stage}/Applications" + mkdir -p "${dmg_dir}" + rm -f "${dmg}" + hdiutil create -volname Hypercolor -srcfolder "${dmg_stage}" \ + -ov -format UDZO "${dmg}" >/dev/null + sign_dmg "${dmg}" + notarize "${dmg}" "${dmg_receipt}" + xcrun stapler staple "${dmg}" + xcrun stapler validate "${dmg}" + + jq -n \ + --arg team_id "${APPLE_TEAM_ID}" \ + --arg target "${target}" \ + --slurpfile objects "${inventory}" \ + --slurpfile app_notarization "${app_receipt}" \ + --slurpfile dmg_notarization "${dmg_receipt}" \ + '{team_id: $team_id, target: $target, objects: $objects[0], app_notarization: $app_notarization[0], dmg_notarization: $dmg_notarization[0]}' \ + > "${dmg}.notarization.json" + + printf 'signed app: %s\n' "${app}" + printf 'signed DMG: %s\n' "${dmg}" +} + +sign_standalone_artifacts() { + local directory="$1" + local target="$2" + [[ -d "${directory}" ]] || die "standalone distribution is missing: ${directory}" + prepare_signing_identity + validate_notary_credentials + for command in ditto file find jq plutil xcrun; do + require "${command}" + done + + sign_scope "${directory}" standalone "${target}" + ensure_signing_tmp + local archive="${SIGNING_TMP}/standalone.zip" + local receipt="${SIGNING_TMP}/standalone-notarization.json" + local inventory="${SIGNING_TMP}/standalone-signing-inventory.json" + local provenance="${directory}/share/hypercolor/macos-notarization.json" + write_object_inventory "${directory}" standalone "${target}" "${inventory}" + ditto -c -k --keepParent "${directory}" "${archive}" + notarize "${archive}" "${receipt}" + mkdir -p "$(dirname -- "${provenance}")" + jq -n \ + --arg team_id "${APPLE_TEAM_ID}" \ + --arg target "${target}" \ + --slurpfile objects "${inventory}" \ + --slurpfile notarization "${receipt}" \ + '{team_id: $team_id, target: $target, objects: $objects[0], notarization: $notarization[0]}' \ + > "${provenance}" + printf 'signed standalone distribution: %s\n' "${directory}" +} + +validate_manifest + +command_name="${1:-}" +[[ -n "${command_name}" ]] || { + usage >&2 + exit 2 +} +shift + +case "${command_name}" in + validate-manifest) + [[ "$#" -eq 0 ]] || die "validate-manifest takes no arguments" + printf 'validated macOS signing manifest\n' + ;; + app) + target="" + version="" + arch="" + ci=0 + while [[ "$#" -gt 0 ]]; do + case "$1" in + --target) target="$2"; shift 2 ;; + --version) version="$2"; shift 2 ;; + --arch) arch="$2"; shift 2 ;; + --ci) ci=1; shift ;; + *) die "unknown app option: $1" ;; + esac + done + [[ "${target}" == *-apple-darwin ]] || die "app target must be an Apple Darwin triple" + [[ "${version}" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ + || die "app version must be semver" + case "${arch}" in + arm64|x86_64) ;; + *) die "app architecture must be arm64 or x86_64" ;; + esac + case "${target}:${arch}" in + aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;; + *) die "app architecture does not match target ${target}" ;; + esac + build_app_artifacts "${target}" "${version}" "${arch}" "${ci}" + ;; + standalone) + directory="" + target="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --directory) directory="$2"; shift 2 ;; + --target) target="$2"; shift 2 ;; + *) die "unknown standalone option: $1" ;; + esac + done + [[ "${target}" == *-apple-darwin ]] \ + || die "standalone target must be an Apple Darwin triple" + [[ -n "${directory}" ]] || die "standalone directory is required" + sign_standalone_artifacts "${directory}" "${target}" + ;; + -h|--help|help) + usage + ;; + *) + usage >&2 + die "unknown command: ${command_name}" + ;; +esac From 2b4515889829572afbba35cadabe7f496412ea73 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:23:07 -0700 Subject: [PATCH 006/144] build(macos): require signed release artifacts 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) --- .github/workflows/ci.yml | 128 ++++++++-------- .../hypercolor-app/tests/packaging_tests.rs | 36 ++++- scripts/build-mac-installer.sh | 103 +++++-------- scripts/dist.sh | 5 + scripts/sign-macos-artifacts.sh | 138 ++++++++++++++++-- scripts/verify-release-artifact.sh | 38 +++++ 6 files changed, 295 insertions(+), 153 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdefe7cab..45aab84f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1206,25 +1206,21 @@ jobs: - target: macos-arm64 os: macos-26 rust-target: aarch64-apple-darwin - bundles: dmg,app artifact-kind: dmg-app cask_arch: arm64 artifact-path: | - target/release/bundle/dmg/*.dmg - target/release/bundle/macos/*.app - crates/hypercolor-app/target/release/bundle/dmg/*.dmg - crates/hypercolor-app/target/release/bundle/macos/*.app + crates/hypercolor-app/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + crates/hypercolor-app/target/aarch64-apple-darwin/release/bundle/dmg/*.notarization.json + crates/hypercolor-app/target/aarch64-apple-darwin/release/bundle/macos/*.app - target: macos-x64 os: macos-26-intel rust-target: x86_64-apple-darwin - bundles: dmg,app artifact-kind: dmg-app cask_arch: x86_64 artifact-path: | - target/release/bundle/dmg/*.dmg - target/release/bundle/macos/*.app - crates/hypercolor-app/target/release/bundle/dmg/*.dmg - crates/hypercolor-app/target/release/bundle/macos/*.app + crates/hypercolor-app/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg + crates/hypercolor-app/target/x86_64-apple-darwin/release/bundle/dmg/*.notarization.json + crates/hypercolor-app/target/x86_64-apple-darwin/release/bundle/macos/*.app runs-on: ${{ matrix.os }} env: # Absolute on purpose. Cargo resolves a relative CARGO_TARGET_DIR @@ -1375,16 +1371,11 @@ jobs: run: ./scripts/stage-app-bundle-assets.ps1 - name: Build Tauri native bundle + if: runner.os == 'Windows' working-directory: crates/hypercolor-app shell: pwsh env: TAURI_BUNDLES: ${{ matrix.bundles }} - # Ad-hoc bundle signing. --no-sign leaves only linker signatures - # with no resource seal, which Gatekeeper reports as "damaged" - # on quarantined downloads — right-click→Open can't bypass that. - # A valid ad-hoc signature downgrades the verdict to - # "unidentified developer", which right-click→Open accepts. - APPLE_SIGNING_IDENTITY: "-" run: | $configArgs = @() if (Test-Path "tauri.bundle.conf.json") { @@ -1394,54 +1385,38 @@ jobs: $configArgs += @("--config", "tauri.windows.bundle.conf.json") } $buildArgs = @("--ci", "--bundles", $env:TAURI_BUNDLES) - if ($env:RUNNER_OS -eq "Windows") { - # No signtool identity on Windows runners yet. - $buildArgs += "--no-sign" - } + # No signtool identity on Windows runners yet. + $buildArgs += "--no-sign" cargo tauri build @buildArgs @configArgs - - name: Normalize macOS DMG artifact name + - name: Build signed and notarized macOS artifacts if: matrix.cask_arch != '' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $version = "${{ steps.version.outputs.version }}" - $arch = "${{ matrix.cask_arch }}" - $dmgFiles = @() - - foreach ($dir in @("target/release/bundle/dmg", "crates/hypercolor-app/target/release/bundle/dmg")) { - if (Test-Path -LiteralPath $dir) { - $dmgFiles += @(Get-ChildItem -LiteralPath $dir -Filter "*.dmg" -File) - } - } - - if ($dmgFiles.Count -ne 1) { - $found = ($dmgFiles | ForEach-Object { $_.FullName }) -join ", " - throw "Expected exactly one DMG for cask packaging, found $($dmgFiles.Count): $found" - } - - $targetName = "Hypercolor-$version-$arch.dmg" - $targetPath = Join-Path $dmgFiles[0].DirectoryName $targetName - if ($dmgFiles[0].FullName -ne $targetPath) { - Move-Item -LiteralPath $dmgFiles[0].FullName -Destination $targetPath -Force - } + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: >- + ./scripts/sign-macos-artifacts.sh app + --target "${{ matrix.rust-target }}" + --version "${{ steps.version.outputs.version }}" + --arch "${{ matrix.cask_arch }}" + --ci - - name: Verify macOS bundle deployment targets + - name: Verify signed macOS app artifacts if: matrix.cask_arch != '' - shell: bash + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - app_bundles=() - while IFS= read -r -d '' app_bundle; do - app_bundles+=("${app_bundle}") - done < <( - find target/release/bundle crates/hypercolor-app/target/release/bundle \ - -type d -name '*.app' -print0 2>/dev/null - ) - if [[ "${#app_bundles[@]}" -ne 1 ]]; then - printf 'expected exactly one app bundle, found %s\n' "${#app_bundles[@]}" >&2 - exit 1 - fi - ./scripts/verify-macos-deployment-target.sh "${app_bundles[0]}" + profile_dir="crates/hypercolor-app/target/${{ matrix.rust-target }}/release" + app="${profile_dir}/bundle/macos/Hypercolor.app" + dmg="${profile_dir}/bundle/dmg/Hypercolor-${{ steps.version.outputs.version }}-${{ matrix.cask_arch }}.dmg" + ./scripts/verify-release-artifact.sh \ + --macos-app "${app}" "${dmg}" "${dmg}.notarization.json" \ + "${{ matrix.rust-target }}" + ./scripts/verify-macos-deployment-target.sh "${app}" - name: Upload native app bundle uses: actions/upload-artifact@v7 @@ -1503,8 +1478,6 @@ jobs: --web-assets web-assets \ --target linux-amd64 \ --version "${{ steps.version.outputs.version }}" - # macOS bash 3.2 exits 0 after a fatal set -u abort inside the - # script; trust the artifact, not the exit status. test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" - name: Generate release checksum @@ -1672,7 +1645,8 @@ jobs: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "dist_name=hypercolor-${VERSION}-${{ matrix.target }}" >> "$GITHUB_OUTPUT" - - name: Assemble distribution + - name: Assemble Linux distribution + if: runner.os == 'Linux' run: | set -euo pipefail # Exhausting memory takes the runner down rather than failing a @@ -1700,8 +1674,23 @@ jobs: --web-assets web-assets \ --target ${{ matrix.target }} \ --version "${{ steps.version.outputs.version }}" - # macOS bash 3.2 exits 0 after a fatal set -u abort inside the - # script; trust the artifact, not the exit status. + test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" + + - name: Assemble signed macOS distribution + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + set -euo pipefail + ./scripts/dist.sh --ci --skip-docs \ + --web-assets web-assets \ + --target ${{ matrix.target }} \ + --version "${{ steps.version.outputs.version }}" test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" # Kept for the failure modes that leave the runner alive. Memory @@ -1730,17 +1719,23 @@ jobs: cat "${tarball}.sha256" ) - - name: Verify release tarball + - name: Verify Linux release tarball + if: runner.os == 'Linux' run: | dist_name="${{ steps.version.outputs.dist_name }}" ./scripts/verify-release-artifact.sh \ "dist/${dist_name}.tar.gz" \ "dist/${dist_name}.tar.gz.sha256" - - name: Verify macOS deployment targets + - name: Verify signed macOS release tarball if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | dist_name="${{ steps.version.outputs.dist_name }}" + ./scripts/verify-release-artifact.sh \ + "dist/${dist_name}.tar.gz" \ + "dist/${dist_name}.tar.gz.sha256" ./scripts/verify-macos-deployment-target.sh "dist/${dist_name}" - name: Build Debian package @@ -1809,7 +1804,8 @@ jobs: # hundreds of internal files into individual release assets. mapfile -t files < <(find release-artifacts -type f \ \( -name '*.tar.gz' -o -name '*.tar.gz.sha256' \ - -o -name '*.dmg' -o -name '*.deb' -o -name '*-setup.exe' \) | sort) + -o -name '*.dmg' -o -name '*.notarization.json' \ + -o -name '*.deb' -o -name '*-setup.exe' \) | sort) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found" >&2 exit 1 diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 4dc35fd98..986548d94 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -32,6 +32,7 @@ const INSTALL_WINDOWS_SMBUS_SERVICE_PS1: &str = include_str!("../../../scripts/install-windows-smbus-service.ps1"); const PACKAGE_DEB_SH: &str = include_str!("../../../scripts/package-deb.sh"); const VERIFY_DEB_SH: &str = include_str!("../../../scripts/verify-deb-package.sh"); +const VERIFY_RELEASE_SH: &str = include_str!("../../../scripts/verify-release-artifact.sh"); const VERIFY_MACOS_DEPLOYMENT_TARGET_SH: &str = include_str!("../../../scripts/verify-macos-deployment-target.sh"); const SIGN_MACOS_ARTIFACTS_SH: &str = include_str!("../../../scripts/sign-macos-artifacts.sh"); @@ -178,6 +179,7 @@ fn macos_signing_actor_rejects_ad_hoc_and_unlisted_objects() { assert!(SIGN_MACOS_ARTIFACTS_SH.contains("anchor apple generic")); assert!(SIGN_MACOS_ARTIFACTS_SH.contains("notarytool submit")); assert!(SIGN_MACOS_ARTIFACTS_SH.contains("stapler validate")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("tauri build --bundles app --no-sign")); } #[test] @@ -218,11 +220,39 @@ fn homebrew_cask_template_targets_normalized_macos_dmg_names() { } #[test] -fn ci_normalizes_macos_dmg_artifacts_for_cask_urls() { - assert!(CI_WORKFLOW.contains("Normalize macOS DMG artifact name")); +fn ci_builds_normalized_macos_dmg_artifacts_for_cask_urls() { + assert!(CI_WORKFLOW.contains("Build signed and notarized macOS artifacts")); assert!(CI_WORKFLOW.contains("cask_arch: arm64")); assert!(CI_WORKFLOW.contains("cask_arch: x86_64")); - assert!(CI_WORKFLOW.contains("Hypercolor-$version-$arch.dmg")); + assert!( + CI_WORKFLOW.contains( + "Hypercolor-${{ steps.version.outputs.version }}-${{ matrix.cask_arch }}.dmg" + ) + ); + assert!(CI_WORKFLOW.contains("*.notarization.json")); + assert!(CI_WORKFLOW.contains("-name '*.notarization.json'")); +} + +#[test] +fn macos_release_lanes_use_the_manifest_signing_actor() { + assert!(!CI_WORKFLOW.contains(r#"APPLE_SIGNING_IDENTITY: "-""#)); + assert!(CI_WORKFLOW.contains("./scripts/sign-macos-artifacts.sh app")); + assert!(CI_WORKFLOW.contains("Assemble signed macOS distribution")); + assert!(BUILD_MAC_INSTALLER_SH.contains(r#"--bundles app"#)); + assert!(!BUILD_MAC_INSTALLER_SH.contains("dmg,app")); + assert!(BUILD_MAC_INSTALLER_SH.contains(r#""${SIGNING_ACTOR}" app"#)); + assert!(DIST_SH.contains(r#""${MACOS_SIGNING_ACTOR}" standalone"#)); +} + +#[test] +fn macos_release_verifier_checks_signatures_and_notarization_provenance() { + assert!(VERIFY_RELEASE_SH.contains("verify-app")); + assert!(VERIFY_RELEASE_SH.contains("verify-standalone")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("verify_scope")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("verify_inventory")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("app_notarization.status")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("dmg_notarization.status")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("notarization.status")); } #[test] diff --git a/scripts/build-mac-installer.sh b/scripts/build-mac-installer.sh index 62bb45902..d8676ca9b 100755 --- a/scripts/build-mac-installer.sh +++ b/scripts/build-mac-installer.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Build the Hypercolor macOS desktop bundle (.app + .dmg). +# Build the Hypercolor macOS desktop bundle. # # Mirrors scripts/build-windows-installer.ps1 in shape: verify prereqs, build -# UI + effects + sidecars, stage assets, then run `cargo tauri build` against -# the hypercolor-app crate. By default the build is unsigned and unnotarized -# so the script Just Works on a fresh dev Mac. +# UI + effects + sidecars, stage assets, then build the hypercolor-app crate. +# The default produces an unsigned development app. Release-ready builds route +# signing, notarization, and separate DMG creation through the signing actor. # # Signing + notarization activate automatically when the relevant env vars are # present. To produce a release-ready artifact locally: @@ -15,8 +15,7 @@ # APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \ # scripts/build-mac-installer.sh --notarize # -# Without those env vars the script still produces a fully usable DMG that -# Gatekeeper will warn on but the developer can right-click → Open to launch. +# Without those env vars the script produces an unsigned development app. set -euo pipefail @@ -31,7 +30,6 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${ROOT_DIR}/target}" PROFILE="release" TARGET="" -BUNDLES="dmg,app" SKIP_UI=0 SKIP_EFFECTS=0 NOTARIZE=0 @@ -39,6 +37,7 @@ CHECK_ONLY=0 CARGO_CACHE_BUILD="${ROOT_DIR}/scripts/cargo-cache-build.sh" STAGE_ASSETS="${ROOT_DIR}/scripts/stage-app-bundle-assets.sh" +SIGNING_ACTOR="${ROOT_DIR}/scripts/sign-macos-artifacts.sh" usage() { cat <<'EOF' @@ -47,15 +46,14 @@ Usage: scripts/build-mac-installer.sh [options] Options: --profile Cargo build profile (default: release) --target Rust target triple (default: host arch) - --bundles Tauri bundle targets (default: dmg,app) --skip-ui Reuse existing UI build output --skip-effects Reuse existing effects build output - --notarize Submit DMG to Apple notary after build + --notarize Produce signed, notarized app and DMG artifacts --check-only Verify prerequisites and exit -h, --help Show this help -Signing is driven entirely by APPLE_SIGNING_IDENTITY; if it is unset the -output is an unsigned bundle. Notarization additionally needs APPLE_ID, +Release signing is driven by APPLE_SIGNING_IDENTITY. Notarization additionally +needs APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_SPECIFIC_PASSWORD (or APPLE_API_KEY_ID + APPLE_API_ISSUER + APPLE_API_KEY_PATH for App Store Connect keys). EOF @@ -75,7 +73,6 @@ while [[ $# -gt 0 ]]; do case "$1" in --profile) PROFILE="$2"; shift 2 ;; --target) TARGET="$2"; shift 2 ;; - --bundles) BUNDLES="$2"; shift 2 ;; --skip-ui) SKIP_UI=1; shift ;; --skip-effects) SKIP_EFFECTS=1; shift ;; --notarize) NOTARIZE=1; shift ;; @@ -106,11 +103,15 @@ assert_prerequisites() { if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then info "signing with identity: ${APPLE_SIGNING_IDENTITY}" + [[ "${NOTARIZE}" -eq 1 ]] \ + || die "APPLE_SIGNING_IDENTITY requires --notarize for manifest-driven signing" else - warn "APPLE_SIGNING_IDENTITY not set; bundle will be unsigned" + warn "APPLE_SIGNING_IDENTITY not set; app will be unsigned" fi if [[ "${NOTARIZE}" -eq 1 ]]; then + [[ "${PROFILE}" == "release" ]] || die "--notarize requires the release profile" + require jq "install with: brew install jq" [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]] || die "--notarize requires APPLE_SIGNING_IDENTITY" if [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then info "notarization will use App Store Connect API key ${APPLE_API_KEY_ID}" @@ -150,15 +151,13 @@ build_tauri_bundle() { local args=( tauri build --config tauri.bundle.conf.json - --bundles "${BUNDLES}" + --bundles app + --no-sign ) if [[ -n "${TARGET}" ]]; then args+=(--target "${TARGET}") fi - if [[ -z "${APPLE_SIGNING_IDENTITY:-}" ]]; then - args+=(--no-sign) - fi - step "Build Tauri macOS bundle" + step "Build unsigned Tauri macOS app" ( cd "${ROOT_DIR}/crates/hypercolor-app" HYPERCOLOR_FORCE_SCCACHE=1 "${CARGO_CACHE_BUILD}" cargo "${args[@]}" @@ -180,51 +179,11 @@ resolve_target_dir() { fi } -find_dmg() { - local profile_dir="$1" - local candidates=( - "${profile_dir}/bundle/dmg" - "${ROOT_DIR}/crates/hypercolor-app/target/${PROFILE}/bundle/dmg" - ) - local d - for d in "${candidates[@]}"; do - if [[ -d "${d}" ]]; then - find "${d}" -maxdepth 1 -type f -name "*.dmg" -print - fi - done -} - -notarize_dmg() { - local dmg="$1" - - step "Submit ${dmg##*/} to Apple notary" - local submit_args=(notarytool submit "${dmg}" --wait --timeout 30m) - if [[ -n "${APPLE_API_KEY_ID:-}" ]]; then - submit_args+=(--key "${APPLE_API_KEY_PATH}" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER}") - else - submit_args+=(--apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" --password "${APPLE_APP_SPECIFIC_PASSWORD}") - fi - xcrun "${submit_args[@]}" - - step "Staple notarization ticket" - xcrun stapler staple "${dmg}" - - step "Verify notarization" - xcrun stapler validate "${dmg}" - spctl --assess --type install --verbose "${dmg}" || warn "spctl assess returned non-zero (preview spctl rules are flaky locally — verify on a clean Mac)" -} - show_artifacts() { step "Artifacts" local profile_dir profile_dir="$(resolve_target_dir)" - local dmgs - dmgs="$(find_dmg "${profile_dir}")" - if [[ -n "${dmgs}" ]]; then - printf '%s\n' "${dmgs}" - else - warn "no DMG produced under ${profile_dir}/bundle/dmg" - fi + find "${profile_dir}/bundle/dmg" -maxdepth 1 -type f -name '*.dmg' -print 2>/dev/null || true local app app="$(find "${profile_dir}/bundle/macos" -maxdepth 1 -type d -name "*.app" 2>/dev/null | head -1)" if [[ -n "${app}" ]]; then @@ -254,17 +213,25 @@ build_cargo "Build daemon sidecar (with servo)" -p hypercolor-daemon --features build_cargo "Build CLI sidecar" -p hypercolor-cli stage_assets -build_tauri_bundle - if [[ "${NOTARIZE}" -eq 1 ]]; then - profile_dir="$(resolve_target_dir)" - mapfile -t dmgs < <(find_dmg "${profile_dir}") - if [[ "${#dmgs[@]}" -eq 0 ]]; then - die "--notarize requested but no DMG was produced" + signing_target="${TARGET}" + if [[ -z "${signing_target}" ]]; then + signing_target="$(rustc --print host-tuple 2>/dev/null || rustc -vV | sed -n 's/^host: //p')" fi - for dmg in "${dmgs[@]}"; do - notarize_dmg "${dmg}" - done + case "${signing_target}" in + aarch64-apple-darwin) signing_arch="arm64" ;; + x86_64-apple-darwin) signing_arch="x86_64" ;; + *) die "unsupported macOS signing target: ${signing_target}" ;; + esac + signing_version="$(cargo metadata --format-version 1 --no-deps \ + | jq -r '.packages[] | select(.name == "hypercolor-app") | .version')" + run_step "Sign, notarize, and package macOS artifacts" \ + "${SIGNING_ACTOR}" app \ + --target "${signing_target}" \ + --version "${signing_version}" \ + --arch "${signing_arch}" +else + build_tauri_bundle fi show_artifacts diff --git a/scripts/dist.sh b/scripts/dist.sh index a67914785..0908534b6 100755 --- a/scripts/dist.sh +++ b/scripts/dist.sh @@ -29,6 +29,7 @@ BUILD_ROOT="" export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${ROOT_DIR}/target}" CARGO_CACHE_BUILD="${ROOT_DIR}/scripts/cargo-cache-build.sh" +MACOS_SIGNING_ACTOR="${ROOT_DIR}/scripts/sign-macos-artifacts.sh" info() { printf '\033[38;2;128;255;234m→\033[0m %s\n' "$*"; } ok() { printf '\033[38;2;80;250;123m✅\033[0m %s\n' "$*"; } @@ -342,6 +343,10 @@ fi if [[ "${IS_MACOS}" -eq 1 ]]; then cp packaging/launchd/tech.hyperbliss.hypercolor.plist \ "${DIST_DIR}/share/hypercolor/launchd/" + info "Signing and notarizing standalone macOS artifacts" + "${MACOS_SIGNING_ACTOR}" standalone \ + --directory "${DIST_DIR}" \ + --target "${RUST_TARGET}" fi cp LICENSE NOTICE README.md "${DIST_DIR}/" diff --git a/scripts/sign-macos-artifacts.sh b/scripts/sign-macos-artifacts.sh index 5db2fefd4..f9a871039 100755 --- a/scripts/sign-macos-artifacts.sh +++ b/scripts/sign-macos-artifacts.sh @@ -7,8 +7,6 @@ APP_ENTITLEMENTS="crates/hypercolor-app/entitlements.plist" DAEMON_ENTITLEMENTS="packaging/macos/daemon.entitlements.plist" SIGNING_TMP="" SIGNING_KEYCHAIN="" -KEYCHAIN_LIST_CHANGED=0 -ORIGINAL_KEYCHAINS=() die() { printf 'macOS signing failed: %s\n' "$*" >&2 @@ -23,6 +21,10 @@ Commands: validate-manifest app --target --version --arch [--ci] standalone --directory --target + verify-app --app --dmg --provenance + --target --team-id + verify-standalone --directory --target + --team-id The app command pre-signs the staged daemon sidecar, builds only the Tauri app bundle, reapplies every manifest signature, notarizes and staples the app, @@ -37,9 +39,6 @@ EOF } cleanup() { - if [[ "${KEYCHAIN_LIST_CHANGED}" -eq 1 ]]; then - security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null - fi if [[ -n "${SIGNING_KEYCHAIN}" && -f "${SIGNING_KEYCHAIN}" ]]; then security delete-keychain "${SIGNING_KEYCHAIN}" >/dev/null 2>&1 || true fi @@ -163,16 +162,6 @@ prepare_signing_identity() { security set-key-partition-list -S apple-tool:,apple: -s \ -k "${keychain_password}" "${SIGNING_KEYCHAIN}" >/dev/null - local keychain - while IFS= read -r keychain; do - keychain="${keychain#*\"}" - keychain="${keychain%\"*}" - [[ -n "${keychain}" ]] && ORIGINAL_KEYCHAINS+=("${keychain}") - done < <(security list-keychains -d user) - security list-keychains -d user -s "${SIGNING_KEYCHAIN}" \ - "${ORIGINAL_KEYCHAINS[@]}" >/dev/null - KEYCHAIN_LIST_CHANGED=1 - security find-identity -v -p codesigning "${SIGNING_KEYCHAIN}" \ | grep -F "${APPLE_SIGNING_IDENTITY}" >/dev/null \ || die "imported certificate does not provide APPLE_SIGNING_IDENTITY" @@ -411,6 +400,81 @@ sign_dmg() { || die "secure timestamp is missing for ${dmg}" } +verify_dmg() { + local dmg="$1" + codesign --verify --strict --verbose=2 "${dmg}" + local metadata + metadata="$(signature_metadata "${dmg}")" + grep -F "TeamIdentifier=${APPLE_TEAM_ID}" <<< "${metadata}" >/dev/null \ + || die "team identifier mismatch for ${dmg}" + grep -F 'Timestamp=' <<< "${metadata}" >/dev/null \ + || die "secure timestamp is missing for ${dmg}" +} + +verify_inventory() { + local scope_root="$1" + local scope="$2" + local target="$3" + local provenance="$4" + ensure_signing_tmp + local actual="${SIGNING_TMP}/${scope}-actual-inventory.json" + local actual_sorted expected_sorted + write_object_inventory "${scope_root}" "${scope}" "${target}" "${actual}" + actual_sorted="$(jq -S 'sort_by(.path)' "${actual}")" + expected_sorted="$(jq -S '.objects | sort_by(.path)' "${provenance}")" + [[ "${actual_sorted}" == "${expected_sorted}" ]] \ + || die "signed object inventory does not match provenance" +} + +verify_provenance_identity() { + local provenance="$1" + local target="$2" + [[ -s "${provenance}" ]] || die "notarization provenance is missing: ${provenance}" + jq -e \ + --arg team_id "${APPLE_TEAM_ID}" \ + --arg target "${target}" \ + '.team_id == $team_id and .target == $target' \ + "${provenance}" >/dev/null \ + || die "notarization provenance identity mismatch" +} + +verify_app_artifacts() { + local app="$1" + local dmg="$2" + local provenance="$3" + local target="$4" + [[ -d "${app}" ]] || die "app bundle is missing: ${app}" + [[ -s "${dmg}" ]] || die "DMG is missing: ${dmg}" + for command in codesign file find jq plutil sed xcrun; do + require "${command}" + done + verify_scope "${app}" app "${target}" + verify_dmg "${dmg}" + xcrun stapler validate "${app}" + xcrun stapler validate "${dmg}" + verify_provenance_identity "${provenance}" "${target}" + jq -e \ + '.app_notarization.status == "Accepted" and .dmg_notarization.status == "Accepted"' \ + "${provenance}" >/dev/null \ + || die "app or DMG notarization was not accepted" + verify_inventory "${app}" app "${target}" "${provenance}" +} + +verify_standalone_artifacts() { + local directory="$1" + local target="$2" + local provenance="${directory}/share/hypercolor/macos-notarization.json" + [[ -d "${directory}" ]] || die "standalone distribution is missing: ${directory}" + for command in codesign file find jq plutil sed; do + require "${command}" + done + verify_scope "${directory}" standalone "${target}" + verify_provenance_identity "${provenance}" "${target}" + jq -e '.notarization.status == "Accepted"' "${provenance}" >/dev/null \ + || die "standalone notarization was not accepted" + verify_inventory "${directory}" standalone "${target}" "${provenance}" +} + build_app_artifacts() { local target="$1" local version="$2" @@ -429,7 +493,7 @@ build_app_artifacts() { codesign_object "${staged_sidecar}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" verify_signature "${staged_sidecar}" "${RULE_IDENTIFIER}" "${RULE_ENTITLEMENTS}" - local tauri_args=(tauri build --bundles app --config tauri.bundle.conf.json --target "${target}") + local tauri_args=(tauri build --bundles app --no-sign --config tauri.bundle.conf.json --target "${target}") [[ "${ci}" -eq 1 ]] && tauri_args+=(--ci) ( cd "${ROOT_DIR}/crates/hypercolor-app" @@ -572,6 +636,48 @@ case "${command_name}" in [[ -n "${directory}" ]] || die "standalone directory is required" sign_standalone_artifacts "${directory}" "${target}" ;; + verify-app) + app="" + dmg="" + provenance="" + target="" + team_id="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --app) app="$2"; shift 2 ;; + --dmg) dmg="$2"; shift 2 ;; + --provenance) provenance="$2"; shift 2 ;; + --target) target="$2"; shift 2 ;; + --team-id) team_id="$2"; shift 2 ;; + *) die "unknown verify-app option: $1" ;; + esac + done + [[ "${target}" == *-apple-darwin ]] \ + || die "verification target must be an Apple Darwin triple" + [[ -n "${team_id}" ]] || die "verification team ID is required" + APPLE_TEAM_ID="${team_id}" + verify_app_artifacts "${app}" "${dmg}" "${provenance}" "${target}" + printf 'verified signed app artifacts\n' + ;; + verify-standalone) + directory="" + target="" + team_id="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --directory) directory="$2"; shift 2 ;; + --target) target="$2"; shift 2 ;; + --team-id) team_id="$2"; shift 2 ;; + *) die "unknown verify-standalone option: $1" ;; + esac + done + [[ "${target}" == *-apple-darwin ]] \ + || die "verification target must be an Apple Darwin triple" + [[ -n "${team_id}" ]] || die "verification team ID is required" + APPLE_TEAM_ID="${team_id}" + verify_standalone_artifacts "${directory}" "${target}" + printf 'verified signed standalone artifacts\n' + ;; -h|--help|help) usage ;; diff --git a/scripts/verify-release-artifact.sh b/scripts/verify-release-artifact.sh index c8715b4bc..67d9dde47 100755 --- a/scripts/verify-release-artifact.sh +++ b/scripts/verify-release-artifact.sh @@ -1,6 +1,31 @@ #!/usr/bin/env bash set -euo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +MACOS_SIGNING_ACTOR="${ROOT_DIR}/scripts/sign-macos-artifacts.sh" + +if [[ "${1:-}" == "--macos-app" ]]; then + app="${2:-}" + dmg="${3:-}" + provenance="${4:-}" + target="${5:-}" + [[ "$#" -eq 5 ]] || { + echo "usage: scripts/verify-release-artifact.sh --macos-app " >&2 + exit 2 + } + [[ -n "${APPLE_TEAM_ID:-}" ]] || { + echo "APPLE_TEAM_ID is required for macOS release verification" >&2 + exit 1 + } + "${MACOS_SIGNING_ACTOR}" verify-app \ + --app "${app}" \ + --dmg "${dmg}" \ + --provenance "${provenance}" \ + --target "${target}" \ + --team-id "${APPLE_TEAM_ID}" + exit 0 +fi + tarball="${1:-}" checksum_file="${2:-${tarball}.sha256}" @@ -197,6 +222,19 @@ case "${platform}" in echo "missing macOS launchd plist" >&2 exit 1 } + [[ -n "${APPLE_TEAM_ID:-}" ]] || { + echo "APPLE_TEAM_ID is required for macOS release verification" >&2 + exit 1 + } + case "${platform}" in + macos-arm64) macos_target="aarch64-apple-darwin" ;; + macos-amd64) macos_target="x86_64-apple-darwin" ;; + *) echo "unsupported macOS platform: ${platform}" >&2; exit 1 ;; + esac + "${MACOS_SIGNING_ACTOR}" verify-standalone \ + --directory "${root_dir}" \ + --target "${macos_target}" \ + --team-id "${APPLE_TEAM_ID}" ;; esac From 5c6f1f9382af9e5494f5e56a78ea6949aea51983 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:32:13 -0700 Subject: [PATCH 007/144] feat(macos): add capture selector and key vocabulary 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) --- Cargo.toml | 5 + crates/hypercolor-core/src/input/keymap.rs | 176 ++++++++++++++++-- crates/hypercolor-core/tests/keymap_tests.rs | 57 +++++- crates/hypercolor-types/src/config.rs | 27 ++- crates/hypercolor-types/tests/config_tests.rs | 73 ++++++++ 5 files changed, 317 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ce187adbb..b7cd4b576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -220,8 +220,13 @@ fast_image_resize = "6.0.0" wgpu = { version = "29.0.1", default-features = false, features = ["std", "vulkan", "metal", "dx12", "gles", "wgsl"] } wgpu-hal = { version = "29.0.1", default-features = false, features = ["vulkan"] } objc2-core-foundation = { version = "0.3.2", default-features = false } +objc2-core-graphics = { version = "0.3.2", default-features = false } +objc2-core-media = { version = "0.3.2", default-features = false } +objc2-core-video = { version = "0.3.2", default-features = false } +objc2-foundation = { version = "0.3.2", default-features = false } objc2-io-surface = { version = "0.3.2", default-features = false } objc2-metal = { version = "0.3.2", default-features = false } +objc2-screen-capture-kit = { version = "0.3.2", default-features = false } objc2 = { version = "0.6.4", default-features = false } pollster = "0.4.0" diff --git a/crates/hypercolor-core/src/input/keymap.rs b/crates/hypercolor-core/src/input/keymap.rs index dbc1911fe..93718354d 100644 --- a/crates/hypercolor-core/src/input/keymap.rs +++ b/crates/hypercolor-core/src/input/keymap.rs @@ -1,21 +1,21 @@ -//! The canonical key inventory both host backends are built from. +//! The canonical key inventory all host backends are built from. //! //! Names in this codebase are **physical-position** names, `KeyboardEvent.code` //! semantics: `a` is the key where `A` sits on QWERTY, whatever the active //! layout prints on it. That is what makes `wasdVector()` and every positional //! effect mean the same thing on a French AZERTY keyboard as on a US one. //! -//! Two platforms have to agree on those names, and the way they drift is for +//! Three platforms have to agree on those names, and the way they drift is for //! someone to add a key to one table and forget the other. So there is exactly -//! one physical table, [`CANONICAL_KEYS`], and both mappers are derived from it — the -//! Linux one keyed by evdev code, the Windows one by set-1 scan code plus -//! prefix. The parity test asserts totality in both directions over this -//! inventory rather than sampling tuples, so a one-sided addition fails the -//! build instead of silently diverging. +//! one physical table, [`CANONICAL_KEYS`], and every mapper is derived from it. +//! Linux keys use evdev codes, Windows keys use set-1 scan codes plus prefixes, +//! and macOS keys use virtual keycodes. The parity test asserts totality over +//! this inventory rather than sampling tuples, so a one-sided addition fails +//! the build instead of silently diverging. //! //! Consumer-control keys do not have stable set-1 positions. They live in the -//! separate [`MEDIA_KEYS`] inventory, keyed by evdev code and Windows virtual -//! key, so both platforms still expose exactly the same logical names. +//! separate [`MEDIA_KEYS`] inventory, keyed by evdev code, Windows virtual key, +//! and optional macOS `NX_KEYTYPE`, so every platform exposes the same names. //! //! The two key spaces line up almost entirely by construction: evdev's //! keycodes 1..=83 were derived from the AT set-1 scan codes, so those rows @@ -25,7 +25,7 @@ use hypercolor_windows_input::RawKeyPrefix; use hypercolor_windows_input::decode::{KEYBOARD_OVERRUN_MAKE_CODE, unknown_key_name}; -/// One physical key, in both platforms' identifier spaces. +/// One physical key in every host platform's identifier space. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct KeyRow { /// Linux evdev keycode. @@ -34,7 +34,9 @@ pub struct KeyRow { pub make_code: u16, /// Windows scan-code prefix. pub prefix: RawKeyPrefix, - /// The canonical name both platforms report. + /// macOS virtual keycode. + pub macos_virtual_keycode: u16, + /// The canonical name every platform reports. pub name: &'static str, } @@ -43,13 +45,123 @@ const fn row(evdev_code: u16, make_code: u16, prefix: RawKeyPrefix, name: &'stat evdev_code, make_code, prefix, + macos_virtual_keycode: macos_virtual_keycode(evdev_code), name, } } +const fn macos_virtual_keycode(evdev_code: u16) -> u16 { + match evdev_code { + 1 => 0x35, + 2 => 0x12, + 3 => 0x13, + 4 => 0x14, + 5 => 0x15, + 6 => 0x17, + 7 => 0x16, + 8 => 0x1A, + 9 => 0x1C, + 10 => 0x19, + 11 => 0x1D, + 12 => 0x1B, + 13 => 0x18, + 14 => 0x33, + 15 => 0x30, + 16 => 0x0C, + 17 => 0x0D, + 18 => 0x0E, + 19 => 0x0F, + 20 => 0x11, + 21 => 0x10, + 22 => 0x20, + 23 => 0x22, + 24 => 0x1F, + 25 => 0x23, + 26 => 0x21, + 27 => 0x1E, + 28 => 0x24, + 29 => 0x3B, + 30 => 0x00, + 31 => 0x01, + 32 => 0x02, + 33 => 0x03, + 34 => 0x05, + 35 => 0x04, + 36 => 0x26, + 37 => 0x28, + 38 => 0x25, + 39 => 0x29, + 40 => 0x27, + 41 => 0x32, + 42 => 0x38, + 43 => 0x2A, + 44 => 0x06, + 45 => 0x07, + 46 => 0x08, + 47 => 0x09, + 48 => 0x0B, + 49 => 0x2D, + 50 => 0x2E, + 51 => 0x2B, + 52 => 0x2F, + 53 => 0x2C, + 54 => 0x3C, + 55 => 0x43, + 56 => 0x3A, + 57 => 0x31, + 58 => 0x39, + 59 => 0x7A, + 60 => 0x78, + 61 => 0x63, + 62 => 0x76, + 63 => 0x60, + 64 => 0x61, + 65 => 0x62, + 66 => 0x64, + 67 => 0x65, + 68 => 0x6D, + 69 => 0x47, + 70 => 0x6B, + 71 => 0x59, + 72 => 0x5B, + 73 => 0x5C, + 74 => 0x4E, + 75 => 0x56, + 76 => 0x57, + 77 => 0x58, + 78 => 0x45, + 79 => 0x53, + 80 => 0x54, + 81 => 0x55, + 82 => 0x52, + 83 => 0x41, + 87 => 0x67, + 88 => 0x6F, + 96 => 0x4C, + 97 => 0x3E, + 98 => 0x4B, + 99 => 0x69, + 100 => 0x3D, + 102 => 0x73, + 103 => 0x7E, + 104 => 0x74, + 105 => 0x7B, + 106 => 0x7C, + 107 => 0x77, + 108 => 0x7D, + 109 => 0x79, + 110 => 0x72, + 111 => 0x75, + 125 => 0x37, + 126 => 0x36, + 127 => 0x6E, + _ => panic!("canonical key lacks an explicit macOS mapping"), + } +} + use RawKeyPrefix::{E0, None as NoPrefix}; -/// Every physical-position key both host backends name identically. +/// Every physical-position key all host backends name identically. /// /// Printable keys use the character they produce on a US layout — a /// deliberate simplification of the W3C `code` vocabulary that this codebase @@ -164,14 +276,16 @@ pub const CANONICAL_KEYS: &[KeyRow] = &[ row(127, 0x5D, E0, "ContextMenu"), ]; -/// One media key in Linux evdev and Windows virtual-key spaces. +/// One media key in each host platform's consumer-control space. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MediaKeyRow { /// Linux evdev keycode. pub evdev_code: u16, /// Windows virtual-key code. pub windows_vkey: u16, - /// The canonical logical name both platforms report. + /// macOS `NX_KEYTYPE`, or `None` when AppKit cannot report this key. + pub macos_nx_key_type: Option, + /// The canonical logical name every supporting platform reports. pub name: &'static str, } @@ -179,11 +293,25 @@ const fn media_row(evdev_code: u16, windows_vkey: u16, name: &'static str) -> Me MediaKeyRow { evdev_code, windows_vkey, + macos_nx_key_type: macos_nx_key_type(evdev_code), name, } } -/// Media and volume keys supported identically by both host backends. +const fn macos_nx_key_type(evdev_code: u16) -> Option { + match evdev_code { + 113 => Some(7), + 114 => Some(1), + 115 => Some(0), + 163 => Some(17), + 164 => Some(16), + 165 => Some(18), + 166 | 226 => None, + _ => panic!("media key lacks an explicit macOS mapping"), + } +} + +/// Media and volume keys supported by one or more host backends. pub const MEDIA_KEYS: &[MediaKeyRow] = &[ media_row(113, 0xAD, "AudioVolumeMute"), media_row(114, 0xAE, "AudioVolumeDown"), @@ -219,6 +347,24 @@ pub fn scancode_name(make_code: u16, prefix: RawKeyPrefix) -> Option<&'static st .map(|row| row.name) } +/// Canonical name for a macOS virtual keycode. +#[must_use] +pub fn macos_key_name(virtual_keycode: u16) -> Option<&'static str> { + CANONICAL_KEYS + .iter() + .find(|row| row.macos_virtual_keycode == virtual_keycode) + .map(|row| row.name) +} + +/// Canonical name for a macOS `NX_KEYTYPE` consumer-control value. +#[must_use] +pub fn macos_media_key_name(nx_key_type: u16) -> Option<&'static str> { + MEDIA_KEYS + .iter() + .find(|row| row.macos_nx_key_type == Some(nx_key_type)) + .map(|row| row.name) +} + /// Where a resolved key name came from. /// /// The provenance matters and callers must not be able to lose it: a diff --git a/crates/hypercolor-core/tests/keymap_tests.rs b/crates/hypercolor-core/tests/keymap_tests.rs index 645377cf7..9df3e2b53 100644 --- a/crates/hypercolor-core/tests/keymap_tests.rs +++ b/crates/hypercolor-core/tests/keymap_tests.rs @@ -2,11 +2,12 @@ //! //! The interesting assertion here is totality, not spot checks. Two hand-kept //! tables drift by someone adding a key to one and forgetting the other, and a -//! curated sample of tuples cannot catch that — so these walk the whole shared -//! inventory in both directions. +//! curated sample of tuples cannot catch that, so these walk the whole shared +//! inventory in every identifier space. use hypercolor_core::input::keymap::{ - CANONICAL_KEYS, KeyNameResult, MEDIA_KEYS, evdev_key_name, scancode_key_name, scancode_name, + CANONICAL_KEYS, KeyNameResult, MEDIA_KEYS, evdev_key_name, macos_key_name, + macos_media_key_name, scancode_key_name, scancode_name, }; use hypercolor_windows_input::RawKeyPrefix; @@ -28,6 +29,13 @@ fn every_inventory_row_resolves_from_both_key_spaces() { row.prefix, row.name ); + assert_eq!( + macos_key_name(row.macos_virtual_keycode), + Some(row.name), + "macOS virtual keycode {:#04X} does not resolve to {}", + row.macos_virtual_keycode, + row.name + ); } } @@ -44,6 +52,9 @@ fn every_media_key_resolves_to_the_same_name_on_both_platforms() { KeyNameResult::Media(row.name), "media identity wins even when firmware supplies an overlapping scan code" ); + if let Some(nx_key_type) = row.macos_nx_key_type { + assert_eq!(macos_media_key_name(nx_key_type), Some(row.name)); + } } } @@ -60,11 +71,20 @@ fn media_key_identifier_spaces_have_no_duplicates() { let before = virtual_keys.len(); virtual_keys.dedup(); assert_eq!(before, virtual_keys.len()); + + let mut nx_key_types: Vec = MEDIA_KEYS + .iter() + .filter_map(|row| row.macos_nx_key_type) + .collect(); + nx_key_types.sort_unstable(); + let before = nx_key_types.len(); + nx_key_types.dedup(); + assert_eq!(before, nx_key_types.len()); } #[test] -fn the_two_key_spaces_have_no_duplicate_entries() { - // A duplicate would make one of the two lookups shadow the other, so the +fn the_key_spaces_have_no_duplicate_entries() { + // A duplicate would make one of the lookups shadow the other, so the // tables would silently disagree for exactly one key. let mut evdev_codes: Vec = CANONICAL_KEYS.iter().map(|row| row.evdev_code).collect(); evdev_codes.sort_unstable(); @@ -88,6 +108,19 @@ fn the_two_key_spaces_have_no_duplicate_entries() { scancodes.len(), "duplicate (make_code, prefix) in inventory" ); + + let mut macos_keycodes: Vec = CANONICAL_KEYS + .iter() + .map(|row| row.macos_virtual_keycode) + .collect(); + macos_keycodes.sort_unstable(); + let before = macos_keycodes.len(); + macos_keycodes.dedup(); + assert_eq!( + before, + macos_keycodes.len(), + "duplicate macOS virtual keycode in inventory" + ); } #[test] @@ -143,9 +176,23 @@ fn left_and_right_modifiers_are_distinct_positions() { (right_row.make_code, right_row.prefix) ); assert_ne!(left_row.evdev_code, right_row.evdev_code); + assert_ne!( + left_row.macos_virtual_keycode, + right_row.macos_virtual_keycode + ); } } +#[test] +fn macos_media_inventory_marks_unsupported_keys_explicitly() { + let unsupported: Vec<&str> = MEDIA_KEYS + .iter() + .filter(|row| row.macos_nx_key_type.is_none()) + .map(|row| row.name) + .collect(); + assert_eq!(unsupported, ["MediaStop", "MediaSelect"]); +} + #[test] fn the_extended_block_is_separated_only_by_its_prefix() { // ControlRight and ControlLeft share scan code 0x1D; only the E0 prefix diff --git a/crates/hypercolor-types/src/config.rs b/crates/hypercolor-types/src/config.rs index 6824829f4..35ae64e8c 100644 --- a/crates/hypercolor-types/src/config.rs +++ b/crates/hypercolor-types/src/config.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use uuid::Uuid; use crate::session::SessionConfig; @@ -754,6 +755,8 @@ pub enum CapturePlatform { WindowsDesktopDuplication, /// XDG desktop portal plus PipeWire. LinuxPipeWire, + /// ScreenCaptureKit with the system content picker. + MacosScreenCaptureKit, /// No native screen-capture implementation is available. Unsupported, } @@ -770,7 +773,11 @@ impl CapturePlatform { { Self::LinuxPipeWire } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] + #[cfg(target_os = "macos")] + { + Self::MacosScreenCaptureKit + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { Self::Unsupported } @@ -906,6 +913,7 @@ fn validate_capture_source( let platform_name = match platform { CapturePlatform::WindowsDesktopDuplication => "Windows Desktop Duplication", CapturePlatform::LinuxPipeWire => "Linux PipeWire", + CapturePlatform::MacosScreenCaptureKit => "macOS ScreenCaptureKit", CapturePlatform::Unsupported => "this platform", }; if source.is_empty() { @@ -935,9 +943,26 @@ fn validate_capture_source( reason: "portal-managed capture requires source = \"auto\"", }); } + if matches!(platform, CapturePlatform::MacosScreenCaptureKit) + && !is_valid_macos_capture_source(source) + { + return Err(CaptureConfigValidationError::Source { + platform: platform_name, + reason: "expected auto, primary_display, session_scoped, or display:", + }); + } Ok(()) } +fn is_valid_macos_capture_source(source: &str) -> bool { + matches!(source, "auto" | "primary_display" | "session_scoped") + || source.strip_prefix("display:").is_some_and(|value| { + value.len() == 36 + && Uuid::parse_str(value) + .is_ok_and(|uuid| uuid.hyphenated().to_string().eq_ignore_ascii_case(value)) + }) +} + impl Default for CaptureConfig { fn default() -> Self { Self { diff --git a/crates/hypercolor-types/tests/config_tests.rs b/crates/hypercolor-types/tests/config_tests.rs index 445662d19..daa50bb47 100644 --- a/crates/hypercolor-types/tests/config_tests.rs +++ b/crates/hypercolor-types/tests/config_tests.rs @@ -116,6 +116,24 @@ fn capture_defaults_match_spec() { assert_eq!(c.restore_token, None); } +#[test] +fn capture_platform_matches_build_target() { + #[cfg(target_os = "windows")] + assert_eq!( + CapturePlatform::current(), + CapturePlatform::WindowsDesktopDuplication + ); + #[cfg(target_os = "linux")] + assert_eq!(CapturePlatform::current(), CapturePlatform::LinuxPipeWire); + #[cfg(target_os = "macos")] + assert_eq!( + CapturePlatform::current(), + CapturePlatform::MacosScreenCaptureKit + ); + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + assert_eq!(CapturePlatform::current(), CapturePlatform::Unsupported); +} + #[test] fn capture_config_tolerates_legacy_monitor_key() { let parsed: CaptureConfig = @@ -130,6 +148,7 @@ fn capture_config_accepts_any_nonzero_backend_rate() { for platform in [ CapturePlatform::WindowsDesktopDuplication, CapturePlatform::LinuxPipeWire, + CapturePlatform::MacosScreenCaptureKit, ] { config.source = "auto".to_owned(); config.capture_fps = 1; @@ -248,6 +267,60 @@ fn capture_config_validates_source_by_backend() { )); } +#[test] +fn macos_capture_source_accepts_only_persistable_picker_grammar() { + let platform = CapturePlatform::MacosScreenCaptureKit; + let mut config = CaptureConfig { + enabled: true, + ..CaptureConfig::default() + }; + + for source in [ + "auto", + "primary_display", + "session_scoped", + "display:7607E722-6D21-4812-8926-D93DBF8FDC58", + "display:7607e722-6d21-4812-8926-d93dbf8fdc58", + ] { + config.source = source.to_owned(); + config + .validate_for_platform(platform) + .unwrap_or_else(|error| panic!("{source} should validate: {error}")); + } + + for source in [ + "display:1", + "display:not-a-uuid", + "display:{7607E722-6D21-4812-8926-D93DBF8FDC58}", + "window:42", + "application:com.example.editor", + "primary-display", + "AUTO", + ] { + config.source = source.to_owned(); + assert!( + matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::Source { .. }) + ), + "{source} should be rejected" + ); + } +} + +#[test] +fn macos_capture_source_is_validated_while_capture_is_disabled() { + let config = CaptureConfig { + enabled: false, + source: "monitor:legacy-windows-id".to_owned(), + ..CaptureConfig::default() + }; + assert!(matches!( + config.validate_for_platform(CapturePlatform::MacosScreenCaptureKit), + Err(CaptureConfigValidationError::Source { .. }) + )); +} + #[test] fn unsupported_capture_platform_only_accepts_disabled_config() { let mut config = CaptureConfig { From 6b42c62cced9d9081343cd0709005d1775925716 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:39:38 -0700 Subject: [PATCH 008/144] feat(input): add exact two-axis scroll contract 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) --- .../src/effect/lightscript/payload.rs | 199 +++++++++++++----- .../src/input/interaction/mod.rs | 1 + crates/hypercolor-core/src/input/mod.rs | 4 +- crates/hypercolor-core/src/input/routing.rs | 11 + crates/hypercolor-core/src/input/scroll.rs | 53 +++++ crates/hypercolor-core/src/input/traits.rs | 42 +++- crates/hypercolor-core/tests/scroll_tests.rs | 48 +++++ crates/hypercolor-types/src/event.rs | 35 +++ crates/hypercolor-types/tests/event_tests.rs | 33 ++- 9 files changed, 365 insertions(+), 61 deletions(-) create mode 100644 crates/hypercolor-core/src/input/scroll.rs create mode 100644 crates/hypercolor-core/tests/scroll_tests.rs diff --git a/crates/hypercolor-core/src/effect/lightscript/payload.rs b/crates/hypercolor-core/src/effect/lightscript/payload.rs index fe0894341..d903c7708 100644 --- a/crates/hypercolor-core/src/effect/lightscript/payload.rs +++ b/crates/hypercolor-core/src/effect/lightscript/payload.rs @@ -161,6 +161,16 @@ impl LightScriptInteractionPayload { ny: sanitize_norm(interaction.mouse.norm_y), mode: pointer_mode_name(interaction.mouse.mode), wheel: interaction.batch.wheel_hi_res, + scroll: LightScriptScrollPayload { + line120_x: crate::input::q16_16_to_f64( + interaction.batch.scroll.line120_x_q16_16, + ), + line120_y: crate::input::q16_16_to_f64( + interaction.batch.scroll.line120_y_q16_16, + ), + pixel_x: crate::input::q16_16_to_f64(interaction.batch.scroll.pixel_x_q16_16), + pixel_y: crate::input::q16_16_to_f64(interaction.batch.scroll.pixel_y_q16_16), + }, velocity: if motion_per_sec.is_finite() { motion_per_sec } else { @@ -195,11 +205,21 @@ pub(super) struct LightScriptMousePayload { pub(super) mode: &'static str, #[serde(skip_serializing_if = "is_zero_i32")] pub(super) wheel: i32, + pub(super) scroll: LightScriptScrollPayload, pub(super) velocity: f32, } +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct LightScriptScrollPayload { + pub(super) line120_x: f64, + pub(super) line120_y: f64, + pub(super) pixel_x: f64, + pub(super) pixel_y: f64, +} + /// One ordered input edge for the frame, flattened for JS ergonomics. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct LightScriptInputEventPayload { pub(super) kind: &'static str, @@ -212,6 +232,16 @@ pub(super) struct LightScriptInputEventPayload { pub(super) state: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] pub(super) delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) delta_x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) delta_y: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) unit: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) phase: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) momentum_phase: Option<&'static str>, pub(super) at_ms: u64, pub(super) seq: u64, #[serde(skip_serializing_if = "Option::is_none")] @@ -223,62 +253,84 @@ impl LightScriptInputEventPayload { fn from_timed(timed: &hypercolor_types::event::TimedInputEvent) -> Option { use hypercolor_types::event::InputEvent; - let (kind, source, key, button, state, delta) = match &timed.event { - InputEvent::Key { - source_id, - key, - state, - } => ( - "key", - source_id.clone(), - Some(key.clone()), - None, - Some(button_state_name(*state)), - None, - ), - InputEvent::MouseButton { - source_id, - button, - state, - } => ( - "button", - source_id.clone(), - None, - Some(button.clone()), - Some(button_state_name(*state)), - None, - ), - InputEvent::MouseWheel { - source_id, - delta_hi_res, - } => ( - "wheel", - source_id.clone(), - None, - None, - None, - Some(*delta_hi_res), - ), + let mut payload = Self { + kind: "", + source: timed.event.source_id().to_owned(), + key: None, + button: None, + state: None, + delta: None, + delta_x: None, + delta_y: None, + unit: None, + phase: None, + momentum_phase: None, + at_ms: timed.at_ms, + seq: timed.seq, + physical_code: timed.physical_code.clone(), + repeat_count: timed.repeat_count, + }; + + match &timed.event { + InputEvent::Key { key, state, .. } => { + payload.kind = "key"; + payload.key = Some(key.clone()); + payload.state = Some(button_state_name(*state)); + } + InputEvent::MouseButton { button, state, .. } => { + payload.kind = "button"; + payload.button = Some(button.clone()); + payload.state = Some(button_state_name(*state)); + } + InputEvent::MouseWheel { delta_hi_res, .. } => { + payload.kind = "wheel"; + payload.delta = Some(*delta_hi_res); + } + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16, + unit, + phase, + momentum_phase, + .. + } => { + payload.kind = "scroll"; + payload.delta_x = Some(crate::input::q16_16_to_f64(*delta_x_q16_16)); + payload.delta_y = Some(crate::input::q16_16_to_f64(*delta_y_q16_16)); + payload.unit = Some(pointer_scroll_unit_name(*unit)); + payload.phase = Some(pointer_scroll_phase_name(*phase)); + payload.momentum_phase = Some(pointer_scroll_phase_name(*momentum_phase)); + } // MIDI edges stay on the event bus; they are not part of the // effect-facing interaction contract yet. InputEvent::MidiNote { .. } | InputEvent::MidiControlChange { .. } | InputEvent::MidiPitchBend { .. } | InputEvent::MidiRealtime { .. } => return None, - }; + } - Some(Self { - kind, - source, - key, - button, - state, - delta, - at_ms: timed.at_ms, - seq: timed.seq, - physical_code: timed.physical_code.clone(), - repeat_count: timed.repeat_count, - }) + Some(payload) + } +} + +fn pointer_scroll_unit_name(unit: hypercolor_types::event::PointerScrollUnit) -> &'static str { + match unit { + hypercolor_types::event::PointerScrollUnit::Line120 => "line120", + hypercolor_types::event::PointerScrollUnit::Pixels => "pixels", + } +} + +fn pointer_scroll_phase_name(phase: hypercolor_types::event::PointerScrollPhase) -> &'static str { + use hypercolor_types::event::PointerScrollPhase; + + match phase { + PointerScrollPhase::None => "none", + PointerScrollPhase::MayBegin => "may_begin", + PointerScrollPhase::Began => "began", + PointerScrollPhase::Changed => "changed", + PointerScrollPhase::Stationary => "stationary", + PointerScrollPhase::Ended => "ended", + PointerScrollPhase::Cancelled => "cancelled", } } @@ -720,6 +772,7 @@ mod tests { ny: 0.75, mode: "virtual", wheel: 120, + scroll: LightScriptScrollPayload::default(), velocity: 0.5, }, events: Vec::new(), @@ -845,8 +898,10 @@ mod tests { #[cfg(test)] mod interaction_payload_v2_tests { use super::*; - use crate::input::{InteractionData, MotionAggregate, PointerMode}; - use hypercolor_types::event::{InputButtonState, InputEvent, TimedInputEvent}; + use crate::input::{InteractionData, MotionAggregate, PointerMode, ScrollAggregate}; + use hypercolor_types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, + }; #[test] fn interaction_payload_carries_events_wheel_and_velocity() { @@ -855,6 +910,12 @@ mod interaction_payload_v2_tests { interaction.mouse.norm_y = 2.0; // clamped interaction.mouse.mode = PointerMode::Virtual; interaction.batch.wheel_hi_res = -240; + interaction.batch.scroll = ScrollAggregate { + line120_x_q16_16: 32_768, + line120_y_q16_16: -131_072, + pixel_x_q16_16: 98_304, + pixel_y_q16_16: -16_384, + }; interaction.batch.motion = MotionAggregate { dx: 0.1, dy: 0.0, @@ -873,13 +934,27 @@ mod interaction_payload_v2_tests { physical_code: Some("evdev:key:30".into()), repeat_count: 3, }, + TimedInputEvent { + event: InputEvent::PointerScroll { + source_id: "ptr".into(), + delta_x_q16_16: 32_768, + delta_y_q16_16: -16_384, + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::Began, + }, + at_ms: 104, + seq: 10, + physical_code: Some("macos:scroll".into()), + repeat_count: 1, + }, TimedInputEvent { event: InputEvent::MouseWheel { source_id: "ptr".into(), delta_hi_res: -240, }, at_ms: 105, - seq: 10, + seq: 11, physical_code: None, repeat_count: 1, }, @@ -889,7 +964,7 @@ mod interaction_payload_v2_tests { message: hypercolor_types::event::MidiRealtimeMessage::Clock, }, at_ms: 106, - seq: 11, + seq: 12, physical_code: Some("midi:realtime:clock".into()), repeat_count: 1, }, @@ -902,11 +977,15 @@ mod interaction_payload_v2_tests { assert_eq!(value["mouse"]["ny"], serde_json::json!(1.0)); assert_eq!(value["mouse"]["mode"], serde_json::json!("virtual")); assert_eq!(value["mouse"]["wheel"], serde_json::json!(-240)); + assert_eq!(value["mouse"]["scroll"]["line120X"], 0.5); + assert_eq!(value["mouse"]["scroll"]["line120Y"], -2.0); + assert_eq!(value["mouse"]["scroll"]["pixelX"], 1.5); + assert_eq!(value["mouse"]["scroll"]["pixelY"], -0.25); assert!(value["mouse"]["velocity"].as_f64().expect("velocity") > 8.9); assert_eq!(value["dropped"], serde_json::json!(2)); let events = value["events"].as_array().expect("events array"); - assert_eq!(events.len(), 2, "MIDI edges stay off the effect contract"); + assert_eq!(events.len(), 3, "MIDI edges stay off the effect contract"); assert_eq!(events[0]["kind"], serde_json::json!("key")); assert_eq!(events[0]["physicalCode"], serde_json::json!("evdev:key:30")); assert_eq!(events[0]["repeatCount"], serde_json::json!(3)); @@ -914,8 +993,14 @@ mod interaction_payload_v2_tests { assert_eq!(events[0]["state"], serde_json::json!("pressed")); assert_eq!(events[0]["atMs"], serde_json::json!(100)); assert_eq!(events[0]["seq"], serde_json::json!(9)); - assert_eq!(events[1]["kind"], serde_json::json!("wheel")); - assert_eq!(events[1]["delta"], serde_json::json!(-240)); + assert_eq!(events[1]["kind"], serde_json::json!("scroll")); + assert_eq!(events[1]["deltaX"], 0.5); + assert_eq!(events[1]["deltaY"], -0.25); + assert_eq!(events[1]["unit"], "pixels"); + assert_eq!(events[1]["phase"], "changed"); + assert_eq!(events[1]["momentumPhase"], "began"); + assert_eq!(events[2]["kind"], serde_json::json!("wheel")); + assert_eq!(events[2]["delta"], serde_json::json!(-240)); } #[test] diff --git a/crates/hypercolor-core/src/input/interaction/mod.rs b/crates/hypercolor-core/src/input/interaction/mod.rs index 23fcfdf70..cc00aac2b 100644 --- a/crates/hypercolor-core/src/input/interaction/mod.rs +++ b/crates/hypercolor-core/src/input/interaction/mod.rs @@ -531,6 +531,7 @@ fn project_recent_keys(target: &mut Vec, events: &[TimedInputEvent]) { InputEvent::Key { .. } | InputEvent::MouseButton { .. } | InputEvent::MouseWheel { .. } + | InputEvent::PointerScroll { .. } | InputEvent::MidiNote { .. } | InputEvent::MidiControlChange { .. } | InputEvent::MidiPitchBend { .. } diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index a18a4a243..e3d9624e1 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -16,6 +16,7 @@ pub mod media; pub mod net; pub mod routing; pub mod screen; +mod scroll; pub mod sensor; mod status; mod traits; @@ -39,6 +40,7 @@ pub use interaction::InteractionInput; pub use media::MediaSource; pub use net::NetSource; pub use screen::{ScreenCaptureDemand, ScreenPublicationDemandSnapshot}; +pub use scroll::{LegacyWheelProjector, Q16_16_SCALE, q16_16_to_f64}; pub use sensor::SensorPoller; pub use status::{ ScreenCaptureDiagnostics, ScreenCaptureReductionPath, SourceDiagnostics, SourceFreshness, @@ -51,7 +53,7 @@ pub use status::{ pub use traits::{ InputData, InputSource, InteractionBatch, InteractionData, InteractionDegradation, InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, ScreenData, - ScreenZoneColors, + ScreenZoneColors, ScrollAggregate, }; pub use windows::WindowsHostInput; #[cfg(all(target_os = "windows", feature = "windows-capture-fixtures"))] diff --git a/crates/hypercolor-core/src/input/routing.rs b/crates/hypercolor-core/src/input/routing.rs index 5b983f938..6d3ced721 100644 --- a/crates/hypercolor-core/src/input/routing.rs +++ b/crates/hypercolor-core/src/input/routing.rs @@ -848,6 +848,7 @@ impl ConsumerRouteState { interaction.mouse.mode = super::PointerMode::None; interaction.mouse.injected = false; interaction.batch.wheel_hi_res = 0; + interaction.batch.scroll = super::ScrollAggregate::default(); interaction.batch.motion = super::MotionAggregate::default(); interaction.batch.window_secs = 0.0; interaction.batch.dropped_events = 0; @@ -905,6 +906,15 @@ impl ConsumerRouteState { interaction.batch.wheel_hi_res = interaction.batch.wheel_hi_res.saturating_add(*delta_hi_res); } + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16, + unit, + .. + } => interaction + .batch + .scroll + .accumulate(*unit, *delta_x_q16_16, *delta_y_q16_16), InputEvent::Key { .. } | InputEvent::MouseButton { .. } | InputEvent::MidiNote { .. } @@ -1250,6 +1260,7 @@ fn synthetic_release(press: &TimedInputEvent, now_ms: u64) -> TimedInputEvent { | InputEvent::MouseButton { state, .. } | InputEvent::MidiNote { state, .. } => *state = InputButtonState::Released, InputEvent::MouseWheel { .. } + | InputEvent::PointerScroll { .. } | InputEvent::MidiControlChange { .. } | InputEvent::MidiPitchBend { .. } | InputEvent::MidiRealtime { .. } => { diff --git a/crates/hypercolor-core/src/input/scroll.rs b/crates/hypercolor-core/src/input/scroll.rs new file mode 100644 index 000000000..56f2f625f --- /dev/null +++ b/crates/hypercolor-core/src/input/scroll.rs @@ -0,0 +1,53 @@ +//! Exact pointer-scroll arithmetic shared by every host producer. + +/// Scale factor for signed Q16.16 scroll values. +pub const Q16_16_SCALE: i64 = 1 << 16; + +/// Per-source projector from exact line scroll to the legacy integral wheel signal. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct LegacyWheelProjector { + remainder_q16_16: i64, +} + +impl LegacyWheelProjector { + /// Project vertical `Line120` motion while retaining signed fractions. + #[must_use] + pub fn project(&mut self, delta_y_q16_16: i64) -> i32 { + let total = i128::from(self.remainder_q16_16) + i128::from(delta_y_q16_16); + let integral = total / i128::from(Q16_16_SCALE); + let remainder = total % i128::from(Q16_16_SCALE); + self.remainder_q16_16 = + i64::try_from(remainder).expect("a Q16.16 remainder always fits in i64"); + i32::try_from(integral).unwrap_or_else(|_| { + if integral.is_negative() { + i32::MIN + } else { + i32::MAX + } + }) + } + + /// Signed fractional motion retained for the next event. + #[must_use] + pub const fn remainder_q16_16(self) -> i64 { + self.remainder_q16_16 + } + + /// Clear fractional state after a source gap or generation change. + pub fn reset(&mut self) { + self.remainder_q16_16 = 0; + } +} + +/// Convert a signed Q16.16 value to its floating representation. +#[must_use] +pub fn q16_16_to_f64(value: i64) -> f64 { + #[expect( + clippy::cast_precision_loss, + clippy::as_conversions, + reason = "effect payloads expose Q16.16 values as JavaScript numbers" + )] + { + value as f64 / Q16_16_SCALE as f64 + } +} diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 1b98484c4..7847d5cd2 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -9,7 +9,7 @@ use super::status::{SourceStatusError, SourceStatusHandle, SourceStatusReporter} use crate::input::audio::{AudioRuntimeRetirement, PreparedAudioReconfiguration}; use crate::types::audio::{AudioData, AudioPipelineConfig}; use crate::types::canvas::{PublishedSurface, SurfaceResourceOwner}; -use crate::types::event::{TimedInputEvent, ZoneColors}; +use crate::types::event::{PointerScrollUnit, TimedInputEvent, ZoneColors}; use hypercolor_types::sensor::SystemSnapshot; use std::ops::Deref; use std::sync::Arc; @@ -114,6 +114,7 @@ impl InteractionData { .batch .wheel_hi_res .saturating_add(other.batch.wheel_hi_res); + self.batch.scroll.absorb(other.batch.scroll); self.batch.motion.dx += other.batch.motion.dx; self.batch.motion.dy += other.batch.motion.dy; self.batch.motion.distance += other.batch.motion.distance; @@ -157,6 +158,7 @@ impl InteractionData { .batch .wheel_hi_res .saturating_add(other.batch.wheel_hi_res); + self.batch.scroll.absorb(other.batch.scroll); self.batch.motion.dx += other.batch.motion.dx; self.batch.motion.dy += other.batch.motion.dy; self.batch.motion.distance += other.batch.motion.distance; @@ -289,6 +291,8 @@ pub struct InteractionBatch { pub events: Vec, /// Accumulated wheel travel since last frame, in 1/120-notch units. pub wheel_hi_res: i32, + /// Exact two-axis scroll totals since the previous frame. + pub scroll: ScrollAggregate, /// Aggregate pointer motion since last frame. pub motion: MotionAggregate, /// Wall-clock span the motion aggregate covers, in seconds. @@ -310,6 +314,7 @@ impl InteractionBatch { pub fn is_empty(&self) -> bool { self.events.is_empty() && self.wheel_hi_res == 0 + && self.scroll == ScrollAggregate::default() && self.motion == MotionAggregate::default() && self.dropped_events == 0 } @@ -336,6 +341,7 @@ impl InteractionBatch { } self.wheel_hi_res = self.wheel_hi_res.saturating_add(prior.wheel_hi_res); + self.scroll.absorb(prior.scroll); self.motion.dx += prior.motion.dx; self.motion.dy += prior.motion.dy; self.motion.distance += prior.motion.distance; @@ -343,6 +349,40 @@ impl InteractionBatch { } } +/// Exact two-axis scroll accumulated independently by coordinate unit. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ScrollAggregate { + pub line120_x_q16_16: i64, + pub line120_y_q16_16: i64, + pub pixel_x_q16_16: i64, + pub pixel_y_q16_16: i64, +} + +impl ScrollAggregate { + /// Add one exact scroll delta with saturating overflow behavior. + pub fn accumulate( + &mut self, + unit: PointerScrollUnit, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + ) { + let (x, y) = match unit { + PointerScrollUnit::Line120 => (&mut self.line120_x_q16_16, &mut self.line120_y_q16_16), + PointerScrollUnit::Pixels => (&mut self.pixel_x_q16_16, &mut self.pixel_y_q16_16), + }; + *x = x.saturating_add(delta_x_q16_16); + *y = y.saturating_add(delta_y_q16_16); + } + + /// Fold another aggregate into this one with saturating arithmetic. + pub fn absorb(&mut self, other: Self) { + self.line120_x_q16_16 = self.line120_x_q16_16.saturating_add(other.line120_x_q16_16); + self.line120_y_q16_16 = self.line120_y_q16_16.saturating_add(other.line120_y_q16_16); + self.pixel_x_q16_16 = self.pixel_x_q16_16.saturating_add(other.pixel_x_q16_16); + self.pixel_y_q16_16 = self.pixel_y_q16_16.saturating_add(other.pixel_y_q16_16); + } +} + /// Summed pointer motion for one frame, in normalized canvas units. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct MotionAggregate { diff --git a/crates/hypercolor-core/tests/scroll_tests.rs b/crates/hypercolor-core/tests/scroll_tests.rs new file mode 100644 index 000000000..d3210fa0b --- /dev/null +++ b/crates/hypercolor-core/tests/scroll_tests.rs @@ -0,0 +1,48 @@ +use hypercolor_core::input::{LegacyWheelProjector, Q16_16_SCALE, ScrollAggregate, q16_16_to_f64}; +use hypercolor_types::event::PointerScrollUnit; + +#[test] +fn legacy_projection_carries_signed_fractional_remainders() { + let mut projector = LegacyWheelProjector::default(); + + assert_eq!(projector.project(Q16_16_SCALE / 3), 0); + assert_eq!(projector.project(Q16_16_SCALE / 3), 0); + assert_eq!(projector.project(Q16_16_SCALE / 3 + 1), 1); + assert_eq!(projector.remainder_q16_16(), 0); + + assert_eq!(projector.project(-Q16_16_SCALE / 2), 0); + assert_eq!(projector.project(-Q16_16_SCALE / 2), -1); + assert_eq!(projector.remainder_q16_16(), 0); +} + +#[test] +fn legacy_projection_reset_discards_pre_gap_fraction() { + let mut projector = LegacyWheelProjector::default(); + assert_eq!(projector.project(Q16_16_SCALE - 1), 0); + projector.reset(); + assert_eq!(projector.project(1), 0); +} + +#[test] +fn scroll_aggregate_keeps_units_and_axes_independent() { + let mut aggregate = ScrollAggregate::default(); + aggregate.accumulate(PointerScrollUnit::Line120, 1, 2); + aggregate.accumulate(PointerScrollUnit::Pixels, 3, 4); + aggregate.absorb(ScrollAggregate { + line120_x_q16_16: 5, + line120_y_q16_16: 6, + pixel_x_q16_16: 7, + pixel_y_q16_16: 8, + }); + + assert_eq!(aggregate.line120_x_q16_16, 6); + assert_eq!(aggregate.line120_y_q16_16, 8); + assert_eq!(aggregate.pixel_x_q16_16, 10); + assert_eq!(aggregate.pixel_y_q16_16, 12); +} + +#[test] +fn q16_16_conversion_preserves_fractional_sign() { + assert_eq!(q16_16_to_f64(Q16_16_SCALE / 2), 0.5); + assert_eq!(q16_16_to_f64(-Q16_16_SCALE / 4), -0.25); +} diff --git a/crates/hypercolor-types/src/event.rs b/crates/hypercolor-types/src/event.rs index 8ba99f883..43a618620 100644 --- a/crates/hypercolor-types/src/event.rs +++ b/crates/hypercolor-types/src/event.rs @@ -212,6 +212,30 @@ pub enum InputButtonState { Repeated, } +/// Coordinate unit carried by a two-axis pointer scroll event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PointerScrollUnit { + /// Integral units are 1/120 of one physical wheel notch. + Line120, + /// Integral units are display-space pixels. + Pixels, +} + +/// Lifecycle phase for a pointer scroll gesture. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PointerScrollPhase { + #[default] + None, + MayBegin, + Began, + Changed, + Stationary, + Ended, + Cancelled, +} + /// MIDI transport-control messages that matter to rhythmic lighting. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -246,6 +270,16 @@ pub enum InputEvent { delta_hi_res: i32, }, + /// Two-axis pointer scroll with exact signed Q16.16 deltas. + PointerScroll { + source_id: String, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: PointerScrollUnit, + phase: PointerScrollPhase, + momentum_phase: PointerScrollPhase, + }, + /// A MIDI note changed state. MidiNote { source_id: String, @@ -285,6 +319,7 @@ impl InputEvent { Self::Key { source_id, .. } | Self::MouseButton { source_id, .. } | Self::MouseWheel { source_id, .. } + | Self::PointerScroll { source_id, .. } | Self::MidiNote { source_id, .. } | Self::MidiControlChange { source_id, .. } | Self::MidiPitchBend { source_id, .. } diff --git a/crates/hypercolor-types/tests/event_tests.rs b/crates/hypercolor-types/tests/event_tests.rs index 000bfb85f..515eba1be 100644 --- a/crates/hypercolor-types/tests/event_tests.rs +++ b/crates/hypercolor-types/tests/event_tests.rs @@ -8,8 +8,8 @@ use hypercolor_types::event::{ AssetChangeKind, ChangeTrigger, ContextType, DisconnectReason, EffectDegradationState, EffectRef, EffectStopReason, EventCategory, EventControlValue, EventPriority, FrameData, FrameTiming, HypercolorEvent, InputButtonState, InputEvent, LayerHealth, LayerStackChangeKind, - SceneChangeReason, Severity, TimedInputEvent, TransitionRef, ZoneChangeKind, ZoneColors, - ZoneRef, + PointerScrollPhase, PointerScrollUnit, SceneChangeReason, Severity, TimedInputEvent, + TransitionRef, ZoneChangeKind, ZoneColors, ZoneRef, }; use hypercolor_types::layer::SceneLayerId; use hypercolor_types::scene::{SceneId, SceneKind, SceneMutationMode, ZoneId, ZoneRole}; @@ -1250,6 +1250,35 @@ fn mouse_input_events_round_trip_through_json() { assert_eq!(restored.source_id(), "host:/dev/input/event4"); } +#[test] +fn pointer_scroll_round_trips_exact_q16_16_metadata() { + let scroll = InputEvent::PointerScroll { + source_id: "host:trackpad".into(), + delta_x_q16_16: -32_768, + delta_y_q16_16: 98_304, + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::Began, + }; + + let json = serde_json::to_value(&scroll).expect("serialize pointer scroll"); + assert_eq!(json["kind"], "pointer_scroll"); + assert_eq!(json["delta_x_q16_16"], -32_768); + assert_eq!(json["delta_y_q16_16"], 98_304); + assert_eq!(json["unit"], "pixels"); + assert_eq!(json["phase"], "changed"); + assert_eq!(json["momentum_phase"], "began"); + + let restored: InputEvent = serde_json::from_value(json).expect("deserialize pointer scroll"); + assert_eq!(restored, scroll); + assert_eq!(restored.source_id(), "host:trackpad"); +} + +#[test] +fn pointer_scroll_phase_defaults_to_none() { + assert_eq!(PointerScrollPhase::default(), PointerScrollPhase::None); +} + #[test] fn timed_input_event_seq_defaults_to_zero_when_absent() { let json = r#"{"event":{"kind":"key","source_id":"s","key":"a","state":"pressed"},"at_ms":5}"#; From 362267a06e2ae1161dc184819aea7656257f128f Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:43:04 -0700 Subject: [PATCH 009/144] feat(input): preserve Windows horizontal scroll 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) --- crates/hypercolor-core/src/input/windows.rs | 92 +++++++++++++++---- .../tests/windows_host_input_tests.rs | 34 +++++-- .../examples/dump_input.rs | 8 +- crates/hypercolor-windows-input/src/decode.rs | 30 +++--- crates/hypercolor-windows-input/src/pump.rs | 13 +-- crates/hypercolor-windows-input/src/shared.rs | 10 +- .../tests/decode_tests.rs | 47 +++++++--- 7 files changed, 166 insertions(+), 68 deletions(-) diff --git a/crates/hypercolor-core/src/input/windows.rs b/crates/hypercolor-core/src/input/windows.rs index e381fd619..c2cc26743 100644 --- a/crates/hypercolor-core/src/input/windows.rs +++ b/crates/hypercolor-core/src/input/windows.rs @@ -38,10 +38,12 @@ use crate::input::traits::{ InputData, InputSource, InteractionData, InteractionDegradation, MotionAggregate, PointerMode, }; use crate::input::{ - SourceIssue, SourceKind, SourceSessionSlot, SourceStatusHandle, SourceStatusReporter, - TerminalFailureLatch, + LegacyWheelProjector, SourceIssue, SourceKind, SourceSessionSlot, SourceStatusHandle, + SourceStatusReporter, TerminalFailureLatch, +}; +use crate::types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, }; -use crate::types::event::{InputButtonState, InputEvent, TimedInputEvent}; const DEFAULT_EVENT_LIMIT: usize = 256; @@ -86,6 +88,7 @@ struct SharedState { pointer_present: bool, devices: BTreeMap, absolute_baselines: BTreeMap, + legacy_wheel_projectors: BTreeMap, /// Batches stamped with any other epoch are inert. See [`WindowsHostInput`]. epoch: u64, } @@ -123,6 +126,7 @@ impl SharedState { self.pointer_present = false; self.devices.clear(); self.absolute_baselines.clear(); + self.legacy_wheel_projectors.clear(); } fn pointer_devices(&self) -> bool { @@ -804,21 +808,16 @@ fn fold_event(state: &mut SharedState, event: &RawInputEvent, at_ms: u64, event_ at_ms, event_limit, ), - RawInputEvent::Wheel { + RawInputEvent::Scroll { device, - delta_hi_res, - } => push_event( + delta_x_q16_16, + delta_y_q16_16, + } => fold_scroll( state, - TimedInputEvent { - event: InputEvent::MouseWheel { - source_id: device.source_id.to_string(), - delta_hi_res: *delta_hi_res, - }, - at_ms, - seq: 0, - physical_code: Some("windows:wheel:vertical".to_owned()), - repeat_count: 1, - }, + &device.source_id, + *delta_x_q16_16, + *delta_y_q16_16, + at_ms, event_limit, ), RawInputEvent::MotionRelative { dx, dy, .. } => { @@ -858,6 +857,7 @@ fn fold_event(state: &mut SharedState, event: &RawInputEvent, at_ms: u64, event_ // from a retired generation. Duplicate metadata refreshes do // not destroy a baseline established by earlier data. state.absolute_baselines.remove(source_id.as_str()); + state.legacy_wheel_projectors.remove(source_id.as_str()); } } RawInputEvent::DeviceRemoved { device } => { @@ -866,6 +866,7 @@ fn fold_event(state: &mut SharedState, event: &RawInputEvent, at_ms: u64, event_ debug!(device = %entry.descriptor.label, "Raw Input device removed"); } state.absolute_baselines.remove(source_id.as_ref()); + state.legacy_wheel_projectors.remove(source_id.as_ref()); synthesize_releases(state, source_id, at_ms, event_limit); } RawInputEvent::StateGap { device } => { @@ -875,11 +876,70 @@ fn fold_event(state: &mut SharedState, event: &RawInputEvent, at_ms: u64, event_ // honest answer: deferring to a quiet moment would leave keys stuck // for as long as the user keeps mashing, which is the whole time. state.absolute_baselines.remove(source_id.as_ref()); + state.legacy_wheel_projectors.remove(source_id.as_ref()); synthesize_releases(state, source_id, at_ms, event_limit); } } } +fn fold_scroll( + state: &mut SharedState, + source_id: &str, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + at_ms: u64, + event_limit: usize, +) { + let physical_code = if delta_x_q16_16 == 0 { + "windows:RI_MOUSE_WHEEL" + } else if delta_y_q16_16 == 0 { + "windows:RI_MOUSE_HWHEEL" + } else { + "windows:scroll" + }; + push_event( + state, + TimedInputEvent { + event: InputEvent::PointerScroll { + source_id: source_id.to_owned(), + delta_x_q16_16, + delta_y_q16_16, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::None, + momentum_phase: PointerScrollPhase::None, + }, + at_ms, + seq: 0, + physical_code: Some(physical_code.to_owned()), + repeat_count: 1, + }, + event_limit, + ); + + let legacy_delta = state + .legacy_wheel_projectors + .entry(source_id.to_owned()) + .or_default() + .project(delta_y_q16_16); + if legacy_delta == 0 { + return; + } + push_event( + state, + TimedInputEvent { + event: InputEvent::MouseWheel { + source_id: source_id.to_owned(), + delta_hi_res: legacy_delta, + }, + at_ms, + seq: 0, + physical_code: Some("windows:legacy-wheel-shadow".to_owned()), + repeat_count: 1, + }, + event_limit, + ); +} + #[expect( clippy::too_many_arguments, reason = "the key report's fields plus the fold context; bundling them would \ diff --git a/crates/hypercolor-core/tests/windows_host_input_tests.rs b/crates/hypercolor-core/tests/windows_host_input_tests.rs index a151fdf4e..de6e06edd 100644 --- a/crates/hypercolor-core/tests/windows_host_input_tests.rs +++ b/crates/hypercolor-core/tests/windows_host_input_tests.rs @@ -10,8 +10,8 @@ use std::sync::Arc; -use hypercolor_core::input::{PointerMode, WindowsHostInput}; -use hypercolor_core::types::event::{InputButtonState, InputEvent}; +use hypercolor_core::input::{PointerMode, Q16_16_SCALE, WindowsHostInput}; +use hypercolor_core::types::event::{InputButtonState, InputEvent, PointerScrollUnit}; use hypercolor_windows_input::{ RawButton, RawCursor, RawDeviceDescriptor, RawDeviceKind, RawInputBatch, RawInputEvent, RawKeyPrefix, @@ -244,17 +244,27 @@ fn a_click_in_one_batch_leaves_nothing_held() { } #[test] -fn wheel_travel_reaches_the_event_bus_unscaled() { +fn vertical_scroll_emits_exact_event_then_legacy_shadow() { let mut input = WindowsHostInput::new(true, true); let (_, events) = fold( &mut input, - &[RawInputEvent::Wheel { + &[RawInputEvent::Scroll { device: device(MOUSE, RawDeviceKind::Mouse), - delta_hi_res: -120, + delta_x_q16_16: 0, + delta_y_q16_16: -120 * Q16_16_SCALE, }], ); assert!(matches!( &events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16: 0, + delta_y_q16_16, + unit: PointerScrollUnit::Line120, + .. + } if *delta_y_q16_16 == -120 * Q16_16_SCALE + )); + assert!(matches!( + &events[1].event, InputEvent::MouseWheel { delta_hi_res: -120, .. @@ -262,7 +272,11 @@ fn wheel_travel_reaches_the_event_bus_unscaled() { )); assert_eq!( events[0].physical_code.as_deref(), - Some("windows:wheel:vertical") + Some("windows:RI_MOUSE_WHEEL") + ); + assert_eq!( + events[1].physical_code.as_deref(), + Some("windows:legacy-wheel-shadow") ); } @@ -640,9 +654,10 @@ fn a_batch_at_the_live_epoch_is_applied() { fn the_event_queue_drops_oldest_and_counts_what_it_dropped() { let mut input = WindowsHostInput::new(true, true); let events = (0..600) - .map(|delta_hi_res| RawInputEvent::Wheel { + .map(|delta| RawInputEvent::Scroll { device: device(MOUSE, RawDeviceKind::Mouse), - delta_hi_res, + delta_x_q16_16: i64::from(delta) * Q16_16_SCALE, + delta_y_q16_16: 0, }) .collect::>(); let (data, drained) = fold(&mut input, &events); @@ -653,7 +668,8 @@ fn the_event_queue_drops_oldest_and_counts_what_it_dropped() { let expected = i32::try_from(index).expect("index fits") + 344; matches!( &timed.event, - InputEvent::MouseWheel { delta_hi_res, .. } if *delta_hi_res == expected + InputEvent::PointerScroll { delta_x_q16_16, delta_y_q16_16: 0, .. } + if *delta_x_q16_16 == i64::from(expected) * Q16_16_SCALE ) })); } diff --git a/crates/hypercolor-windows-input/examples/dump_input.rs b/crates/hypercolor-windows-input/examples/dump_input.rs index fd3577e83..7f7adcc55 100644 --- a/crates/hypercolor-windows-input/examples/dump_input.rs +++ b/crates/hypercolor-windows-input/examples/dump_input.rs @@ -132,11 +132,13 @@ fn windows_main() { if *pressed { "down" } else { "up" }, short_id(&device.source_id) ), - RawInputEvent::Wheel { + RawInputEvent::Scroll { device, - delta_hi_res, + delta_x_q16_16, + delta_y_q16_16, } => println!( - "{:>8} #{batch_no:<5} wheel {delta_hi_res:+} {}", + "{:>8} #{batch_no:<5} scroll x={delta_x_q16_16:+} \ + y={delta_y_q16_16:+} {}", batch.at_ms, short_id(&device.source_id) ), diff --git a/crates/hypercolor-windows-input/src/decode.rs b/crates/hypercolor-windows-input/src/decode.rs index f1f5d876e..800467c0b 100644 --- a/crates/hypercolor-windows-input/src/decode.rs +++ b/crates/hypercolor-windows-input/src/decode.rs @@ -24,6 +24,9 @@ const VKEY_UNMAPPED: u16 = 0xFF; /// wheel travel passes through with no conversion. pub const WHEEL_DELTA: i32 = 120; +/// Scale factor for the shared signed Q16.16 scroll representation. +pub const SCROLL_Q16_16_SCALE: i64 = 1 << 16; + /// Absolute pointer reports span this range over their chosen rect. const ABSOLUTE_RANGE: f32 = 65535.0; @@ -196,26 +199,23 @@ pub fn button_edges(flags: u32) -> Vec<(RawButton, bool)> { edges } -/// Vertical wheel travel, or `None` when this report carries no wheel. +/// Two-axis wheel travel in signed Q16.16 `Line120` units. /// /// `usButtonData` is declared `u16` but carries a signed value: scroll-down /// arrives as `0xFF88`. Widening the `u16` yields 65416 instead of −120, so -/// the reinterpretation is mandatory. `RI_MOUSE_HWHEEL` returns `None` — the -/// shared event contract has no horizontal axis, and reporting horizontal -/// scroll through the vertical channel would be a silent lie. +/// the reinterpretation is mandatory. One Raw Input report owns one data +/// field, so a malformed record with both wheel flags set resolves to the +/// vertical axis rather than duplicating one value across both axes. #[must_use] -pub const fn wheel_delta(flags: u32, button_data: u16) -> Option { - if flags & button_flags::WHEEL == 0 { - return None; +pub const fn scroll_delta_q16_16(flags: u32, button_data: u16) -> Option<(i64, i64)> { + let delta = (button_data.cast_signed() as i64) << 16; + if flags & button_flags::WHEEL != 0 { + Some((0, delta)) + } else if flags & button_flags::HWHEEL != 0 { + Some((delta, 0)) + } else { + None } - Some(button_data.cast_signed() as i32) -} - -/// Whether this report carries a horizontal wheel, which is deliberately -/// dropped rather than folded into the vertical channel. -#[must_use] -pub const fn is_horizontal_wheel(flags: u32) -> bool { - flags & button_flags::HWHEEL != 0 } /// A screen rectangle in physical pixels. diff --git a/crates/hypercolor-windows-input/src/pump.rs b/crates/hypercolor-windows-input/src/pump.rs index 53bdfc983..ed504fd3f 100644 --- a/crates/hypercolor-windows-input/src/pump.rs +++ b/crates/hypercolor-windows-input/src/pump.rs @@ -44,7 +44,7 @@ use windows::core::{PCWSTR, w}; use crate::claim::PROCESS_CLAIM; use crate::decode::{ AbsoluteSpace, CanonicalKeyReport, KeyCanonicalizer, MotionKind, RecordStep, button_edges, - is_horizontal_wheel, motion_kind, next_record, normalize_absolute, wheel_delta, + motion_kind, next_record, normalize_absolute, scroll_delta_q16_16, }; use crate::devices::{DeviceCache, DeviceResolution, enumerate_devices, seed_cache}; use crate::metrics::{MonitorTopology, monitor_topology, pin_dpi_context, sample_cursor}; @@ -869,13 +869,14 @@ impl Pump { }); } - if let Some(delta) = wheel_delta(button_flags, button_data) { - self.events.push(RawInputEvent::Wheel { + if let Some((delta_x_q16_16, delta_y_q16_16)) = + scroll_delta_q16_16(button_flags, button_data) + { + self.events.push(RawInputEvent::Scroll { device: Arc::clone(device), - delta_hi_res: delta, + delta_x_q16_16, + delta_y_q16_16, }); - } else if is_horizontal_wheel(button_flags) { - tracing::trace!("dropping horizontal wheel: the shared event contract has no axis"); } match motion_kind(flags) { diff --git a/crates/hypercolor-windows-input/src/shared.rs b/crates/hypercolor-windows-input/src/shared.rs index 60822fea7..8d7ca6867 100644 --- a/crates/hypercolor-windows-input/src/shared.rs +++ b/crates/hypercolor-windows-input/src/shared.rs @@ -93,11 +93,11 @@ pub enum RawInputEvent { button: RawButton, pressed: bool, }, - /// Vertical wheel travel in 1/120-notch units, matching evdev's - /// `REL_WHEEL_HI_RES`. Horizontal wheel is dropped rather than folded in. - Wheel { + /// Two-axis wheel travel in signed Q16.16 `Line120` units. + Scroll { device: Arc, - delta_hi_res: i32, + delta_x_q16_16: i64, + delta_y_q16_16: i64, }, /// Relative counts from a normal mouse. MotionRelative { @@ -145,7 +145,7 @@ impl RawInputEvent { match self { Self::Key { device, .. } | Self::Button { device, .. } - | Self::Wheel { device, .. } + | Self::Scroll { device, .. } | Self::MotionRelative { device, .. } | Self::MotionAbsolute { device, .. } | Self::DeviceArrived { device } diff --git a/crates/hypercolor-windows-input/tests/decode_tests.rs b/crates/hypercolor-windows-input/tests/decode_tests.rs index f78c9a37e..2a1ad2975 100644 --- a/crates/hypercolor-windows-input/tests/decode_tests.rs +++ b/crates/hypercolor-windows-input/tests/decode_tests.rs @@ -6,9 +6,9 @@ use hypercolor_windows_input::decode::{ AbsoluteSpace, CanonicalKeyReport, KEYBOARD_OVERRUN_MAKE_CODE, KeyCanonicalizer, KeyReport, - MotionKind, RecordStep, ScreenRect, WHEEL_DELTA, button_edges, classify_key, - is_horizontal_wheel, motion_kind, next_record, normalize_absolute, unknown_key_name, - wheel_delta, + MotionKind, RecordStep, SCROLL_Q16_16_SCALE, ScreenRect, WHEEL_DELTA, button_edges, + classify_key, motion_kind, next_record, normalize_absolute, scroll_delta_q16_16, + unknown_key_name, }; use hypercolor_windows_input::{RawButton, RawKeyPrefix}; @@ -368,7 +368,10 @@ fn unrelated_flag_bits_produce_no_button_edges() { #[test] fn scroll_up_is_one_positive_notch() { let data = u16::try_from(WHEEL_DELTA).expect("WHEEL_DELTA fits a u16"); - assert_eq!(wheel_delta(RI_MOUSE_WHEEL, data), Some(WHEEL_DELTA)); + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_WHEEL, data), + Some((0, i64::from(WHEEL_DELTA) * SCROLL_Q16_16_SCALE)) + ); } #[test] @@ -376,29 +379,45 @@ fn scroll_down_reinterprets_the_u16_as_signed() { // usButtonData is declared u16 but carries a signed value: widening it // directly yields 65416 instead of -120, and every downward scroll would // read as a huge upward one. - assert_eq!(wheel_delta(RI_MOUSE_WHEEL, 0xFF88), Some(-WHEEL_DELTA)); + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_WHEEL, 0xFF88), + Some((0, -i64::from(WHEEL_DELTA) * SCROLL_Q16_16_SCALE)) + ); } #[test] fn sub_notch_hi_res_values_pass_through_unscaled() { // 1/120-notch units are already evdev's REL_WHEEL_HI_RES unit, so a // high-resolution wheel needs no conversion in either direction. - assert_eq!(wheel_delta(RI_MOUSE_WHEEL, 30), Some(30)); - assert_eq!(wheel_delta(RI_MOUSE_WHEEL, 0xFFE2), Some(-30)); + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_WHEEL, 30), + Some((0, 30 * SCROLL_Q16_16_SCALE)) + ); + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_WHEEL, 0xFFE2), + Some((0, -30 * SCROLL_Q16_16_SCALE)) + ); } #[test] -fn horizontal_wheel_is_dropped_not_folded_into_vertical() { - // The shared event contract has no axis. Reporting horizontal scroll - // through the vertical channel would be a silent lie to every effect. - assert_eq!(wheel_delta(RI_MOUSE_HWHEEL, 120), None); - assert!(is_horizontal_wheel(RI_MOUSE_HWHEEL)); - assert!(!is_horizontal_wheel(RI_MOUSE_WHEEL)); +fn horizontal_wheel_keeps_its_axis() { + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_HWHEEL, 120), + Some((120 * SCROLL_Q16_16_SCALE, 0)) + ); } #[test] fn a_report_with_no_wheel_flag_has_no_wheel() { - assert_eq!(wheel_delta(RI_MOUSE_LEFT_DOWN, 120), None); + assert_eq!(scroll_delta_q16_16(RI_MOUSE_LEFT_DOWN, 120), None); +} + +#[test] +fn malformed_dual_axis_report_uses_one_vertical_value() { + assert_eq!( + scroll_delta_q16_16(RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL, 120), + Some((0, 120 * SCROLL_Q16_16_SCALE)) + ); } // ── Motion ───────────────────────────────────────────────────────────────── From a59ae7923ac17bd11dfaf3913f1f7ea148d4f6cb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 15:47:22 -0700 Subject: [PATCH 010/144] feat(input): preserve Linux horizontal scroll 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) --- crates/hypercolor-core/src/input/evdev.rs | 251 +++++++++++++++++++--- 1 file changed, 218 insertions(+), 33 deletions(-) diff --git a/crates/hypercolor-core/src/input/evdev.rs b/crates/hypercolor-core/src/input/evdev.rs index 61ed704dd..789b37afc 100644 --- a/crates/hypercolor-core/src/input/evdev.rs +++ b/crates/hypercolor-core/src/input/evdev.rs @@ -25,10 +25,12 @@ use crate::input::input_mono_ms; use crate::input::traits::{InputData, InputSource, InteractionData, MotionAggregate, PointerMode}; use crate::input::worker_retention::{retain_input_worker, spawn_input_worker}; use crate::input::{ - SourceIssue, SourceKind, SourceResourceScanHealth, SourceStatusHandle, SourceStatusReporter, - classify_source_resource_scan, + LegacyWheelProjector, SourceIssue, SourceKind, SourceResourceScanHealth, SourceStatusHandle, + SourceStatusReporter, classify_source_resource_scan, +}; +use crate::types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, }; -use crate::types::event::{InputButtonState, InputEvent, TimedInputEvent}; const POLL_INTERVAL: Duration = Duration::from_millis(8); const READY_TIMEOUT: Duration = Duration::from_secs(1); @@ -69,7 +71,8 @@ pub struct DeviceOpenStatus { struct DeviceCaps { keyboard: bool, pointer: bool, - hi_res_wheel: bool, + hi_res_vertical_scroll: bool, + hi_res_horizontal_scroll: bool, } struct OpenDevice { @@ -154,6 +157,7 @@ struct SharedState { motion: MotionAggregate, pointer_present: bool, device_status: Vec, + legacy_wheel_projectors: BTreeMap, } impl SharedState { @@ -182,6 +186,7 @@ impl SharedState { self.motion = MotionAggregate::default(); self.pointer_present = false; self.device_status.clear(); + self.legacy_wheel_projectors.clear(); } } @@ -874,36 +879,48 @@ fn fold_event( device.relative_motion.accumulate(axis, value); } RelativeAxisCode::REL_WHEEL_HI_RES => { - push_event( + fold_scroll( state, - TimedInputEvent { - event: InputEvent::MouseWheel { - source_id: device.source_id.clone(), - delta_hi_res: value, - }, - at_ms, - seq: 0, - physical_code: Some("evdev:REL_WHEEL_HI_RES".to_owned()), - repeat_count: 1, - }, + &device.source_id, + 0, + i64::from(value) << 16, + "evdev:REL_WHEEL_HI_RES", + at_ms, event_limit, ); } - // Devices with hi-res wheels report both; keep only the - // hi-res stream to avoid double counting. - RelativeAxisCode::REL_WHEEL if !device.caps.hi_res_wheel => { - push_event( + RelativeAxisCode::REL_HWHEEL_HI_RES => { + fold_scroll( state, - TimedInputEvent { - event: InputEvent::MouseWheel { - source_id: device.source_id.clone(), - delta_hi_res: value.saturating_mul(120), - }, - at_ms, - seq: 0, - physical_code: Some("evdev:REL_WHEEL".to_owned()), - repeat_count: 1, - }, + &device.source_id, + i64::from(value) << 16, + 0, + "evdev:REL_HWHEEL_HI_RES", + at_ms, + event_limit, + ); + } + // Devices with hi-res axes report both forms. Suppress each + // low-resolution axis independently to avoid double counting. + RelativeAxisCode::REL_WHEEL if !device.caps.hi_res_vertical_scroll => { + fold_scroll( + state, + &device.source_id, + 0, + (i64::from(value) * 120) << 16, + "evdev:REL_WHEEL", + at_ms, + event_limit, + ); + } + RelativeAxisCode::REL_HWHEEL if !device.caps.hi_res_horizontal_scroll => { + fold_scroll( + state, + &device.source_id, + (i64::from(value) * 120) << 16, + 0, + "evdev:REL_HWHEEL", + at_ms, event_limit, ); } @@ -917,6 +934,58 @@ fn fold_event( } } +fn fold_scroll( + state: &mut SharedState, + source_id: &str, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + physical_code: &str, + at_ms: u64, + event_limit: usize, +) { + push_event( + state, + TimedInputEvent { + event: InputEvent::PointerScroll { + source_id: source_id.to_owned(), + delta_x_q16_16, + delta_y_q16_16, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::None, + momentum_phase: PointerScrollPhase::None, + }, + at_ms, + seq: 0, + physical_code: Some(physical_code.to_owned()), + repeat_count: 1, + }, + event_limit, + ); + + let legacy_delta = state + .legacy_wheel_projectors + .entry(source_id.to_owned()) + .or_default() + .project(delta_y_q16_16); + if legacy_delta == 0 { + return; + } + push_event( + state, + TimedInputEvent { + event: InputEvent::MouseWheel { + source_id: source_id.to_owned(), + delta_hi_res: legacy_delta, + }, + at_ms, + seq: 0, + physical_code: Some("evdev:legacy-wheel-shadow".to_owned()), + repeat_count: 1, + }, + event_limit, + ); +} + fn push_event(state: &mut SharedState, event: TimedInputEvent, limit: usize) { if limit == 0 { state.dropped = state @@ -975,6 +1044,7 @@ fn synthesize_releases_at( event_limit: usize, at_ms: u64, ) { + state.legacy_wheel_projectors.remove(source_id); if let Some(keys) = state.pressed_keys.remove(source_id) { for key in keys { push_event( @@ -1056,12 +1126,16 @@ fn classify_capabilities( let looks_like_pointer = axes.is_some_and(|axes| { axes.contains(RelativeAxisCode::REL_X) && axes.contains(RelativeAxisCode::REL_Y) }) && keys.is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)); - let hi_res_wheel = axes.is_some_and(|axes| axes.contains(RelativeAxisCode::REL_WHEEL_HI_RES)); + let hi_res_vertical_scroll = + axes.is_some_and(|axes| axes.contains(RelativeAxisCode::REL_WHEEL_HI_RES)); + let hi_res_horizontal_scroll = + axes.is_some_and(|axes| axes.contains(RelativeAxisCode::REL_HWHEEL_HI_RES)); DeviceCaps { keyboard: capture_keyboard && looks_like_keyboard, pointer: capture_pointer && looks_like_pointer, - hi_res_wheel, + hi_res_vertical_scroll, + hi_res_horizontal_scroll, } } @@ -1124,7 +1198,8 @@ mod tests { caps: DeviceCaps { keyboard, pointer, - hi_res_wheel: false, + hi_res_vertical_scroll: false, + hi_res_horizontal_scroll: false, }, relative_motion: RelativeMotionFrame::default(), discard_until_report: false, @@ -1183,7 +1258,8 @@ mod tests { DeviceCaps { keyboard: true, pointer: false, - hi_res_wheel: false, + hi_res_vertical_scroll: false, + hi_res_horizontal_scroll: false, }, "{} should make a media-only node eligible", media_key.name @@ -1231,6 +1307,115 @@ mod tests { ); } + #[test] + fn scroll_capabilities_are_detected_per_axis() { + let keys = [KeyCode::BTN_LEFT].into_iter().collect::>(); + let axes = [ + RelativeAxisCode::REL_X, + RelativeAxisCode::REL_Y, + RelativeAxisCode::REL_WHEEL_HI_RES, + ] + .into_iter() + .collect::>(); + + let caps = classify_capabilities(Some(&keys), Some(&axes), false, true); + assert!(caps.pointer); + assert!(caps.hi_res_vertical_scroll); + assert!(!caps.hi_res_horizontal_scroll); + } + + #[test] + fn high_resolution_vertical_scroll_emits_exact_event_then_shadow() { + let mut state = SharedState::default(); + let mut device = event_state("mouse", false, true); + + fold_event( + &mut state, + &mut device, + relative_event(RelativeAxisCode::REL_WHEEL_HI_RES, -30), + 7, + DEFAULT_EVENT_LIMIT, + ); + + assert_eq!(state.events.len(), 2); + assert!(matches!( + &state.events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16: 0, + delta_y_q16_16, + unit: PointerScrollUnit::Line120, + .. + } if *delta_y_q16_16 == -30 * crate::input::Q16_16_SCALE + )); + assert!(matches!( + &state.events[1].event, + InputEvent::MouseWheel { + delta_hi_res: -30, + .. + } + )); + } + + #[test] + fn high_resolution_horizontal_scroll_never_projects_to_legacy_wheel() { + let mut state = SharedState::default(); + let mut device = event_state("mouse", false, true); + + fold_event( + &mut state, + &mut device, + relative_event(RelativeAxisCode::REL_HWHEEL_HI_RES, 45), + 7, + DEFAULT_EVENT_LIMIT, + ); + + assert_eq!(state.events.len(), 1); + assert!(matches!( + &state.events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16: 0, + .. + } if *delta_x_q16_16 == 45 * crate::input::Q16_16_SCALE + )); + } + + #[test] + fn low_resolution_scroll_converts_notches_and_defers_to_each_high_res_axis() { + let mut state = SharedState::default(); + let mut device = event_state("mouse", false, true); + device.caps.hi_res_vertical_scroll = true; + + fold_event( + &mut state, + &mut device, + relative_event(RelativeAxisCode::REL_WHEEL, 1), + 7, + DEFAULT_EVENT_LIMIT, + ); + fold_event( + &mut state, + &mut device, + relative_event(RelativeAxisCode::REL_HWHEEL, -1), + 8, + DEFAULT_EVENT_LIMIT, + ); + + assert_eq!( + state.events.len(), + 1, + "vertical low-res duplicate is suppressed" + ); + assert!(matches!( + &state.events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16: 0, + .. + } if *delta_x_q16_16 == -120 * crate::input::Q16_16_SCALE + )); + } + #[test] fn fold_event_tracks_pressed_and_released_keys_per_source() { let mut state = SharedState::default(); From bfa96de18dbe5229355e2e675e162a7f81711b2e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:08:04 -0700 Subject: [PATCH 011/144] feat(input): preserve browser scroll axes 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) --- crates/hypercolor-core/src/input/browser.rs | 244 ++++++++++++++++-- .../tests/browser_registry_tests.rs | 29 ++- .../hypercolor-daemon/src/api/ws/protocol.rs | 78 +++++- crates/hypercolor-daemon/src/api/ws/tests.rs | 105 +++++++- .../src/render_thread/pipeline_runtime.rs | 29 ++- .../src/components/canvas_preview.rs | 57 ++-- crates/hypercolor-ui/src/ws/input.rs | 35 ++- crates/hypercolor-ui/src/ws/mod.rs | 4 +- .../hypercolor-ui/tests/input_inject_tests.rs | 62 ++++- 9 files changed, 565 insertions(+), 78 deletions(-) diff --git a/crates/hypercolor-core/src/input/browser.rs b/crates/hypercolor-core/src/input/browser.rs index eb5959b70..5bd069e30 100644 --- a/crates/hypercolor-core/src/input/browser.rs +++ b/crates/hypercolor-core/src/input/browser.rs @@ -24,8 +24,13 @@ use crate::input::routing::{ ReusedInteractionRouteRead, }; use crate::input::traits::{InputData, InputSource, InteractionData, MotionAggregate, PointerMode}; -use crate::input::{InteractionSourceOrigin, SourceKind, SourceStatusHandle, SourceStatusReporter}; -use crate::types::event::{InputButtonState, InputEvent, TimedInputEvent}; +use crate::input::{ + InteractionSourceOrigin, LegacyWheelProjector, SourceKind, SourceStatusHandle, + SourceStatusReporter, +}; +use crate::types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, +}; const DEFAULT_EVENT_LIMIT: usize = 256; const SHARED_SAMPLE_POOL_CAPACITY: usize = 2; @@ -51,6 +56,14 @@ pub enum BrowserInputEdge { Move { norm_x: f32, norm_y: f32 }, /// The wheel moved, in 1/120-notch hi-res units. Wheel { delta_hi_res: i32 }, + /// Exact two-axis scroll motion. + Scroll { + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: PointerScrollUnit, + phase: PointerScrollPhase, + momentum_phase: PointerScrollPhase, + }, } #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -175,6 +188,7 @@ struct BrowserChildState { pressed_keys: BTreeSet, held_buttons: BTreeSet, cursor: Option<(f32, f32)>, + legacy_wheel_projector: LegacyWheelProjector, generation: u64, } @@ -342,6 +356,7 @@ impl BrowserInputChildSlot { state.pressed_keys.clear(); state.held_buttons.clear(); state.cursor = None; + state.legacy_wheel_projector.reset(); state.generation = state .generation .checked_add(1) @@ -955,13 +970,28 @@ impl BrowserInputSource { self.retain_active_aggregate_cursors(®istry); retired_legacy.clear(); self.retained_legacy = retired_legacy; - data.batch.wheel_hi_res = events[first_event..].iter().fold(0_i32, |total, event| { - if let InputEvent::MouseWheel { delta_hi_res, .. } = &event.event { - total.saturating_add(*delta_hi_res) - } else { - total + for event in &events[first_event..] { + match &event.event { + InputEvent::MouseWheel { delta_hi_res, .. } => { + data.batch.wheel_hi_res = data.batch.wheel_hi_res.saturating_add(*delta_hi_res); + } + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16, + unit, + .. + } => data + .batch + .scroll + .accumulate(*unit, *delta_x_q16_16, *delta_y_q16_16), + InputEvent::Key { .. } + | InputEvent::MouseButton { .. } + | InputEvent::MidiNote { .. } + | InputEvent::MidiControlChange { .. } + | InputEvent::MidiPitchBend { .. } + | InputEvent::MidiRealtime { .. } => {} } - }); + } data.batch.dropped_events = data.batch.dropped_events.saturating_add(dropped); self.finish_aggregate_snapshot(data, changed) } @@ -1319,16 +1349,77 @@ fn fold_child_edge( } state.cursor = Some(position); } - BrowserInputEdge::Wheel { delta_hi_res } => events.push(timed_event( - InputEvent::MouseWheel { - source_id: source_id.to_owned(), - delta_hi_res, - }, + BrowserInputEdge::Wheel { delta_hi_res } => fold_scroll_edge( + state, + source_id, + 0, + i64::from(delta_hi_res) << 16, + PointerScrollUnit::Line120, + PointerScrollPhase::None, + PointerScrollPhase::None, + at_ms, + events, + ), + BrowserInputEdge::Scroll { + delta_x_q16_16, + delta_y_q16_16, + unit, + phase, + momentum_phase, + } => fold_scroll_edge( + state, + source_id, + delta_x_q16_16, + delta_y_q16_16, + unit, + phase, + momentum_phase, at_ms, - )), + events, + ), } } +#[expect(clippy::too_many_arguments)] +fn fold_scroll_edge( + state: &mut BrowserChildState, + source_id: &str, + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: PointerScrollUnit, + phase: PointerScrollPhase, + momentum_phase: PointerScrollPhase, + at_ms: u64, + events: &mut Vec, +) { + events.push(timed_event( + InputEvent::PointerScroll { + source_id: source_id.to_owned(), + delta_x_q16_16, + delta_y_q16_16, + unit, + phase, + momentum_phase, + }, + at_ms, + )); + + if unit != PointerScrollUnit::Line120 { + return; + } + let delta_hi_res = state.legacy_wheel_projector.project(delta_y_q16_16); + if delta_hi_res == 0 { + return; + } + events.push(timed_event( + InputEvent::MouseWheel { + source_id: source_id.to_owned(), + delta_hi_res, + }, + at_ms, + )); +} + fn build_child_snapshot( state: &BrowserChildState, recent_keys: Vec, @@ -1510,7 +1601,7 @@ mod tests { } #[test] - fn wheel_edges_carry_hi_res_delta() { + fn legacy_wheel_edges_emit_exact_scroll_then_compatibility_shadow() { let mut source = BrowserInputSource::new(); source.start().expect("start"); let handle = source.handle(); @@ -1520,9 +1611,20 @@ mod tests { [BrowserInputEdge::Wheel { delta_hi_res: -240 }], ); let events = source.drain_events(); - assert_eq!(events.len(), 1); + assert_eq!(events.len(), 2); assert!(matches!( events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16: 0, + delta_y_q16_16, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::None, + momentum_phase: PointerScrollPhase::None, + .. + } if delta_y_q16_16 == -240 * crate::input::Q16_16_SCALE + )); + assert!(matches!( + events[1].event, InputEvent::MouseWheel { delta_hi_res: -240, .. @@ -1530,6 +1632,78 @@ mod tests { )); } + #[test] + fn exact_pixel_scroll_preserves_axes_and_phases_without_legacy_shadow() { + let mut source = BrowserInputSource::new(); + source.start().expect("start"); + source.handle().inject( + "browser-1", + [BrowserInputEdge::Scroll { + delta_x_q16_16: 3 * crate::input::Q16_16_SCALE, + delta_y_q16_16: -7 * crate::input::Q16_16_SCALE, + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::Began, + }], + ); + + let (data, events) = source.sample_and_drain_with_delta_secs(0.0); + let InputData::Interaction(data) = data.expect("sample") else { + panic!("expected interaction data"); + }; + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].event, + InputEvent::PointerScroll { + delta_x_q16_16, + delta_y_q16_16, + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::Began, + .. + } if delta_x_q16_16 == 3 * crate::input::Q16_16_SCALE + && delta_y_q16_16 == -7 * crate::input::Q16_16_SCALE + )); + assert_eq!( + data.batch.scroll.pixel_x_q16_16, + 3 * crate::input::Q16_16_SCALE + ); + assert_eq!( + data.batch.scroll.pixel_y_q16_16, + -7 * crate::input::Q16_16_SCALE + ); + assert_eq!(data.batch.wheel_hi_res, 0); + } + + #[test] + fn reconnect_discards_fractional_legacy_projection_state() { + let mut source = BrowserInputSource::new(); + source.start().expect("start"); + let handle = source.handle(); + let half_line = BrowserInputEdge::Scroll { + delta_x_q16_16: 0, + delta_y_q16_16: crate::input::Q16_16_SCALE / 2, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::None, + momentum_phase: PointerScrollPhase::None, + }; + + handle.inject("browser-1", [half_line.clone()]); + let first = source.drain_events(); + assert_eq!(first.len(), 1); + assert!(matches!(first[0].event, InputEvent::PointerScroll { .. })); + + handle.release_source("browser-1"); + assert!(source.drain_events().is_empty()); + handle.inject("browser-1", [half_line]); + let reconnected = source.drain_events(); + assert_eq!(reconnected.len(), 1); + assert!(matches!( + reconnected[0].event, + InputEvent::PointerScroll { .. } + )); + } + #[test] fn bounded_rings_keep_newest_events_in_order_and_report_overflow() { let mut source = BrowserInputSource::new(); @@ -1546,16 +1720,32 @@ mod tests { let InputData::Interaction(data) = data.expect("sample") else { panic!("expected interaction data"); }; - let deltas = events - .into_iter() - .map(|timed| match timed.event { - InputEvent::MouseWheel { delta_hi_res, .. } => delta_hi_res, - other => panic!("expected wheel event, got {other:?}"), - }) - .collect::>(); - - assert_eq!(deltas, vec![6, 7, 8, 9]); - assert_eq!(data.batch.dropped_events, 6); + assert_eq!(events.len(), 4); + assert!(matches!( + events[0].event, + InputEvent::PointerScroll { delta_y_q16_16, .. } + if delta_y_q16_16 == 8 * crate::input::Q16_16_SCALE + )); + assert!(matches!( + events[1].event, + InputEvent::MouseWheel { + delta_hi_res: 8, + .. + } + )); + assert!(matches!( + events[2].event, + InputEvent::PointerScroll { delta_y_q16_16, .. } + if delta_y_q16_16 == 9 * crate::input::Q16_16_SCALE + )); + assert!(matches!( + events[3].event, + InputEvent::MouseWheel { + delta_hi_res: 9, + .. + } + )); + assert_eq!(data.batch.dropped_events, 15); let next = drain_snapshot(&mut source); assert_eq!(next.batch.dropped_events, 0); @@ -1575,7 +1765,7 @@ mod tests { panic!("expected interaction data"); }; assert!(events.is_empty()); - assert_eq!(data.batch.dropped_events, 1); + assert_eq!(data.batch.dropped_events, 2); } #[test] diff --git a/crates/hypercolor-core/tests/browser_registry_tests.rs b/crates/hypercolor-core/tests/browser_registry_tests.rs index a0b9c32dc..2d095a018 100644 --- a/crates/hypercolor-core/tests/browser_registry_tests.rs +++ b/crates/hypercolor-core/tests/browser_registry_tests.rs @@ -56,7 +56,18 @@ fn shared_sampling_reuses_browser_snapshot_pool_and_drains_directly() { .expect("shared sample should succeed") .expect("running browser source should publish"); let first_ptr = Arc::as_ptr(&first); - assert_eq!(events.len(), 1); + assert!(matches!( + events.as_slice(), + [exact, shadow] + if matches!( + exact.event, + InputEvent::PointerScroll { delta_y_q16_16, .. } + if delta_y_q16_16 == 120 * hypercolor_core::input::Q16_16_SCALE + ) && matches!( + shadow.event, + InputEvent::MouseWheel { delta_hi_res: 120, .. } + ) + )); drop(first); events.clear(); @@ -78,9 +89,13 @@ fn shared_sampling_reuses_browser_snapshot_pool_and_drains_directly() { assert_eq!(Arc::as_ptr(&third), first_ptr); assert!(matches!( events.as_slice(), - [event] + [exact, shadow] if matches!( - event.event, + exact.event, + InputEvent::PointerScroll { delta_y_q16_16, .. } + if delta_y_q16_16 == -30 * hypercolor_core::input::Q16_16_SCALE + ) && matches!( + shadow.event, InputEvent::MouseWheel { delta_hi_res: -30, .. } ) )); @@ -236,7 +251,7 @@ fn bounded_child_history_is_non_destructive_for_independent_consumers() { let mut fast_events = Vec::new(); let fast_cursor = slot.read_events_since(0, &mut fast_events).next_cursor; - assert_eq!(fast_cursor, 3); + assert_eq!(fast_cursor, 5); attachment .inject( (0..INPUT_EVENT_RING_CAPACITY + 5).map(|index| BrowserInputEdge::Wheel { @@ -248,12 +263,12 @@ fn bounded_child_history_is_non_destructive_for_independent_consumers() { let mut slow_events = Vec::new(); let slow = slot.read_events_since(0, &mut slow_events); assert_eq!(slow_events.len(), INPUT_EVENT_RING_CAPACITY); - assert_eq!(slow.dropped, 8); + assert_eq!(slow.dropped, 270); fast_events.clear(); let fast = slot.read_events_since(fast_cursor, &mut fast_events); assert_eq!(fast_events.len(), INPUT_EVENT_RING_CAPACITY); - assert_eq!(fast.dropped, 5); + assert_eq!(fast.dropped, 265); let mut replay = Vec::new(); let replay_read = slot.read_events_since(0, &mut replay); @@ -366,7 +381,7 @@ fn fast_aggregate_consumer_is_not_charged_for_replaced_history() { let InputData::Interaction(sample) = sample.expect("aggregate sample") else { panic!("expected interaction sample"); }; - assert_eq!(events.len(), 1); + assert_eq!(events.len(), if delta_hi_res == 0 { 1 } else { 2 }); assert_eq!( sample.batch.wheel_hi_res, i32::try_from(delta_hi_res).expect("test delta fits i32") diff --git a/crates/hypercolor-daemon/src/api/ws/protocol.rs b/crates/hypercolor-daemon/src/api/ws/protocol.rs index fb1a00cfd..695d58b2f 100644 --- a/crates/hypercolor-daemon/src/api/ws/protocol.rs +++ b/crates/hypercolor-daemon/src/api/ws/protocol.rs @@ -589,6 +589,8 @@ pub(super) const MAX_INPUT_INJECT_EVENTS: usize = 256; pub(super) const MAX_INPUT_NAME_BYTES: usize = 128; /// Largest accepted browser wheel delta, equivalent to 100 notches. pub(super) const MAX_INPUT_WHEEL_DELTA: i32 = 120 * 100; +/// Largest accepted exact browser scroll delta on either axis. +pub(super) const MAX_INPUT_SCROLL_Q16_16: i64 = (120_i64 * 100) << 16; /// Client-to-server subscription messages. #[derive(Debug, Deserialize)] @@ -682,6 +684,17 @@ pub(super) enum BrowserInputEdgeWire { #[serde(deserialize_with = "deserialize_wheel_delta")] delta_hi_res: i32, }, + Scroll { + #[serde(deserialize_with = "deserialize_scroll_delta")] + delta_x_q16_16: i64, + #[serde(deserialize_with = "deserialize_scroll_delta")] + delta_y_q16_16: i64, + unit: PointerScrollUnitWire, + #[serde(default)] + phase: PointerScrollPhaseWire, + #[serde(default)] + momentum_phase: PointerScrollPhaseWire, + }, } #[derive(Debug, Clone, Copy, Deserialize)] @@ -692,16 +705,49 @@ pub(super) enum InputButtonStateWire { Repeated, } +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum PointerScrollUnitWire { + Line120, + Pixels, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum PointerScrollPhaseWire { + #[default] + None, + MayBegin, + Began, + Changed, + Stationary, + Ended, + Cancelled, +} + impl BrowserInputEdgeWire { pub(super) fn into_edge(self) -> hypercolor_core::input::BrowserInputEdge { use hypercolor_core::input::BrowserInputEdge; - use hypercolor_types::event::InputButtonState; + use hypercolor_types::event::{InputButtonState, PointerScrollPhase, PointerScrollUnit}; let map_state = |state: InputButtonStateWire| match state { InputButtonStateWire::Pressed => InputButtonState::Pressed, InputButtonStateWire::Released => InputButtonState::Released, InputButtonStateWire::Repeated => InputButtonState::Repeated, }; + let map_unit = |unit: PointerScrollUnitWire| match unit { + PointerScrollUnitWire::Line120 => PointerScrollUnit::Line120, + PointerScrollUnitWire::Pixels => PointerScrollUnit::Pixels, + }; + let map_phase = |phase: PointerScrollPhaseWire| match phase { + PointerScrollPhaseWire::None => PointerScrollPhase::None, + PointerScrollPhaseWire::MayBegin => PointerScrollPhase::MayBegin, + PointerScrollPhaseWire::Began => PointerScrollPhase::Began, + PointerScrollPhaseWire::Changed => PointerScrollPhase::Changed, + PointerScrollPhaseWire::Stationary => PointerScrollPhase::Stationary, + PointerScrollPhaseWire::Ended => PointerScrollPhase::Ended, + PointerScrollPhaseWire::Cancelled => PointerScrollPhase::Cancelled, + }; match self { Self::Key { key, state } => BrowserInputEdge::Key { @@ -717,6 +763,19 @@ impl BrowserInputEdgeWire { norm_y: ny, }, Self::Wheel { delta_hi_res } => BrowserInputEdge::Wheel { delta_hi_res }, + Self::Scroll { + delta_x_q16_16, + delta_y_q16_16, + unit, + phase, + momentum_phase, + } => BrowserInputEdge::Scroll { + delta_x_q16_16, + delta_y_q16_16, + unit: map_unit(unit), + phase: map_phase(phase), + momentum_phase: map_phase(momentum_phase), + }, } } } @@ -946,6 +1005,23 @@ where } } +fn deserialize_scroll_delta<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = i64::deserialize(deserializer)?; + if value + .checked_abs() + .is_some_and(|magnitude| magnitude <= MAX_INPUT_SCROLL_Q16_16) + { + Ok(value) + } else { + Err(de::Error::custom(format_args!( + "browser input scroll delta must be within ±{MAX_INPUT_SCROLL_Q16_16}" + ))) + } +} + pub(super) fn deserialize_finite_coordinate<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, diff --git a/crates/hypercolor-daemon/src/api/ws/tests.rs b/crates/hypercolor-daemon/src/api/ws/tests.rs index 3a4bd50dc..2e8d70c57 100644 --- a/crates/hypercolor-daemon/src/api/ws/tests.rs +++ b/crates/hypercolor-daemon/src/api/ws/tests.rs @@ -64,10 +64,11 @@ use super::protocol::{ ActiveFramesConfig, BrowserInputEdgeWire, CanvasFormat, ChannelConfig, ChannelConfigPatch, ChannelSet, ClientMessage, FrameFormat, FrameZoneSelection, FramesConfig, InputButtonStateWire, InteractivePreviewConfig, InteractivePreviewTarget, MAX_INPUT_INJECT_EVENTS, - MAX_INPUT_NAME_BYTES, MAX_INPUT_WHEEL_DELTA, MAX_PREVIEW_PUBLICATION_BYTES, ServerMessage, - SubscriptionState, WsChannel, deserialize_finite_coordinate, event_message_parts, - parse_channels, should_relay_event, to_snake_case, unique_sorted_channel_names, - validate_interactive_preview_id, validate_interactive_preview_shape, ws_capabilities, + MAX_INPUT_NAME_BYTES, MAX_INPUT_SCROLL_Q16_16, MAX_INPUT_WHEEL_DELTA, + MAX_PREVIEW_PUBLICATION_BYTES, ServerMessage, SubscriptionState, WsChannel, + deserialize_finite_coordinate, event_message_parts, parse_channels, should_relay_event, + to_snake_case, unique_sorted_channel_names, validate_interactive_preview_id, + validate_interactive_preview_shape, ws_capabilities, }; use super::relays::{ PreviewCursorQueue, PreviewOutboundError, PreviewOutboundItem, PreviewOutboundLimits, @@ -2944,7 +2945,7 @@ fn default_subscription_excludes_input_events() { #[test] fn input_inject_message_parses_all_edge_kinds() { use hypercolor_core::input::BrowserInputEdge; - use hypercolor_types::event::InputButtonState; + use hypercolor_types::event::{InputButtonState, PointerScrollPhase, PointerScrollUnit}; let raw = r#"{ "type": "input_inject", @@ -2953,7 +2954,21 @@ fn input_inject_message_parses_all_edge_kinds() { {"kind": "key", "key": "a", "state": "pressed"}, {"kind": "button", "button": "left", "state": "released"}, {"kind": "move", "nx": 0.5, "ny": 0.25}, - {"kind": "wheel", "delta_hi_res": -240} + {"kind": "wheel", "delta_hi_res": -240}, + { + "kind": "scroll", + "delta_x_q16_16": 98304, + "delta_y_q16_16": -131072, + "unit": "pixels", + "phase": "changed", + "momentum_phase": "began" + }, + { + "kind": "scroll", + "delta_x_q16_16": 0, + "delta_y_q16_16": 65536, + "unit": "line120" + } ] }"#; @@ -2963,7 +2978,7 @@ fn input_inject_message_parses_all_edge_kinds() { panic!("expected InputInject"); }; assert_eq!(preview_id, "main"); - assert_eq!(events.len(), 4); + assert_eq!(events.len(), 6); let edges: Vec = events .into_iter() @@ -2991,6 +3006,26 @@ fn input_inject_message_parses_all_edge_kinds() { } ); assert_eq!(edges[3], BrowserInputEdge::Wheel { delta_hi_res: -240 }); + assert_eq!( + edges[4], + BrowserInputEdge::Scroll { + delta_x_q16_16: 98_304, + delta_y_q16_16: -131_072, + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::Began, + } + ); + assert_eq!( + edges[5], + BrowserInputEdge::Scroll { + delta_x_q16_16: 0, + delta_y_q16_16: 65_536, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::None, + momentum_phase: PointerScrollPhase::None, + } + ); } #[test] @@ -3104,6 +3139,62 @@ fn input_inject_rejects_invalid_names_buttons_coordinates_and_wheel_deltas() { "amplified wheel delta must be rejected" ); } + + for delta in [ + MAX_INPUT_SCROLL_Q16_16.saturating_add(1), + MAX_INPUT_SCROLL_Q16_16.saturating_neg().saturating_sub(1), + i64::MIN, + ] { + for axis in ["delta_x_q16_16", "delta_y_q16_16"] { + let mut edge = serde_json::json!({ + "kind": "scroll", + "delta_x_q16_16": 0, + "delta_y_q16_16": 0, + "unit": "line120" + }); + edge[axis] = serde_json::json!(delta); + let payload = serde_json::json!({ + "type": "input_inject", + "preview_id": "main", + "events": [edge] + }); + assert!( + serde_json::from_value::(payload).is_err(), + "amplified {axis} scroll delta must be rejected" + ); + } + } + + for delta in [MAX_INPUT_SCROLL_Q16_16, -MAX_INPUT_SCROLL_Q16_16] { + let payload = serde_json::json!({ + "type": "input_inject", + "preview_id": "main", + "events": [{ + "kind": "scroll", + "delta_x_q16_16": delta, + "delta_y_q16_16": delta, + "unit": "pixels" + }] + }); + assert!( + serde_json::from_value::(payload).is_ok(), + "inclusive scroll bound must be accepted" + ); + } + + let missing_unit = serde_json::json!({ + "type": "input_inject", + "preview_id": "main", + "events": [{ + "kind": "scroll", + "delta_x_q16_16": 0, + "delta_y_q16_16": 0 + }] + }); + assert!( + serde_json::from_value::(missing_unit).is_err(), + "scroll unit must be required" + ); } #[test] diff --git a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs index 206102d77..7c080ddc0 100644 --- a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs +++ b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs @@ -2251,7 +2251,8 @@ mod tests { }; use hypercolor_core::input::{ InputData, InputGraphSnapshot, InputManager, InputSource, InputSourceSlot, InteractionData, - MotionAggregate, SourceIssue, SourceKind, SourceStatusWriter, + MotionAggregate, Q16_16_SCALE, ScrollAggregate, SourceIssue, SourceKind, + SourceStatusWriter, }; use hypercolor_core::spatial::{ SpatialEngine, SpatialSamplingCapacity, SpatialSamplingWorkspaceUsage, @@ -2849,6 +2850,10 @@ mod tests { assert!((inputs.interaction.batch.motion.dx - 0.4).abs() < 0.000_1); assert!((inputs.interaction.batch.motion.dy - 0.4).abs() < 0.000_1); assert_eq!(inputs.interaction.batch.wheel_hi_res, 80); + assert_eq!( + inputs.interaction.batch.scroll.line120_y_q16_16, + 80 * Q16_16_SCALE + ); assert_eq!( inputs .interaction @@ -2859,10 +2864,32 @@ mod tests { .count(), 2 ); + assert_eq!( + inputs + .interaction + .batch + .events + .iter() + .filter(|event| matches!(event.event, InputEvent::PointerScroll { .. })) + .count(), + 2 + ); + assert!( + inputs + .interaction + .batch + .events + .chunks_exact(2) + .all( + |pair| matches!(pair[0].event, InputEvent::PointerScroll { .. }) + && matches!(pair[1].event, InputEvent::MouseWheel { .. }) + ) + ); resolve_authoritative(&mut routes, &graph, &event_bus, &mut inputs); assert_eq!(inputs.interaction.batch.motion, MotionAggregate::default()); assert_eq!(inputs.interaction.batch.wheel_hi_res, 0); + assert_eq!(inputs.interaction.batch.scroll, ScrollAggregate::default()); assert!(inputs.interaction.batch.events.is_empty()); } diff --git a/crates/hypercolor-ui/src/components/canvas_preview.rs b/crates/hypercolor-ui/src/components/canvas_preview.rs index 16890d0f2..b41907025 100644 --- a/crates/hypercolor-ui/src/components/canvas_preview.rs +++ b/crates/hypercolor-ui/src/components/canvas_preview.rs @@ -1,4 +1,4 @@ -//! Canvas preview — presents authoritative daemon frames in the browser via WebGL. +//! Canvas preview presents authoritative daemon frames in the browser via WebGL. use std::cell::RefCell; use std::collections::HashSet; @@ -22,7 +22,9 @@ use crate::api; use crate::app::{EffectsContext, WsContext}; use crate::icons::LuMousePointerClick; use crate::preview_telemetry::{PreviewPresenterTelemetry, PreviewTelemetryContext}; -use crate::ws::input::{InputEdgeButton, InputEdgeState, InputInjectEdge}; +use crate::ws::input::{ + InputEdgeButton, InputEdgeScrollPhase, InputEdgeScrollUnit, InputEdgeState, InputInjectEdge, +}; use crate::ws::{CanvasFrame, InteractivePreviewLifecycle, InteractivePreviewRequest}; use super::preview_runtime::{PreviewRenderOutcome, PreviewRuntime, PreviewRuntimeInitError}; @@ -105,24 +107,38 @@ pub fn canonical_injection_key(code: &str) -> Option { Some(name.to_owned()) } -/// Convert a `WheelEvent` delta into the daemon's hi-res wheel units -/// (120 per notch). Pixel deltas assume the common ~100px notch; line and -/// page modes scale through conventional pixel equivalents. Sign flips so -/// scrolling up (negative `deltaY`) is a positive notch, matching evdev's -/// `REL_WHEEL_HI_RES`. -pub fn wheel_delta_hi_res(delta_y: f64, delta_mode: u32) -> i32 { - const LINE_HEIGHT_PX: f64 = 40.0; +/// Convert a DOM wheel sample into exact two-axis scroll motion. +pub fn wheel_scroll_edge(delta_x: f64, delta_y: f64, delta_mode: u32) -> Option { + if !delta_x.is_finite() || !delta_y.is_finite() || (delta_x == 0.0 && delta_y == 0.0) { + return None; + } + + const LINE120_PER_DOM_LINE: f64 = 48.0; const PAGE_HEIGHT_PX: f64 = 400.0; - const NOTCH_PX: f64 = 100.0; - let pixels = match delta_mode { - 1 => delta_y * LINE_HEIGHT_PX, - 2 => delta_y * PAGE_HEIGHT_PX, - _ => delta_y, + let (unit, scale) = match delta_mode { + 1 => (InputEdgeScrollUnit::Line120, LINE120_PER_DOM_LINE), + 2 => (InputEdgeScrollUnit::Pixels, PAGE_HEIGHT_PX), + _ => (InputEdgeScrollUnit::Pixels, 1.0), }; - let hi_res = (-pixels * 120.0 / NOTCH_PX).round(); - #[allow(clippy::cast_possible_truncation)] + Some(InputInjectEdge::Scroll { + delta_x_q16_16: f64_to_q16_16(-delta_x * scale), + delta_y_q16_16: f64_to_q16_16(-delta_y * scale), + unit, + phase: InputEdgeScrollPhase::None, + momentum_phase: InputEdgeScrollPhase::None, + }) +} + +fn f64_to_q16_16(value: f64) -> i64 { + let scaled = (value * 65_536.0).round(); + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + reason = "DOM wheel doubles must be bounded before fixed-point conversion" + )] { - hi_res.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 + scaled.clamp(i64::MIN as f64, i64::MAX as f64) as i64 } } @@ -1021,9 +1037,10 @@ pub fn CanvasPreview( } ev.prevent_default(); ev.stop_propagation(); - let delta = wheel_delta_hi_res(ev.delta_y(), ev.delta_mode()); - if delta != 0 { - queue_edge(InputInjectEdge::Wheel { delta_hi_res: delta }); + if let Some(edge) = + wheel_scroll_edge(ev.delta_x(), ev.delta_y(), ev.delta_mode()) + { + queue_edge(edge); } } } diff --git a/crates/hypercolor-ui/src/ws/input.rs b/crates/hypercolor-ui/src/ws/input.rs index 2fd2bcc15..e7eb388ed 100644 --- a/crates/hypercolor-ui/src/ws/input.rs +++ b/crates/hypercolor-ui/src/ws/input.rs @@ -1,7 +1,7 @@ -//! Browser-preview input injection — upstream `input_inject` client messages. +//! Browser-preview input injection: upstream `input_inject` client messages. //! -//! Wire-shaped mirror of the daemon's `BrowserInputEdgeWire` (spec 71 W4): -//! the daemon stamps a per-connection `source_id`, folds edges into the +//! Wire-shaped mirror of the daemon's `BrowserInputEdgeWire`: the daemon +//! stamps a per-connection `source_id`, folds edges into the //! interaction state, and synthesizes releases on socket close. Injection is //! control-tier authorized server-side; read-only sockets receive a //! `forbidden` protocol error and no state changes. @@ -28,6 +28,35 @@ pub enum InputInjectEdge { Wheel { delta_hi_res: i32, }, + Scroll { + delta_x_q16_16: i64, + delta_y_q16_16: i64, + unit: InputEdgeScrollUnit, + phase: InputEdgeScrollPhase, + momentum_phase: InputEdgeScrollPhase, + }, +} + +/// Coordinate unit for an exact two-axis scroll edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputEdgeScrollUnit { + Line120, + Pixels, +} + +/// Lifecycle phase for an exact scroll edge. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputEdgeScrollPhase { + #[default] + None, + MayBegin, + Began, + Changed, + Stationary, + Ended, + Cancelled, } /// Press state for key and button edges. diff --git a/crates/hypercolor-ui/src/ws/mod.rs b/crates/hypercolor-ui/src/ws/mod.rs index 565716cc0..b698ddc52 100644 --- a/crates/hypercolor-ui/src/ws/mod.rs +++ b/crates/hypercolor-ui/src/ws/mod.rs @@ -9,7 +9,9 @@ pub mod messages; mod preview; pub use connection::WsManager; -pub use input::{InputEdgeButton, InputEdgeState, InputInjectEdge}; +pub use input::{ + InputEdgeButton, InputEdgeScrollPhase, InputEdgeScrollUnit, InputEdgeState, InputInjectEdge, +}; pub use interactive_preview::{InteractivePreviewLifecycle, InteractivePreviewRequest}; pub use messages::{ AudioLevel, BackpressureNotice, CanvasFrame, CanvasPixelFormat, ControlSurfaceEventHint, diff --git a/crates/hypercolor-ui/tests/input_inject_tests.rs b/crates/hypercolor-ui/tests/input_inject_tests.rs index 634500ccb..325d00280 100644 --- a/crates/hypercolor-ui/tests/input_inject_tests.rs +++ b/crates/hypercolor-ui/tests/input_inject_tests.rs @@ -1,7 +1,7 @@ use hypercolor_ui::api::{EffectCapabilitySet, EffectSummary}; use hypercolor_ui::components::canvas_preview::{ canonical_injection_key, effect_wants_interaction, normalized_canvas_position, - wheel_delta_hi_res, + wheel_scroll_edge, }; use hypercolor_ui::ws::interactive_preview::{ InteractivePreviewLifecycle, InteractivePreviewLifecycleTracker, @@ -10,7 +10,8 @@ use hypercolor_ui::ws::interactive_preview::{ }; use hypercolor_ui::ws::messages::interactive_preview_supported; use hypercolor_ui::ws::{ - InputEdgeButton, InputEdgeState, InputInjectEdge, InteractivePreviewRequest, + InputEdgeButton, InputEdgeScrollPhase, InputEdgeScrollUnit, InputEdgeState, InputInjectEdge, + InteractivePreviewRequest, }; fn summary(input_reactive: bool, category: &str, tags: &[&str]) -> EffectSummary { @@ -47,6 +48,13 @@ fn edges_serialize_to_daemon_wire_shape() { }, InputInjectEdge::Move { nx: 0.25, ny: 1.0 }, InputInjectEdge::Wheel { delta_hi_res: -120 }, + InputInjectEdge::Scroll { + delta_x_q16_16: 98_304, + delta_y_q16_16: -131_072, + unit: InputEdgeScrollUnit::Pixels, + phase: InputEdgeScrollPhase::Changed, + momentum_phase: InputEdgeScrollPhase::Began, + }, ]; let message = input_inject_message("main", &edges); assert_eq!( @@ -59,6 +67,14 @@ fn edges_serialize_to_daemon_wire_shape() { { "kind": "button", "button": "left", "state": "released" }, { "kind": "move", "nx": 0.25, "ny": 1.0 }, { "kind": "wheel", "delta_hi_res": -120 }, + { + "kind": "scroll", + "delta_x_q16_16": 98304, + "delta_y_q16_16": -131072, + "unit": "pixels", + "phase": "changed", + "momentum_phase": "began" + }, ], }) ); @@ -231,15 +247,39 @@ fn injection_keys_match_daemon_canonical_names() { } #[test] -fn wheel_deltas_scale_to_hi_res_notches() { - // One standard pixel-mode notch (100px down) = -120 hi-res units. - assert_eq!(wheel_delta_hi_res(100.0, 0), -120); - assert_eq!(wheel_delta_hi_res(-100.0, 0), 120); - // Firefox line mode: 3 lines per notch. - assert_eq!(wheel_delta_hi_res(3.0, 1), -144); - // Page mode scales through the page-height equivalent. - assert_eq!(wheel_delta_hi_res(1.0, 2), -480); - assert_eq!(wheel_delta_hi_res(0.0, 0), 0); +fn wheel_deltas_preserve_axes_and_dom_units() { + assert_eq!( + wheel_scroll_edge(12.5, 100.0, 0), + Some(InputInjectEdge::Scroll { + delta_x_q16_16: -819_200, + delta_y_q16_16: -6_553_600, + unit: InputEdgeScrollUnit::Pixels, + phase: InputEdgeScrollPhase::None, + momentum_phase: InputEdgeScrollPhase::None, + }) + ); + assert_eq!( + wheel_scroll_edge(0.0, 3.0, 1), + Some(InputInjectEdge::Scroll { + delta_x_q16_16: 0, + delta_y_q16_16: -9_437_184, + unit: InputEdgeScrollUnit::Line120, + phase: InputEdgeScrollPhase::None, + momentum_phase: InputEdgeScrollPhase::None, + }) + ); + assert_eq!( + wheel_scroll_edge(1.0, -0.5, 2), + Some(InputInjectEdge::Scroll { + delta_x_q16_16: -26_214_400, + delta_y_q16_16: 13_107_200, + unit: InputEdgeScrollUnit::Pixels, + phase: InputEdgeScrollPhase::None, + momentum_phase: InputEdgeScrollPhase::None, + }) + ); + assert_eq!(wheel_scroll_edge(0.0, 0.0, 0), None); + assert_eq!(wheel_scroll_edge(f64::NAN, 1.0, 0), None); } #[test] From b3b8c473dfbab19a4568091f9627ec6870b4ae43 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:12:00 -0700 Subject: [PATCH 012/144] feat(sdk): expose exact scroll input 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) --- .../lightscript/frame_payload_adapter.js | 18 ++++++ sdk/packages/core/src/index.ts | 6 ++ sdk/packages/core/src/input/data.ts | 52 ++++++++++++++++ sdk/packages/core/src/input/index.ts | 6 ++ sdk/packages/core/src/input/types.ts | 62 +++++++++++++++---- sdk/packages/core/tests/input-data.test.ts | 33 +++++++++- .../core/tests/input-runtime-bridge.test.ts | 37 ++++++++++- 7 files changed, 198 insertions(+), 16 deletions(-) diff --git a/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js b/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js index f90fe2216..95592fe4e 100644 --- a/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js +++ b/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js @@ -3,6 +3,10 @@ const number = Number(value); return Number.isFinite(number) ? number : fallback; }; + const scrollPhase = function(value) { + if (value === 'may_begin' || value === 'began' || value === 'changed' || value === 'stationary' || value === 'ended' || value === 'cancelled') { return value; } + return 'none'; + }; const trueObject = function(values) { const object = {}; if (!Array.isArray(values)) { return object; } @@ -171,6 +175,13 @@ engine.mouse.mode = typeof mouse.mode === 'string' ? mouse.mode : 'none'; engine.mouse.available = engine.mouse.mode !== 'none'; engine.mouse.wheel = finiteNumber(mouse.wheel, 0) / 120; + const scroll = typeof mouse.scroll === 'object' && mouse.scroll !== null ? mouse.scroll : {}; + engine.mouse.scroll = { + line120X: finiteNumber(scroll.line120X, 0), + line120Y: finiteNumber(scroll.line120Y, 0), + pixelX: finiteNumber(scroll.pixelX, 0), + pixelY: finiteNumber(scroll.pixelY, 0), + }; engine.mouse.velocity = finiteNumber(mouse.velocity, 0); const events = Array.isArray(interaction.events) ? interaction.events : []; const keyEvents = []; @@ -196,6 +207,13 @@ } else if (entry.kind === 'wheel') { entry.delta = finiteNumber(event.delta, 0) / 120; mouseEvents.push(entry); + } else if (entry.kind === 'scroll') { + entry.deltaX = finiteNumber(event.deltaX, 0); + entry.deltaY = finiteNumber(event.deltaY, 0); + entry.unit = event.unit === 'pixels' ? 'pixels' : 'line120'; + entry.phase = scrollPhase(event.phase); + entry.momentumPhase = scrollPhase(event.momentumPhase); + mouseEvents.push(entry); } } engine.keyboard.events = keyEvents; diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index ad2018d22..24d1af72f 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -108,9 +108,15 @@ export type { KeyboardInputState, KeyEventState, KeyInputEvent, + MouseButtonInputEvent, MouseInputEvent, MouseInputState, MouseMode, + MouseScrollInputEvent, + MouseScrollPhase, + MouseScrollState, + MouseScrollUnit, + MouseWheelInputEvent, PressEnvelopeOptions, TypingRateOptions, } from './input' diff --git a/sdk/packages/core/src/input/data.ts b/sdk/packages/core/src/input/data.ts index 1832a9ddd..4ee944aec 100644 --- a/sdk/packages/core/src/input/data.ts +++ b/sdk/packages/core/src/input/data.ts @@ -12,6 +12,9 @@ import { KeyboardInputState, KeyInputEvent, MouseInputEvent, + MouseScrollPhase, + MouseScrollState, + MouseScrollUnit, MouseInputState, MouseMode, } from './types' @@ -85,6 +88,7 @@ function readMouse(raw: any): MouseInputState { mode, nx: clamp01(finiteNumber(raw.nx, 0)), ny: clamp01(finiteNumber(raw.ny, 0)), + scroll: readMouseScroll(raw.scroll), velocity: finiteNumber(raw.velocity, 0), wheel: finiteNumber(raw.wheel, 0), x: Math.trunc(finiteNumber(raw.x, 0)), @@ -101,6 +105,7 @@ function createIdleMouse(): MouseInputState { mode: 'none', nx: 0, ny: 0, + scroll: createIdleScroll(), velocity: 0, wheel: 0, x: 0, @@ -158,11 +163,58 @@ function readMouseEvents(raw: unknown): MouseInputEvent[] { } if (typeof entry.physicalCode === 'string') event.physicalCode = entry.physicalCode events.push(event) + } else if (entry.kind === 'scroll') { + const event: MouseInputEvent = { + atMs: finiteNumber(entry.atMs, 0), + deltaX: finiteNumber(entry.deltaX, 0), + deltaY: finiteNumber(entry.deltaY, 0), + kind: 'scroll', + momentumPhase: readMouseScrollPhase(entry.momentumPhase), + phase: readMouseScrollPhase(entry.phase), + repeatCount: positiveInteger(entry.repeatCount, 1), + seq: finiteNumber(entry.seq, 0), + source: typeof entry.source === 'string' ? entry.source : '', + unit: readMouseScrollUnit(entry.unit), + } + if (typeof entry.physicalCode === 'string') event.physicalCode = entry.physicalCode + events.push(event) } } return events } +function readMouseScroll(raw: any): MouseScrollState { + if (typeof raw !== 'object' || raw === null) return createIdleScroll() + return { + line120X: finiteNumber(raw.line120X, 0), + line120Y: finiteNumber(raw.line120Y, 0), + pixelX: finiteNumber(raw.pixelX, 0), + pixelY: finiteNumber(raw.pixelY, 0), + } +} + +function createIdleScroll(): MouseScrollState { + return { line120X: 0, line120Y: 0, pixelX: 0, pixelY: 0 } +} + +function readMouseScrollUnit(raw: unknown): MouseScrollUnit { + return raw === 'pixels' ? raw : 'line120' +} + +function readMouseScrollPhase(raw: unknown): MouseScrollPhase { + switch (raw) { + case 'may_begin': + case 'began': + case 'changed': + case 'stationary': + case 'ended': + case 'cancelled': + return raw + default: + return 'none' + } +} + function readMouseMode(raw: unknown): MouseMode { return raw === 'absolute' || raw === 'virtual' ? raw : 'none' } diff --git a/sdk/packages/core/src/input/index.ts b/sdk/packages/core/src/input/index.ts index 861e76f01..f961b335c 100644 --- a/sdk/packages/core/src/input/index.ts +++ b/sdk/packages/core/src/input/index.ts @@ -15,7 +15,13 @@ export type { KeyboardInputState, KeyEventState, KeyInputEvent, + MouseButtonInputEvent, MouseInputEvent, MouseInputState, MouseMode, + MouseScrollInputEvent, + MouseScrollPhase, + MouseScrollState, + MouseScrollUnit, + MouseWheelInputEvent, } from './types' diff --git a/sdk/packages/core/src/input/types.ts b/sdk/packages/core/src/input/types.ts index 7631e117f..9c2ead790 100644 --- a/sdk/packages/core/src/input/types.ts +++ b/sdk/packages/core/src/input/types.ts @@ -33,20 +33,15 @@ export interface KeyInputEvent { repeatCount: number } -/** - * A single mouse button or wheel event, ordered by `seq` and stamped with - * the capture timestamp (`atMs`, monotonic milliseconds). - */ -export interface MouseInputEvent { - kind: 'button' | 'wheel' +/** Coordinate unit carried by an exact scroll event. */ +export type MouseScrollUnit = 'line120' | 'pixels' + +/** Lifecycle phase carried by an exact scroll event. */ +export type MouseScrollPhase = 'none' | 'may_begin' | 'began' | 'changed' | 'stationary' | 'ended' | 'cancelled' + +interface MouseInputEventBase { /** Identifier of the device that produced the event. */ source: string - /** Button name (present for `kind: 'button'`). */ - button?: string - /** Button lifecycle (present for `kind: 'button'`). */ - state?: KeyEventState - /** Wheel delta in notches (present for `kind: 'wheel'`). */ - delta?: number /** Capture timestamp in monotonic milliseconds. */ atMs: number /** Strictly increasing sequence number. */ @@ -57,6 +52,45 @@ export interface MouseInputEvent { repeatCount: number } +/** One ordered mouse-button lifecycle event. */ +export interface MouseButtonInputEvent extends MouseInputEventBase { + kind: 'button' + button: string + state: KeyEventState +} + +/** One ordered exact two-axis scroll event. */ +export interface MouseScrollInputEvent extends MouseInputEventBase { + kind: 'scroll' + deltaX: number + deltaY: number + unit: MouseScrollUnit + phase: MouseScrollPhase + momentumPhase: MouseScrollPhase +} + +/** + * One ordered legacy vertical wheel event. + * + * @deprecated Consume the adjacent `scroll` event instead. This member remains + * available through the next API major. + */ +export interface MouseWheelInputEvent extends MouseInputEventBase { + kind: 'wheel' + delta: number +} + +/** Mouse event ordered by `seq` and stamped with monotonic capture time. */ +export type MouseInputEvent = MouseButtonInputEvent | MouseScrollInputEvent | MouseWheelInputEvent + +/** Exact two-axis scroll totals for the current frame. */ +export interface MouseScrollState { + line120X: number + line120Y: number + pixelX: number + pixelY: number +} + /** Keyboard snapshot for the current frame. */ export interface KeyboardInputState { /** Currently held keys (includes alias forms like "A" and "KeyA"). */ @@ -87,9 +121,11 @@ export interface MouseInputState { available: boolean /** Accumulated wheel notches this frame (hi-res deltas divided by 120). */ wheel: number + /** Exact two-axis scroll accumulated independently by coordinate unit. */ + scroll: MouseScrollState /** Normalized pointer motion magnitude per second. */ velocity: number - /** Ordered button/wheel events captured since the last frame. */ + /** Ordered button, scroll, and compatibility wheel events captured this frame. */ events: MouseInputEvent[] } diff --git a/sdk/packages/core/tests/input-data.test.ts b/sdk/packages/core/tests/input-data.test.ts index 0e8338cff..5f90735a5 100644 --- a/sdk/packages/core/tests/input-data.test.ts +++ b/sdk/packages/core/tests/input-data.test.ts @@ -29,6 +29,7 @@ describe('input data contract', () => { expect(input.mouse.y).toBe(0) expect(input.mouse.nx).toBe(0) expect(input.mouse.ny).toBe(0) + expect(input.mouse.scroll).toEqual({ line120X: 0, line120Y: 0, pixelX: 0, pixelY: 0 }) expect(input.mouse.wheel).toBe(0) expect(input.mouse.velocity).toBe(0) }) @@ -66,11 +67,24 @@ describe('input data contract', () => { events: [ { atMs: 1005, button: 'left', kind: 'button', seq: 3, source: 'mouse0', state: 'pressed' }, { atMs: 1006, button: 'left', kind: 'button', seq: 4, source: 'mouse0', state: 'repeated' }, - { atMs: 1007, delta: 1.5, kind: 'wheel', seq: 5, source: 'mouse0' }, + { + atMs: 1007, + deltaX: 0.5, + deltaY: -0.25, + kind: 'scroll', + momentumPhase: 'began', + phase: 'changed', + physicalCode: 'macos:scroll', + seq: 5, + source: 'mouse0', + unit: 'pixels', + }, + { atMs: 1008, delta: 1.5, kind: 'wheel', seq: 6, source: 'mouse0' }, ], mode: 'virtual', nx: 0.25, ny: 0.75, + scroll: { line120X: 0.5, line120Y: -2, pixelX: 1.5, pixelY: -0.25 }, velocity: 0.4, wheel: 1.5, x: 320, @@ -119,6 +133,7 @@ describe('input data contract', () => { expect(input.mouse.x).toBe(320) expect(input.mouse.y).toBe(240) expect(input.mouse.wheel).toBe(1.5) + expect(input.mouse.scroll).toEqual({ line120X: 0.5, line120Y: -2, pixelX: 1.5, pixelY: -0.25 }) expect(input.mouse.velocity).toBe(0.4) expect(input.mouse.events).toEqual([ { @@ -139,7 +154,20 @@ describe('input data contract', () => { source: 'mouse0', state: 'repeated', }, - { atMs: 1007, delta: 1.5, kind: 'wheel', repeatCount: 1, seq: 5, source: 'mouse0' }, + { + atMs: 1007, + deltaX: 0.5, + deltaY: -0.25, + kind: 'scroll', + momentumPhase: 'began', + phase: 'changed', + physicalCode: 'macos:scroll', + repeatCount: 1, + seq: 5, + source: 'mouse0', + unit: 'pixels', + }, + { atMs: 1008, delta: 1.5, kind: 'wheel', repeatCount: 1, seq: 6, source: 'mouse0' }, ]) }) @@ -232,5 +260,6 @@ describe('input data contract', () => { expect(input.mouse.mode).toBe('none') expect(input.mouse.nx).toBe(0) expect(input.mouse.x).toBe(12) + expect(input.mouse.scroll).toEqual({ line120X: 0, line120Y: 0, pixelX: 0, pixelY: 0 }) }) }) diff --git a/sdk/packages/core/tests/input-runtime-bridge.test.ts b/sdk/packages/core/tests/input-runtime-bridge.test.ts index 33ad041fc..1a12813ab 100644 --- a/sdk/packages/core/tests/input-runtime-bridge.test.ts +++ b/sdk/packages/core/tests/input-runtime-bridge.test.ts @@ -55,9 +55,28 @@ describe('LightScript input availability bridge', () => { source: 'mouse0', state: 'pressed', }, + { + atMs: 1002, + deltaX: 0.5, + deltaY: -0.25, + kind: 'scroll', + momentumPhase: 'began', + phase: 'changed', + physicalCode: 'macos:scroll', + repeatCount: 1, + seq: 3, + source: 'mouse0', + unit: 'pixels', + }, + { atMs: 1003, delta: -240, kind: 'wheel', repeatCount: 1, seq: 4, source: 'mouse0' }, ], keyboard: { keys: ['a'], recent: ['a'] }, - mouse: { buttons: ['left'], mode: 'virtual' }, + mouse: { + buttons: ['left'], + mode: 'virtual', + scroll: { line120X: 0.5, line120Y: -2, pixelX: 1.5, pixelY: -0.25 }, + wheel: -240, + }, }, timing: { deltaSecs: 1 / 60, frameNumber: 8, timeSecs: 1 }, }) @@ -87,7 +106,23 @@ describe('LightScript input availability bridge', () => { source: 'mouse0', state: 'pressed', }, + { + atMs: 1002, + deltaX: 0.5, + deltaY: -0.25, + kind: 'scroll', + momentumPhase: 'began', + phase: 'changed', + physicalCode: 'macos:scroll', + repeatCount: 1, + seq: 3, + source: 'mouse0', + unit: 'pixels', + }, + { atMs: 1003, delta: -2, kind: 'wheel', repeatCount: 1, seq: 4, source: 'mouse0' }, ]) + expect(input.mouse.scroll).toEqual({ line120X: 0.5, line120Y: -2, pixelX: 1.5, pixelY: -0.25 }) + expect(input.mouse.wheel).toBe(-2) }) test('keeps an idle healthy routed source available', () => { From ec5dfc5459d68715a1556fd2b1f713173924fc20 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:17:03 -0700 Subject: [PATCH 013/144] fix(sdk): preserve legacy wheel units 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) --- .../src/effect/lightscript/frame_payload_adapter.js | 4 ++-- sdk/packages/core/src/input/types.ts | 3 ++- sdk/packages/core/tests/input-runtime-bridge.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js b/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js index 95592fe4e..3361c5c9d 100644 --- a/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js +++ b/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js @@ -174,7 +174,7 @@ engine.mouse.ny = finiteNumber(mouse.ny, 0); engine.mouse.mode = typeof mouse.mode === 'string' ? mouse.mode : 'none'; engine.mouse.available = engine.mouse.mode !== 'none'; - engine.mouse.wheel = finiteNumber(mouse.wheel, 0) / 120; + engine.mouse.wheel = finiteNumber(mouse.wheel, 0); const scroll = typeof mouse.scroll === 'object' && mouse.scroll !== null ? mouse.scroll : {}; engine.mouse.scroll = { line120X: finiteNumber(scroll.line120X, 0), @@ -205,7 +205,7 @@ entry.button = typeof event.button === 'string' ? event.button : ''; mouseEvents.push(entry); } else if (entry.kind === 'wheel') { - entry.delta = finiteNumber(event.delta, 0) / 120; + entry.delta = finiteNumber(event.delta, 0); mouseEvents.push(entry); } else if (entry.kind === 'scroll') { entry.deltaX = finiteNumber(event.deltaX, 0); diff --git a/sdk/packages/core/src/input/types.ts b/sdk/packages/core/src/input/types.ts index 9c2ead790..823bd6a27 100644 --- a/sdk/packages/core/src/input/types.ts +++ b/sdk/packages/core/src/input/types.ts @@ -77,6 +77,7 @@ export interface MouseScrollInputEvent extends MouseInputEventBase { */ export interface MouseWheelInputEvent extends MouseInputEventBase { kind: 'wheel' + /** Integral vertical wheel delta in 1/120-notch units. */ delta: number } @@ -119,7 +120,7 @@ export interface MouseInputState { mode: MouseMode /** True when pointer coordinates are meaningful (`mode !== 'none'`). */ available: boolean - /** Accumulated wheel notches this frame (hi-res deltas divided by 120). */ + /** Accumulated integral vertical wheel delta in 1/120-notch units. */ wheel: number /** Exact two-axis scroll accumulated independently by coordinate unit. */ scroll: MouseScrollState diff --git a/sdk/packages/core/tests/input-runtime-bridge.test.ts b/sdk/packages/core/tests/input-runtime-bridge.test.ts index 1a12813ab..619dcd6e9 100644 --- a/sdk/packages/core/tests/input-runtime-bridge.test.ts +++ b/sdk/packages/core/tests/input-runtime-bridge.test.ts @@ -119,10 +119,10 @@ describe('LightScript input availability bridge', () => { source: 'mouse0', unit: 'pixels', }, - { atMs: 1003, delta: -2, kind: 'wheel', repeatCount: 1, seq: 4, source: 'mouse0' }, + { atMs: 1003, delta: -240, kind: 'wheel', repeatCount: 1, seq: 4, source: 'mouse0' }, ]) expect(input.mouse.scroll).toEqual({ line120X: 0.5, line120Y: -2, pixelX: 1.5, pixelY: -0.25 }) - expect(input.mouse.wheel).toBe(-2) + expect(input.mouse.wheel).toBe(-240) }) test('keeps an idle healthy routed source available', () => { From e07d6fa0a6e6c2d3d440769ec8bcbb7bfb9810f3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:21:07 -0700 Subject: [PATCH 014/144] feat(macos): add native input contract 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) --- Cargo.lock | 7 + Cargo.toml | 1 + crates/hypercolor-macos-input/Cargo.toml | 19 ++ crates/hypercolor-macos-input/src/decode.rs | 128 ++++++++++ crates/hypercolor-macos-input/src/lib.rs | 18 ++ crates/hypercolor-macos-input/src/shared.rs | 240 ++++++++++++++++++ .../tests/input_contract_tests.rs | 191 ++++++++++++++ 7 files changed, 604 insertions(+) create mode 100644 crates/hypercolor-macos-input/Cargo.toml create mode 100644 crates/hypercolor-macos-input/src/decode.rs create mode 100644 crates/hypercolor-macos-input/src/lib.rs create mode 100644 crates/hypercolor-macos-input/src/shared.rs create mode 100644 crates/hypercolor-macos-input/tests/input_contract_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8fa85bf5d..d4a71fa5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5355,6 +5355,13 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "hypercolor-macos-input" +version = "0.3.1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "hypercolor-network" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index b7cd4b576..968e35941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,7 @@ hypercolor-core = { path = "crates/hypercolor-core" } hypercolor-platform-fs = { path = "crates/hypercolor-platform-fs" } hypercolor-linux-gpu-interop = { path = "crates/hypercolor-linux-gpu-interop" } hypercolor-macos-gpu-interop = { path = "crates/hypercolor-macos-gpu-interop" } +hypercolor-macos-input = { path = "crates/hypercolor-macos-input" } hypercolor-windows-capture = { path = "crates/hypercolor-windows-capture" } hypercolor-windows-gpu-interop = { path = "crates/hypercolor-windows-gpu-interop" } hypercolor-driver-api = { path = "crates/hypercolor-driver-api" } diff --git a/crates/hypercolor-macos-input/Cargo.toml b/crates/hypercolor-macos-input/Cargo.toml new file mode 100644 index 000000000..391963542 --- /dev/null +++ b/crates/hypercolor-macos-input/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hypercolor-macos-input" +description = "macOS event-tap host input capture for Hypercolor" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +undocumented_unsafe_blocks = "deny" +unwrap_used = "deny" + +[dependencies] +thiserror = { workspace = true } diff --git a/crates/hypercolor-macos-input/src/decode.rs b/crates/hypercolor-macos-input/src/decode.rs new file mode 100644 index 000000000..41590cdbb --- /dev/null +++ b/crates/hypercolor-macos-input/src/decode.rs @@ -0,0 +1,128 @@ +//! Pure decoding for Core Graphics and AppKit scalar fields. + +use crate::shared::{EffectiveEventMasks, MacosMediaKey, MacosPointerButton, MacosScrollPhase}; + +pub const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8; + +const EVENT_LEFT_MOUSE_DOWN: u32 = 1; +const EVENT_LEFT_MOUSE_UP: u32 = 2; +const EVENT_RIGHT_MOUSE_DOWN: u32 = 3; +const EVENT_RIGHT_MOUSE_UP: u32 = 4; +const EVENT_MOUSE_MOVED: u32 = 5; +const EVENT_LEFT_MOUSE_DRAGGED: u32 = 6; +const EVENT_RIGHT_MOUSE_DRAGGED: u32 = 7; +const EVENT_KEY_DOWN: u32 = 10; +const EVENT_KEY_UP: u32 = 11; +const EVENT_FLAGS_CHANGED: u32 = 12; +const EVENT_SYSTEM_DEFINED: u32 = 14; +const EVENT_SCROLL_WHEEL: u32 = 22; +const EVENT_OTHER_MOUSE_DOWN: u32 = 25; +const EVENT_OTHER_MOUSE_UP: u32 = 26; +const EVENT_OTHER_MOUSE_DRAGGED: u32 = 27; + +const fn event_mask(event_type: u32) -> u64 { + 1_u64 << event_type +} + +/// Build independent keyboard and pointer masks from requested capabilities. +#[must_use] +pub const fn event_masks(keyboard: bool, pointer: bool) -> EffectiveEventMasks { + let keyboard_mask = if keyboard { + event_mask(EVENT_KEY_DOWN) + | event_mask(EVENT_KEY_UP) + | event_mask(EVENT_FLAGS_CHANGED) + | event_mask(EVENT_SYSTEM_DEFINED) + } else { + 0 + }; + let pointer_mask = if pointer { + event_mask(EVENT_MOUSE_MOVED) + | event_mask(EVENT_LEFT_MOUSE_DRAGGED) + | event_mask(EVENT_RIGHT_MOUSE_DRAGGED) + | event_mask(EVENT_OTHER_MOUSE_DRAGGED) + | event_mask(EVENT_LEFT_MOUSE_DOWN) + | event_mask(EVENT_LEFT_MOUSE_UP) + | event_mask(EVENT_RIGHT_MOUSE_DOWN) + | event_mask(EVENT_RIGHT_MOUSE_UP) + | event_mask(EVENT_OTHER_MOUSE_DOWN) + | event_mask(EVENT_OTHER_MOUSE_UP) + | event_mask(EVENT_SCROLL_WHEEL) + } else { + 0 + }; + EffectiveEventMasks { + keyboard: keyboard_mask, + pointer: pointer_mask, + } +} + +/// Decode an AppKit subtype-8 packed media-key payload. +#[must_use] +pub fn decode_media_key(subtype: i16, data1: i64) -> Option { + if subtype != NX_SUBTYPE_AUX_CONTROL_BUTTONS { + return None; + } + let packed = u32::try_from(data1).ok()?; + let nx_key_type = u16::try_from(packed >> 16).ok()?; + let flags = u16::try_from(packed & 0xffff).ok()?; + let state = u8::try_from(flags >> 8).ok()?; + let pressed = match state { + 0x0a => true, + 0x0b => false, + _ => return None, + }; + Some(MacosMediaKey { + nx_key_type, + pressed, + repeat: flags & 1 != 0, + }) +} + +/// Decode a mouse-button event type and native button number. +#[must_use] +pub const fn decode_button_event( + event_type: u32, + button_number: u16, +) -> Option<(MacosPointerButton, bool)> { + match event_type { + EVENT_LEFT_MOUSE_DOWN => Some((MacosPointerButton::Left, true)), + EVENT_LEFT_MOUSE_UP => Some((MacosPointerButton::Left, false)), + EVENT_RIGHT_MOUSE_DOWN => Some((MacosPointerButton::Right, true)), + EVENT_RIGHT_MOUSE_UP => Some((MacosPointerButton::Right, false)), + EVENT_OTHER_MOUSE_DOWN | EVENT_OTHER_MOUSE_UP => { + let button = if button_number == 2 { + MacosPointerButton::Middle + } else { + MacosPointerButton::Other(button_number) + }; + Some((button, event_type == EVENT_OTHER_MOUSE_DOWN)) + } + _ => None, + } +} + +/// Decode `kCGScrollWheelEventScrollPhase`. +#[must_use] +pub const fn decode_scroll_phase(raw: i64) -> Option { + match raw { + 0 => Some(MacosScrollPhase::None), + 1 => Some(MacosScrollPhase::Began), + 2 => Some(MacosScrollPhase::Changed), + 4 => Some(MacosScrollPhase::Ended), + 8 => Some(MacosScrollPhase::Cancelled), + 128 => Some(MacosScrollPhase::MayBegin), + _ => None, + } +} + +/// Decode `kCGScrollWheelEventMomentumPhase`. +#[must_use] +pub const fn decode_momentum_phase(raw: i64) -> Option { + match raw { + 0 => Some(MacosScrollPhase::None), + 1 => Some(MacosScrollPhase::Began), + 2 => Some(MacosScrollPhase::Changed), + 3 => Some(MacosScrollPhase::Ended), + _ => None, + } +} diff --git a/crates/hypercolor-macos-input/src/lib.rs b/crates/hypercolor-macos-input/src/lib.rs new file mode 100644 index 000000000..2b6518f61 --- /dev/null +++ b/crates/hypercolor-macos-input/src/lib.rs @@ -0,0 +1,18 @@ +//! macOS host input capture vocabulary and native event decoding. +//! +//! Core Graphics and Core Foundation ownership stays inside this crate. The +//! public boundary contains only plain Rust values so canonical input folding +//! remains portable and deterministic in `hypercolor-core`. + +mod decode; +mod shared; + +pub use decode::{ + NX_SUBTYPE_AUX_CONTROL_BUTTONS, decode_button_event, decode_media_key, decode_momentum_phase, + decode_scroll_phase, event_masks, +}; +pub use shared::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, + MacosInputGapReason, MacosInputResult, MacosMediaKey, MacosModifierFlags, MacosPointerButton, + MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, +}; diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs new file mode 100644 index 000000000..fa1e4f205 --- /dev/null +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -0,0 +1,240 @@ +//! Platform-neutral values crossing the macOS input boundary. + +use std::sync::Arc; + +/// Capture configuration for one event-tap session. +#[derive(Clone)] +pub struct MacosInputConfig { + pub keyboard: bool, + pub pointer: bool, + pub epoch: u64, + pub clock: Arc u64 + Send + Sync>, +} + +impl std::fmt::Debug for MacosInputConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MacosInputConfig") + .field("keyboard", &self.keyboard) + .field("pointer", &self.pointer) + .field("epoch", &self.epoch) + .finish_non_exhaustive() + } +} + +/// Aggregate Core Graphics modifier flags retained without native types. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct MacosModifierFlags(u64); + +impl MacosModifierFlags { + pub const ALPHA_SHIFT: Self = Self(1 << 16); + pub const SHIFT: Self = Self(1 << 17); + pub const CONTROL: Self = Self(1 << 18); + pub const ALTERNATE: Self = Self(1 << 19); + pub const COMMAND: Self = Self(1 << 20); + pub const NUMERIC_PAD: Self = Self(1 << 21); + pub const HELP: Self = Self(1 << 22); + pub const SECONDARY_FN: Self = Self(1 << 23); + + #[must_use] + pub const fn from_bits(bits: u64) -> Self { + Self(bits) + } + + #[must_use] + pub const fn bits(self) -> u64 { + self.0 + } + + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +/// Pointer button reported by a Core Graphics event tap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosPointerButton { + Left, + Right, + Middle, + Other(u16), +} + +/// Unit of the signed 16.16 values in a wheel event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosScrollUnit { + /// Physical wheel notches before core scales them into `Line120` units. + Notches, + /// Continuous trackpad or Magic Mouse movement in pixels. + Pixels, +} + +/// Gesture phase attached to exact scroll motion. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum MacosScrollPhase { + #[default] + None, + Began, + Stationary, + Changed, + Ended, + Cancelled, + MayBegin, +} + +/// Why native state can no longer be treated as a complete edge stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosInputGapReason { + TapDisabledTimeout, + TapDisabledUserInput, + PermissionRevoked, + SessionInterrupted, + WorkerExited, + SourceStopped, + QueueOverflow, +} + +/// One decoded edge or ordered state barrier. +#[derive(Debug, Clone, PartialEq)] +pub enum MacosInputEvent { + Key { + virtual_keycode: u16, + pressed: bool, + autorepeat: bool, + }, + ModifierFlags { + virtual_keycode: u16, + flags: MacosModifierFlags, + }, + Button { + button: MacosPointerButton, + pressed: bool, + }, + Motion { + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, + }, + Wheel { + fixed_delta_x: i64, + fixed_delta_y: i64, + unit: MacosScrollUnit, + phase: MacosScrollPhase, + momentum_phase: MacosScrollPhase, + }, + MediaKey { + nx_key_type: u16, + pressed: bool, + repeat: bool, + }, + StateGap { + reason: MacosInputGapReason, + }, +} + +/// Decoded subtype-8 media-key payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosMediaKey { + pub nx_key_type: u16, + pub pressed: bool, + pub repeat: bool, +} + +/// Union of active macOS display bounds for one topology generation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacosVirtualDesktop { + pub origin_x: f64, + pub origin_y: f64, + pub width: f64, + pub height: f64, + pub topology_generation: u64, +} + +impl MacosVirtualDesktop { + /// Construct validated virtual-desktop bounds. + /// + /// # Errors + /// + /// Returns [`MacosInputError::InvalidVirtualDesktop`] for non-finite or + /// non-positive dimensions and non-finite origins. + pub fn new( + origin_x: f64, + origin_y: f64, + width: f64, + height: f64, + topology_generation: u64, + ) -> MacosInputResult { + if !origin_x.is_finite() + || !origin_y.is_finite() + || !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + { + return Err(MacosInputError::InvalidVirtualDesktop); + } + Ok(Self { + origin_x, + origin_y, + width, + height, + topology_generation, + }) + } + + /// Normalize a signed global point into the current display union. + #[must_use] + pub fn normalize(self, x: f64, y: f64) -> (f64, f64) { + let nx = ((x - self.origin_x) / self.width).clamp(0.0, 1.0); + let ny = ((y - self.origin_y) / self.height).clamp(0.0, 1.0); + (nx, ny) + } +} + +/// One coherent native queue drain. +#[derive(Debug)] +pub struct MacosInputBatch<'a> { + pub epoch: u64, + pub at_ms: u64, + pub events: &'a [MacosInputEvent], + pub virtual_desktop: MacosVirtualDesktop, +} + +/// Event masks actually requested for one session. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct EffectiveEventMasks { + pub keyboard: u64, + pub pointer: u64, +} + +/// Liveness of the event-tap worker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacosWorkerState { + Running, + Degraded(String), + Failed(String), +} + +/// Errors from validating or starting macOS host input capture. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MacosInputError { + #[error("macOS host input is only available on macOS")] + UnsupportedPlatform, + #[error("no input kinds enabled for capture")] + NothingToCapture, + #[error("keyboard capture requires Input Monitoring permission")] + PermissionDenied, + #[error("invalid virtual desktop bounds")] + InvalidVirtualDesktop, + #[error("failed to spawn the macOS input worker: {0}")] + WorkerSpawn(String), + #[error("timed out waiting for the macOS input worker to become ready")] + WorkerReadyTimeout, + #[error("failed to create the {0} event tap")] + TapCreation(&'static str), + #[error("failed to create the {0} event-tap run-loop source")] + RunLoopSource(&'static str), +} + +pub type MacosInputResult = Result; diff --git a/crates/hypercolor-macos-input/tests/input_contract_tests.rs b/crates/hypercolor-macos-input/tests/input_contract_tests.rs new file mode 100644 index 000000000..0c8301d67 --- /dev/null +++ b/crates/hypercolor-macos-input/tests/input_contract_tests.rs @@ -0,0 +1,191 @@ +use std::sync::Arc; + +use hypercolor_macos_input::{ + MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, MacosInputGapReason, + MacosModifierFlags, MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, + NX_SUBTYPE_AUX_CONTROL_BUTTONS, decode_button_event, decode_media_key, decode_momentum_phase, + decode_scroll_phase, event_masks, +}; + +#[test] +fn config_debug_omits_the_injected_clock() { + let config = MacosInputConfig { + keyboard: true, + pointer: false, + epoch: 42, + clock: Arc::new(|| 7), + }; + + assert_eq!( + format!("{config:?}"), + "MacosInputConfig { keyboard: true, pointer: false, epoch: 42, .. }" + ); + assert_eq!((config.clock)(), 7); +} + +#[test] +fn masks_keep_keyboard_and_pointer_consent_independent() { + let keyboard = event_masks(true, false); + let pointer = event_masks(false, true); + let both = event_masks(true, true); + + assert_ne!(keyboard.keyboard, 0); + assert_eq!(keyboard.pointer, 0); + assert_eq!(pointer.keyboard, 0); + assert_ne!(pointer.pointer, 0); + assert_eq!(both.keyboard, keyboard.keyboard); + assert_eq!(both.pointer, pointer.pointer); + assert_eq!(event_masks(false, false), Default::default()); +} + +#[test] +fn media_decoder_accepts_only_valid_subtype_eight_payloads() { + let pressed = (16_i64 << 16) | (0x0a_i64 << 8) | 1; + let released = (18_i64 << 16) | (0x0b_i64 << 8); + + assert_eq!( + decode_media_key(NX_SUBTYPE_AUX_CONTROL_BUTTONS, pressed), + Some(hypercolor_macos_input::MacosMediaKey { + nx_key_type: 16, + pressed: true, + repeat: true, + }) + ); + assert_eq!( + decode_media_key(NX_SUBTYPE_AUX_CONTROL_BUTTONS, released), + Some(hypercolor_macos_input::MacosMediaKey { + nx_key_type: 18, + pressed: false, + repeat: false, + }) + ); + assert_eq!(decode_media_key(7, pressed), None); + assert_eq!(decode_media_key(8, 16_i64 << 16), None); + assert_eq!(decode_media_key(8, -1), None); +} + +#[test] +fn button_decoder_preserves_numbered_extras() { + assert_eq!( + decode_button_event(1, 0), + Some((MacosPointerButton::Left, true)) + ); + assert_eq!( + decode_button_event(4, 1), + Some((MacosPointerButton::Right, false)) + ); + assert_eq!( + decode_button_event(25, 2), + Some((MacosPointerButton::Middle, true)) + ); + assert_eq!( + decode_button_event(26, 7), + Some((MacosPointerButton::Other(7), false)) + ); + assert_eq!(decode_button_event(5, 0), None); +} + +#[test] +fn scroll_phases_use_core_graphics_native_values() { + assert_eq!(decode_scroll_phase(0), Some(MacosScrollPhase::None)); + assert_eq!(decode_scroll_phase(1), Some(MacosScrollPhase::Began)); + assert_eq!(decode_scroll_phase(2), Some(MacosScrollPhase::Changed)); + assert_eq!(decode_scroll_phase(4), Some(MacosScrollPhase::Ended)); + assert_eq!(decode_scroll_phase(8), Some(MacosScrollPhase::Cancelled)); + assert_eq!(decode_scroll_phase(128), Some(MacosScrollPhase::MayBegin)); + assert_eq!(decode_scroll_phase(16), None); + + assert_eq!(decode_momentum_phase(0), Some(MacosScrollPhase::None)); + assert_eq!(decode_momentum_phase(1), Some(MacosScrollPhase::Began)); + assert_eq!(decode_momentum_phase(2), Some(MacosScrollPhase::Changed)); + assert_eq!(decode_momentum_phase(3), Some(MacosScrollPhase::Ended)); + assert_eq!(decode_momentum_phase(4), None); +} + +#[test] +fn modifier_flags_preserve_distinct_native_bits() { + let flags = MacosModifierFlags::from_bits( + MacosModifierFlags::SHIFT.bits() | MacosModifierFlags::COMMAND.bits(), + ); + + assert!(flags.contains(MacosModifierFlags::SHIFT)); + assert!(flags.contains(MacosModifierFlags::COMMAND)); + assert!(!flags.contains(MacosModifierFlags::CONTROL)); + assert_eq!(flags.bits(), (1 << 17) | (1 << 20)); +} + +#[test] +fn virtual_desktop_normalizes_negative_origins_and_clamps_edges() { + let desktop = MacosVirtualDesktop::new(-1920.0, -120.0, 4480.0, 1560.0, 9) + .expect("fixture bounds are valid"); + + assert_eq!(desktop.normalize(-1920.0, -120.0), (0.0, 0.0)); + assert_eq!(desktop.normalize(320.0, 660.0), (0.5, 0.5)); + assert_eq!(desktop.normalize(4000.0, -500.0), (1.0, 0.0)); + assert_eq!(desktop.topology_generation, 9); +} + +#[test] +fn virtual_desktop_rejects_nonfinite_and_empty_bounds() { + assert_eq!( + MacosVirtualDesktop::new(0.0, 0.0, 0.0, 100.0, 1), + Err(MacosInputError::InvalidVirtualDesktop) + ); + assert_eq!( + MacosVirtualDesktop::new(f64::NAN, 0.0, 100.0, 100.0, 1), + Err(MacosInputError::InvalidVirtualDesktop) + ); +} + +#[test] +fn batch_carries_the_complete_plain_rust_vocabulary() { + let events = [ + MacosInputEvent::Key { + virtual_keycode: 0, + pressed: true, + autorepeat: false, + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x38, + flags: MacosModifierFlags::SHIFT, + }, + MacosInputEvent::Button { + button: MacosPointerButton::Middle, + pressed: true, + }, + MacosInputEvent::Motion { + x: -10.0, + y: 30.0, + delta_x: 2.0, + delta_y: -1.0, + }, + MacosInputEvent::Wheel { + fixed_delta_x: 1 << 15, + fixed_delta_y: -(1 << 16), + unit: MacosScrollUnit::Pixels, + phase: MacosScrollPhase::Changed, + momentum_phase: MacosScrollPhase::Began, + }, + MacosInputEvent::MediaKey { + nx_key_type: 16, + pressed: true, + repeat: false, + }, + MacosInputEvent::StateGap { + reason: MacosInputGapReason::QueueOverflow, + }, + ]; + let desktop = + MacosVirtualDesktop::new(0.0, 0.0, 100.0, 100.0, 2).expect("fixture bounds are valid"); + let batch = MacosInputBatch { + epoch: 4, + at_ms: 55, + events: &events, + virtual_desktop: desktop, + }; + + assert_eq!(batch.epoch, 4); + assert_eq!(batch.at_ms, 55); + assert_eq!(batch.events, events); + assert_eq!(batch.virtual_desktop, desktop); +} From 1900af8c27a8f57c2ea25c0268c99315236ff808 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:35:17 -0700 Subject: [PATCH 015/144] feat(macos): implement event-tap input session 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) --- Cargo.lock | 4 + Cargo.toml | 2 + crates/hypercolor-macos-input/Cargo.toml | 12 + crates/hypercolor-macos-input/src/lib.rs | 21 +- crates/hypercolor-macos-input/src/macos.rs | 659 ++++++++++++++++++ crates/hypercolor-macos-input/src/queue.rs | 223 ++++++ crates/hypercolor-macos-input/src/shared.rs | 16 + crates/hypercolor-macos-input/src/stubs.rs | 60 ++ .../tests/input_contract_tests.rs | 45 +- 9 files changed, 1038 insertions(+), 4 deletions(-) create mode 100644 crates/hypercolor-macos-input/src/macos.rs create mode 100644 crates/hypercolor-macos-input/src/queue.rs create mode 100644 crates/hypercolor-macos-input/src/stubs.rs diff --git a/Cargo.lock b/Cargo.lock index d4a71fa5f..0bc07f418 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5359,6 +5359,10 @@ dependencies = [ name = "hypercolor-macos-input" version = "0.3.1" dependencies = [ + "crossbeam-queue", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 968e35941..f3d026043 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # Data structures +crossbeam-queue = "0.3.12" uuid = { version = "1.11", features = ["v4", "v5", "v7", "serde"] } ulid = { version = "1.2.1", features = ["serde"] } sha2 = "0.10" @@ -222,6 +223,7 @@ wgpu = { version = "29.0.1", default-features = false, features = ["std", "vulka wgpu-hal = { version = "29.0.1", default-features = false, features = ["vulkan"] } objc2-core-foundation = { version = "0.3.2", default-features = false } objc2-core-graphics = { version = "0.3.2", default-features = false } +objc2-app-kit = { version = "0.3.2", default-features = false } objc2-core-media = { version = "0.3.2", default-features = false } objc2-core-video = { version = "0.3.2", default-features = false } objc2-foundation = { version = "0.3.2", default-features = false } diff --git a/crates/hypercolor-macos-input/Cargo.toml b/crates/hypercolor-macos-input/Cargo.toml index 391963542..a61a528cf 100644 --- a/crates/hypercolor-macos-input/Cargo.toml +++ b/crates/hypercolor-macos-input/Cargo.toml @@ -16,4 +16,16 @@ undocumented_unsafe_blocks = "deny" unwrap_used = "deny" [dependencies] +crossbeam-queue = { workspace = true } thiserror = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { workspace = true, features = ["std", "NSEvent", "objc2-core-graphics"] } +objc2-core-foundation = { workspace = true, features = ["std", "CFMachPort", "CFRunLoop"] } +objc2-core-graphics = { workspace = true, features = [ + "std", + "CGDirectDisplay", + "CGError", + "CGEvent", + "CGEventTypes", +] } diff --git a/crates/hypercolor-macos-input/src/lib.rs b/crates/hypercolor-macos-input/src/lib.rs index 2b6518f61..a0baa1c5e 100644 --- a/crates/hypercolor-macos-input/src/lib.rs +++ b/crates/hypercolor-macos-input/src/lib.rs @@ -5,6 +5,7 @@ //! remains portable and deterministic in `hypercolor-core`. mod decode; +mod queue; mod shared; pub use decode::{ @@ -12,7 +13,21 @@ pub use decode::{ decode_scroll_phase, event_masks, }; pub use shared::{ - EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, - MacosInputGapReason, MacosInputResult, MacosMediaKey, MacosModifierFlags, MacosPointerButton, - MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputEvent, MacosInputGapReason, MacosInputResult, MacosMediaKey, MacosModifierFlags, + MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, +}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{ + MacosInputSession, current_virtual_desktop, input_monitoring_granted, request_input_monitoring, +}; + +#[cfg(not(target_os = "macos"))] +mod stubs; +#[cfg(not(target_os = "macos"))] +pub use stubs::{ + MacosInputSession, current_virtual_desktop, input_monitoring_granted, request_input_monitoring, }; diff --git a/crates/hypercolor-macos-input/src/macos.rs b/crates/hypercolor-macos-input/src/macos.rs new file mode 100644 index 000000000..10d800ff9 --- /dev/null +++ b/crates/hypercolor-macos-input/src/macos.rs @@ -0,0 +1,659 @@ +use std::ffi::c_void; +use std::ptr::NonNull; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use objc2_app_kit::NSEvent; +use objc2_core_foundation::{ + CFMachPort, CFRetained, CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes, +}; +use objc2_core_graphics::{ + CGDisplayBounds, CGError, CGEvent, CGEventField, CGEventTapLocation, CGEventTapOptions, + CGEventTapPlacement, CGEventType, CGGetActiveDisplayList, CGPreflightListenEventAccess, + CGRequestListenEventAccess, +}; + +use crate::queue::{DEFAULT_QUEUE_CAPACITY, EventQueue}; +use crate::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputEvent, MacosInputGapReason, MacosInputResult, MacosModifierFlags, MacosScrollPhase, + MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, decode_button_event, decode_media_key, + decode_momentum_phase, decode_scroll_phase, event_masks, +}; + +const READY_TIMEOUT: Duration = Duration::from_secs(2); +const HEALTH_INTERVAL: Duration = Duration::from_millis(250); +const TOPOLOGY_INTERVAL: Duration = Duration::from_secs(1); +const TAP_DISABLE_HEALTH_WINDOW: Duration = Duration::from_secs(10); +const SYSTEM_DEFINED_EVENT: CGEventType = CGEventType(14); + +#[derive(Debug, Clone, Copy)] +enum TapKind { + Keyboard, + Pointer, +} + +impl TapKind { + const fn label(self) -> &'static str { + match self { + Self::Keyboard => "keyboard", + Self::Pointer => "pointer", + } + } +} + +struct RunLoopControl { + stopping: AtomicBool, + address: Mutex, +} + +impl RunLoopControl { + fn new() -> Self { + Self { + stopping: AtomicBool::new(false), + address: Mutex::new(0), + } + } + + fn install(&self, run_loop: &CFRunLoop) { + *self + .address + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + std::ptr::from_ref(run_loop).expose_provenance(); + } + + fn clear(&self) { + *self + .address + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = 0; + } + + fn request_stop(&self) { + self.stopping.store(true, Ordering::Release); + let address = self + .address + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if *address == 0 { + return; + } + // SAFETY: the run-loop worker owns the retained object and clears this + // address under the same mutex before releasing it. Core Foundation + // permits stopping and waking a run loop from another thread. + let run_loop = unsafe { &*std::ptr::with_exposed_provenance::(*address) }; + run_loop.stop(); + run_loop.wake_up(); + } +} + +struct TapContext { + queue: Arc, + tap: AtomicPtr, + last_disable_ms: AtomicU64, +} + +struct TapBundle { + source: CFRetained, + tap: CFRetained, + context: Box, +} + +impl TapBundle { + fn teardown(&self, run_loop: &CFRunLoop) { + // SAFETY: Core Foundation exports this process-lifetime static mode. + let mode = unsafe { kCFRunLoopCommonModes }; + run_loop.remove_source(Some(&self.source), mode); + self.tap.invalidate(); + self.context + .tap + .store(std::ptr::null_mut(), Ordering::Release); + } +} + +/// A live Core Graphics event-tap session. +pub struct MacosInputSession { + masks: EffectiveEventMasks, + state: Arc>, + queue: Arc, + control: Arc, + event_worker: Option>, + sink_worker: Option>, + stopped: bool, +} + +impl MacosInputSession { + /// Start the requested event taps and block until their run loop is ready. + pub fn start( + config: MacosInputConfig, + sink: impl FnMut(MacosInputBatch<'_>) + Send + 'static, + ) -> MacosInputResult { + if !config.keyboard && !config.pointer { + return Err(MacosInputError::NothingToCapture); + } + if config.keyboard && !input_monitoring_granted() { + return Err(MacosInputError::PermissionDenied); + } + + let masks = event_masks(config.keyboard, config.pointer); + let desktop = current_virtual_desktop()?; + let queue = Arc::new(EventQueue::new(DEFAULT_QUEUE_CAPACITY)); + let state = Arc::new(Mutex::new(MacosWorkerState::Running)); + let control = Arc::new(RunLoopControl::new()); + + let sink_worker = thread::Builder::new() + .name("hypercolor-macos-input-fold".to_owned()) + .spawn({ + let queue = Arc::clone(&queue); + let state = Arc::clone(&state); + let control = Arc::clone(&control); + let config = config.clone(); + move || drain_batches(config, desktop, sink, &queue, &state, &control) + }) + .map_err(|error| MacosInputError::WorkerSpawn(error.to_string()))?; + + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let event_worker = match thread::Builder::new() + .name("hypercolor-macos-event-tap".to_owned()) + .spawn({ + let queue = Arc::clone(&queue); + let state = Arc::clone(&state); + let control = Arc::clone(&control); + move || run_event_taps(masks, &queue, &state, &control, &ready_tx) + }) { + Ok(worker) => worker, + Err(error) => { + queue.close(); + let _ = sink_worker.join(); + return Err(MacosInputError::WorkerSpawn(error.to_string())); + } + }; + + match ready_rx.recv_timeout(READY_TIMEOUT) { + Ok(Ok(())) => Ok(Self { + masks, + state, + queue, + control, + event_worker: Some(event_worker), + sink_worker: Some(sink_worker), + stopped: false, + }), + Ok(Err(error)) => { + control.request_stop(); + let _ = event_worker.join(); + queue.close(); + let _ = sink_worker.join(); + Err(error) + } + Err(_) => { + control.request_stop(); + let _ = event_worker.join(); + queue.close(); + let _ = sink_worker.join(); + Err(MacosInputError::WorkerReadyTimeout) + } + } + } + + #[must_use] + pub const fn effective_masks(&self) -> EffectiveEventMasks { + self.masks + } + + #[must_use] + pub fn worker_state(&self) -> MacosWorkerState { + self.state + .lock() + .map(|state| state.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + #[must_use] + pub fn diagnostics(&self) -> MacosInputDiagnostics { + self.queue.diagnostics().snapshot() + } + + /// Stop the run loop, tear down both taps, join their worker, then flush + /// the ordered source-stop barrier through the sink. + pub fn stop(&mut self) { + if self.stopped { + return; + } + self.stopped = true; + self.control.request_stop(); + if let Some(worker) = self.event_worker.take() { + let _ = worker.join(); + } + self.queue.request_gap(MacosInputGapReason::SourceStopped); + self.queue.close(); + if let Some(worker) = self.sink_worker.take() { + let _ = worker.join(); + } + } +} + +impl Drop for MacosInputSession { + fn drop(&mut self) { + self.stop(); + } +} + +#[must_use] +pub fn input_monitoring_granted() -> bool { + CGPreflightListenEventAccess() +} + +/// Ask macOS to grant Input Monitoring to the current signed process. +#[must_use] +pub fn request_input_monitoring() -> bool { + CGRequestListenEventAccess() +} + +/// Snapshot the union of active display bounds. +pub fn current_virtual_desktop() -> MacosInputResult { + query_virtual_desktop(1) +} + +fn query_virtual_desktop(generation: u64) -> MacosInputResult { + let mut count = 0; + // SAFETY: the first call writes only the display count because both the + // capacity and display pointer are zero. + let error = unsafe { CGGetActiveDisplayList(0, std::ptr::null_mut(), &raw mut count) }; + if error != CGError::Success { + return Err(MacosInputError::DisplayTopology(error.0)); + } + if count == 0 { + return Err(MacosInputError::NoActiveDisplays); + } + + let mut displays = vec![0; usize::try_from(count).unwrap_or(usize::MAX)]; + let mut written = count; + // SAFETY: `displays` has capacity for `count` identifiers and `written` + // points to initialized writable storage. + let error = unsafe { CGGetActiveDisplayList(count, displays.as_mut_ptr(), &raw mut written) }; + if error != CGError::Success { + return Err(MacosInputError::DisplayTopology(error.0)); + } + displays.truncate(usize::try_from(written).unwrap_or(displays.len())); + let mut bounds = displays.into_iter().map(|display| CGDisplayBounds(display)); + let first = bounds.next().ok_or(MacosInputError::NoActiveDisplays)?; + let mut min_x = first.origin.x; + let mut min_y = first.origin.y; + let mut max_x = first.origin.x + first.size.width; + let mut max_y = first.origin.y + first.size.height; + for rect in bounds { + min_x = min_x.min(rect.origin.x); + min_y = min_y.min(rect.origin.y); + max_x = max_x.max(rect.origin.x + rect.size.width); + max_y = max_y.max(rect.origin.y + rect.size.height); + } + MacosVirtualDesktop::new(min_x, min_y, max_x - min_x, max_y - min_y, generation) +} + +fn run_event_taps( + masks: EffectiveEventMasks, + queue: &Arc, + state: &Arc>, + control: &Arc, + ready: &mpsc::SyncSender>, +) { + let Some(run_loop) = CFRunLoop::current() else { + let _ = ready.send(Err(MacosInputError::WorkerSpawn( + "Core Foundation returned no current run loop".to_owned(), + ))); + return; + }; + control.install(&run_loop); + + let mut taps = Vec::with_capacity(2); + let result = (|| { + if masks.keyboard != 0 { + taps.push(create_tap( + TapKind::Keyboard, + masks.keyboard, + &run_loop, + queue, + )?); + } + if masks.pointer != 0 { + taps.push(create_tap( + TapKind::Pointer, + masks.pointer, + &run_loop, + queue, + )?); + } + Ok(()) + })(); + + if let Err(error) = result { + for tap in &taps { + tap.teardown(&run_loop); + } + control.clear(); + let _ = ready.send(Err(error)); + return; + } + if ready.send(Ok(())).is_err() { + control.request_stop(); + } + if !control.stopping.load(Ordering::Acquire) { + CFRunLoop::run(); + } + for tap in &taps { + tap.teardown(&run_loop); + } + control.clear(); + + if !control.stopping.load(Ordering::Acquire) { + set_worker_state( + state, + MacosWorkerState::Failed("event-tap run loop exited unexpectedly".to_owned()), + ); + queue.request_gap(MacosInputGapReason::WorkerExited); + queue.close(); + } +} + +fn create_tap( + kind: TapKind, + mask: u64, + run_loop: &CFRunLoop, + queue: &Arc, +) -> MacosInputResult { + let mut context = Box::new(TapContext { + queue: Arc::clone(queue), + tap: AtomicPtr::new(std::ptr::null_mut()), + last_disable_ms: AtomicU64::new(0), + }); + // SAFETY: `context` remains at a stable Box address until its tap is + // removed and invalidated. The callback returns the borrowed event and + // never retains the proxy or event pointers. + let tap = unsafe { + CGEvent::tap_create( + CGEventTapLocation::SessionEventTap, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + mask, + Some(event_tap_callback), + std::ptr::from_mut(context.as_mut()).cast::(), + ) + } + .ok_or(MacosInputError::TapCreation(kind.label()))?; + context.tap.store( + std::ptr::from_ref::(&tap).cast_mut(), + Ordering::Release, + ); + let source = CFMachPort::new_run_loop_source(None, Some(&tap), 0) + .ok_or(MacosInputError::RunLoopSource(kind.label()))?; + // SAFETY: Core Foundation exports this process-lifetime static mode. + let mode = unsafe { kCFRunLoopCommonModes }; + run_loop.add_source(Some(&source), mode); + CGEvent::tap_enable(&tap, true); + Ok(TapBundle { + source, + tap, + context, + }) +} + +unsafe extern "C-unwind" fn event_tap_callback( + _proxy: objc2_core_graphics::CGEventTapProxy, + event_type: CGEventType, + event: NonNull, + user_info: *mut c_void, +) -> *mut CGEvent { + // SAFETY: Core Graphics supplies both pointers for the lifetime of this + // callback. `create_tap` keeps the boxed context alive through teardown. + let context = unsafe { &*(user_info.cast::()) }; + // SAFETY: Core Graphics guarantees this non-null event for the callback. + let event_ref = unsafe { event.as_ref() }; + + if event_type == CGEventType::TapDisabledByTimeout { + handle_tap_disable(context, MacosInputGapReason::TapDisabledTimeout); + } else if event_type == CGEventType::TapDisabledByUserInput { + handle_tap_disable(context, MacosInputGapReason::TapDisabledUserInput); + } else if let Some(decoded) = decode_native_event(event_type, event_ref, context) { + context.queue.enqueue(decoded); + } + event.as_ptr() +} + +fn handle_tap_disable(context: &TapContext, reason: MacosInputGapReason) { + static DISABLE_CLOCK: std::sync::OnceLock = std::sync::OnceLock::new(); + let elapsed_ms = DISABLE_CLOCK + .get_or_init(Instant::now) + .elapsed() + .as_millis() + .min(u128::from(u64::MAX)) as u64 + + 1; + let previous = context.last_disable_ms.swap(elapsed_ms, Ordering::AcqRel); + let health_window_ms = u64::try_from(TAP_DISABLE_HEALTH_WINDOW.as_millis()).unwrap_or(u64::MAX); + let repeated = previous != 0 && elapsed_ms.saturating_sub(previous) < health_window_ms; + context.queue.diagnostics().record_tap_disable(repeated); + context.queue.enqueue(MacosInputEvent::StateGap { reason }); + if repeated { + return; + } + let tap = context.tap.load(Ordering::Acquire); + if tap.is_null() { + return; + } + // SAFETY: the callback runs on the owning run-loop thread while the tap is + // retained. Teardown clears this pointer only after removing the source. + CGEvent::tap_enable(unsafe { &*tap }, true); +} + +fn decode_native_event( + event_type: CGEventType, + event: &CGEvent, + context: &TapContext, +) -> Option { + if event_type == CGEventType::KeyDown || event_type == CGEventType::KeyUp { + return Some(MacosInputEvent::Key { + virtual_keycode: u16::try_from(CGEvent::integer_value_field( + Some(event), + CGEventField::KeyboardEventKeycode, + )) + .ok()?, + pressed: event_type == CGEventType::KeyDown, + autorepeat: CGEvent::integer_value_field( + Some(event), + CGEventField::KeyboardEventAutorepeat, + ) != 0, + }); + } + if event_type == CGEventType::FlagsChanged { + return Some(MacosInputEvent::ModifierFlags { + virtual_keycode: u16::try_from(CGEvent::integer_value_field( + Some(event), + CGEventField::KeyboardEventKeycode, + )) + .ok()?, + flags: MacosModifierFlags::from_bits(CGEvent::flags(Some(event)).bits()), + }); + } + if event_type == SYSTEM_DEFINED_EVENT { + let Some(native) = NSEvent::eventWithCGEvent(event) else { + context + .queue + .diagnostics() + .record_unsupported_system_event(); + return None; + }; + let data1 = i64::try_from(native.data1()).ok()?; + if let Some(media) = decode_media_key(native.subtype().0, data1) { + return Some(MacosInputEvent::MediaKey { + nx_key_type: media.nx_key_type, + pressed: media.pressed, + repeat: media.repeat, + }); + } + context + .queue + .diagnostics() + .record_unsupported_system_event(); + return None; + } + if let Some((button, pressed)) = decode_button_event( + event_type.0, + u16::try_from(CGEvent::integer_value_field( + Some(event), + CGEventField::MouseEventButtonNumber, + )) + .ok()?, + ) { + return Some(MacosInputEvent::Button { button, pressed }); + } + if matches!( + event_type, + CGEventType::MouseMoved + | CGEventType::LeftMouseDragged + | CGEventType::RightMouseDragged + | CGEventType::OtherMouseDragged + ) { + let location = CGEvent::location(Some(event)); + return Some(MacosInputEvent::Motion { + x: location.x, + y: location.y, + delta_x: CGEvent::integer_value_field(Some(event), CGEventField::MouseEventDeltaX) + as f64, + delta_y: CGEvent::integer_value_field(Some(event), CGEventField::MouseEventDeltaY) + as f64, + }); + } + if event_type == CGEventType::ScrollWheel { + let point_y = CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventPointDeltaAxis1, + ); + let point_x = CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventPointDeltaAxis2, + ); + context + .queue + .diagnostics() + .record_point_delta(point_x, point_y); + let phase = decode_phase( + CGEvent::integer_value_field(Some(event), CGEventField::ScrollWheelEventScrollPhase), + context, + decode_scroll_phase, + ); + let momentum_phase = decode_phase( + CGEvent::integer_value_field(Some(event), CGEventField::ScrollWheelEventMomentumPhase), + context, + decode_momentum_phase, + ); + let unit = if CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventIsContinuous, + ) != 0 + { + MacosScrollUnit::Pixels + } else { + MacosScrollUnit::Notches + }; + return Some(MacosInputEvent::Wheel { + fixed_delta_x: CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventFixedPtDeltaAxis2, + ), + fixed_delta_y: CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventFixedPtDeltaAxis1, + ), + unit, + phase, + momentum_phase, + }); + } + None +} + +fn decode_phase( + raw: i64, + context: &TapContext, + decode: impl FnOnce(i64) -> Option, +) -> MacosScrollPhase { + decode(raw).unwrap_or_else(|| { + context.queue.diagnostics().record_invalid_scroll_phase(); + MacosScrollPhase::None + }) +} + +fn drain_batches( + config: MacosInputConfig, + mut desktop: MacosVirtualDesktop, + mut sink: impl FnMut(MacosInputBatch<'_>), + queue: &EventQueue, + state: &Mutex, + control: &RunLoopControl, +) { + let mut events = Vec::with_capacity(DEFAULT_QUEUE_CAPACITY + 2); + let mut next_topology_check = Instant::now() + TOPOLOGY_INTERVAL; + loop { + queue.wait(HEALTH_INTERVAL); + let now = Instant::now(); + if queue.diagnostics().take_repeated_tap_disable() { + set_worker_state( + state, + MacosWorkerState::Degraded("event tap disabled repeatedly".to_owned()), + ); + } + if config.keyboard && !input_monitoring_granted() { + set_worker_state(state, MacosWorkerState::PermissionRevoked); + queue.request_gap(MacosInputGapReason::PermissionRevoked); + control.request_stop(); + queue.close(); + } + if config.pointer && now >= next_topology_check { + match query_virtual_desktop(desktop.topology_generation) { + Ok(current) if desktop_geometry_changed(desktop, current) => { + desktop = MacosVirtualDesktop { + topology_generation: desktop.topology_generation.saturating_add(1), + ..current + }; + } + Ok(_) => {} + Err(error) => set_worker_state( + state, + MacosWorkerState::Degraded(format!("display topology refresh failed: {error}")), + ), + } + next_topology_check = now + TOPOLOGY_INTERVAL; + } + + events.clear(); + let at_ms = (config.clock)(); + queue.drain_into(&mut events); + if !events.is_empty() { + sink(MacosInputBatch { + epoch: config.epoch, + at_ms, + events: &events, + virtual_desktop: desktop, + }); + } + if queue.is_closed() && queue.is_empty() { + break; + } + } +} + +fn desktop_geometry_changed(left: MacosVirtualDesktop, right: MacosVirtualDesktop) -> bool { + left.origin_x != right.origin_x + || left.origin_y != right.origin_y + || left.width != right.width + || left.height != right.height +} + +fn set_worker_state(state: &Mutex, value: MacosWorkerState) { + *state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = value; +} diff --git a/crates/hypercolor-macos-input/src/queue.rs b/crates/hypercolor-macos-input/src/queue.rs new file mode 100644 index 000000000..989f6b70c --- /dev/null +++ b/crates/hypercolor-macos-input/src/queue.rs @@ -0,0 +1,223 @@ +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::{Mutex, mpsc}; +use std::time::Duration; + +use crossbeam_queue::ArrayQueue; + +use crate::{MacosInputDiagnostics, MacosInputEvent, MacosInputGapReason}; + +pub(crate) const DEFAULT_QUEUE_CAPACITY: usize = 2048; + +#[derive(Default)] +pub(crate) struct Diagnostics { + dropped_events: AtomicU64, + tap_disable_count: AtomicU64, + unsupported_system_events: AtomicU64, + invalid_scroll_phases: AtomicU64, + last_point_delta_x: AtomicI64, + last_point_delta_y: AtomicI64, + repeated_tap_disable: AtomicBool, +} + +impl Diagnostics { + pub(crate) fn snapshot(&self) -> MacosInputDiagnostics { + MacosInputDiagnostics { + dropped_events: self.dropped_events.load(Ordering::Relaxed), + tap_disable_count: self.tap_disable_count.load(Ordering::Relaxed), + unsupported_system_events: self.unsupported_system_events.load(Ordering::Relaxed), + invalid_scroll_phases: self.invalid_scroll_phases.load(Ordering::Relaxed), + last_point_delta_x: self.last_point_delta_x.load(Ordering::Relaxed), + last_point_delta_y: self.last_point_delta_y.load(Ordering::Relaxed), + } + } + + pub(crate) fn record_drop(&self) { + self.dropped_events.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_tap_disable(&self, repeated: bool) { + self.tap_disable_count.fetch_add(1, Ordering::Relaxed); + if repeated { + self.repeated_tap_disable.store(true, Ordering::Release); + } + } + + pub(crate) fn record_unsupported_system_event(&self) { + self.unsupported_system_events + .fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_invalid_scroll_phase(&self) { + self.invalid_scroll_phases.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_point_delta(&self, x: i64, y: i64) { + self.last_point_delta_x.store(x, Ordering::Relaxed); + self.last_point_delta_y.store(y, Ordering::Relaxed); + } + + pub(crate) fn take_repeated_tap_disable(&self) -> bool { + self.repeated_tap_disable.swap(false, Ordering::AcqRel) + } +} + +pub(crate) struct EventQueue { + events: ArrayQueue, + overflowed: AtomicBool, + closed: AtomicBool, + wake_tx: mpsc::SyncSender<()>, + wake_rx: Mutex>, + terminal_gaps: Mutex>, + diagnostics: Diagnostics, +} + +impl EventQueue { + pub(crate) fn new(capacity: usize) -> Self { + let (wake_tx, wake_rx) = mpsc::sync_channel(1); + Self { + events: ArrayQueue::new(capacity), + overflowed: AtomicBool::new(false), + closed: AtomicBool::new(false), + wake_tx, + wake_rx: Mutex::new(wake_rx), + terminal_gaps: Mutex::new(VecDeque::new()), + diagnostics: Diagnostics::default(), + } + } + + pub(crate) fn enqueue(&self, event: MacosInputEvent) { + if self.overflowed.load(Ordering::Acquire) { + self.diagnostics.record_drop(); + return; + } + if self.events.push(event).is_err() { + self.diagnostics.record_drop(); + self.overflowed.store(true, Ordering::Release); + } + self.notify(); + } + + pub(crate) fn request_gap(&self, reason: MacosInputGapReason) { + self.terminal_gaps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push_back(reason); + self.notify(); + } + + pub(crate) fn close(&self) { + self.closed.store(true, Ordering::Release); + self.notify(); + } + + pub(crate) fn is_closed(&self) -> bool { + self.closed.load(Ordering::Acquire) + } + + pub(crate) fn wait(&self, timeout: Duration) { + if self.is_closed() { + return; + } + let _ = self + .wake_rx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .recv_timeout(timeout); + } + + pub(crate) fn drain_into(&self, output: &mut Vec) { + while let Some(event) = self.events.pop() { + output.push(event); + } + if self.overflowed.swap(false, Ordering::AcqRel) { + output.push(MacosInputEvent::StateGap { + reason: MacosInputGapReason::QueueOverflow, + }); + } + output.extend( + self.terminal_gaps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .map(|reason| MacosInputEvent::StateGap { reason }), + ); + } + + pub(crate) fn is_empty(&self) -> bool { + self.events.is_empty() + && !self.overflowed.load(Ordering::Acquire) + && self + .terminal_gaps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + } + + pub(crate) fn diagnostics(&self) -> &Diagnostics { + &self.diagnostics + } + + fn notify(&self) { + let _ = self.wake_tx.try_send(()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MacosPointerButton; + + fn button(pressed: bool) -> MacosInputEvent { + MacosInputEvent::Button { + button: MacosPointerButton::Left, + pressed, + } + } + + #[test] + fn overflow_ends_with_one_ordered_state_gap() { + let queue = EventQueue::new(2); + queue.enqueue(button(true)); + queue.enqueue(button(false)); + queue.enqueue(button(true)); + queue.enqueue(button(false)); + let mut drained = Vec::new(); + + queue.drain_into(&mut drained); + + assert_eq!(drained.len(), 3); + assert_eq!(drained[0], button(true)); + assert_eq!(drained[1], button(false)); + assert_eq!( + drained[2], + MacosInputEvent::StateGap { + reason: MacosInputGapReason::QueueOverflow + } + ); + assert_eq!(queue.diagnostics().snapshot().dropped_events, 2); + } + + #[test] + fn terminal_gaps_follow_preceding_edges() { + let queue = EventQueue::new(2); + queue.enqueue(button(true)); + queue.request_gap(MacosInputGapReason::SourceStopped); + queue.close(); + let mut drained = Vec::new(); + + queue.drain_into(&mut drained); + + assert_eq!(drained[0], button(true)); + assert_eq!( + drained[1], + MacosInputEvent::StateGap { + reason: MacosInputGapReason::SourceStopped + } + ); + assert!(queue.is_closed()); + assert!(queue.is_empty()); + } +} diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs index fa1e4f205..82e162fa4 100644 --- a/crates/hypercolor-macos-input/src/shared.rs +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -201,6 +201,17 @@ pub struct MacosInputBatch<'a> { pub virtual_desktop: MacosVirtualDesktop, } +/// Monotonic native diagnostics for one session. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MacosInputDiagnostics { + pub dropped_events: u64, + pub tap_disable_count: u64, + pub unsupported_system_events: u64, + pub invalid_scroll_phases: u64, + pub last_point_delta_x: i64, + pub last_point_delta_y: i64, +} + /// Event masks actually requested for one session. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub struct EffectiveEventMasks { @@ -213,6 +224,7 @@ pub struct EffectiveEventMasks { pub enum MacosWorkerState { Running, Degraded(String), + PermissionRevoked, Failed(String), } @@ -227,6 +239,10 @@ pub enum MacosInputError { PermissionDenied, #[error("invalid virtual desktop bounds")] InvalidVirtualDesktop, + #[error("Core Graphics display enumeration failed with error {0}")] + DisplayTopology(i32), + #[error("Core Graphics reported no active displays")] + NoActiveDisplays, #[error("failed to spawn the macOS input worker: {0}")] WorkerSpawn(String), #[error("timed out waiting for the macOS input worker to become ready")] diff --git a/crates/hypercolor-macos-input/src/stubs.rs b/crates/hypercolor-macos-input/src/stubs.rs new file mode 100644 index 000000000..e01daf6bc --- /dev/null +++ b/crates/hypercolor-macos-input/src/stubs.rs @@ -0,0 +1,60 @@ +use crate::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputResult, MacosVirtualDesktop, MacosWorkerState, +}; + +/// Native event-tap session placeholder outside macOS. +pub struct MacosInputSession { + _private: (), +} + +impl MacosInputSession { + /// Always fails because Core Graphics event taps are macOS-only. + pub fn start( + _config: MacosInputConfig, + _sink: impl FnMut(MacosInputBatch<'_>) + Send + 'static, + ) -> MacosInputResult { + Err(MacosInputError::UnsupportedPlatform) + } + + #[must_use] + pub const fn effective_masks(&self) -> EffectiveEventMasks { + EffectiveEventMasks { + keyboard: 0, + pointer: 0, + } + } + + #[must_use] + pub fn worker_state(&self) -> MacosWorkerState { + MacosWorkerState::Failed("macOS host input is unavailable".to_owned()) + } + + #[must_use] + pub const fn diagnostics(&self) -> MacosInputDiagnostics { + MacosInputDiagnostics { + dropped_events: 0, + tap_disable_count: 0, + unsupported_system_events: 0, + invalid_scroll_phases: 0, + last_point_delta_x: 0, + last_point_delta_y: 0, + } + } + + pub const fn stop(&mut self) {} +} + +#[must_use] +pub const fn input_monitoring_granted() -> bool { + false +} + +#[must_use] +pub const fn request_input_monitoring() -> bool { + false +} + +pub fn current_virtual_desktop() -> MacosInputResult { + Err(MacosInputError::UnsupportedPlatform) +} diff --git a/crates/hypercolor-macos-input/tests/input_contract_tests.rs b/crates/hypercolor-macos-input/tests/input_contract_tests.rs index 0c8301d67..f616601b4 100644 --- a/crates/hypercolor-macos-input/tests/input_contract_tests.rs +++ b/crates/hypercolor-macos-input/tests/input_contract_tests.rs @@ -4,7 +4,7 @@ use hypercolor_macos_input::{ MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, MacosInputGapReason, MacosModifierFlags, MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, NX_SUBTYPE_AUX_CONTROL_BUTTONS, decode_button_event, decode_media_key, decode_momentum_phase, - decode_scroll_phase, event_masks, + decode_scroll_phase, event_masks, input_monitoring_granted, request_input_monitoring, }; #[test] @@ -189,3 +189,46 @@ fn batch_carries_the_complete_plain_rust_vocabulary() { assert_eq!(batch.events, events); assert_eq!(batch.virtual_desktop, desktop); } + +#[test] +fn permission_preflight_is_a_read_only_boolean_probe() { + let granted = input_monitoring_granted(); + assert!(matches!(granted, true | false)); + + #[cfg(target_os = "macos")] + let _request: fn() -> bool = request_input_monitoring; + #[cfg(not(target_os = "macos"))] + assert!(!request_input_monitoring()); +} + +#[test] +fn empty_session_is_rejected_before_platform_access() { + let error = hypercolor_macos_input::MacosInputSession::start( + MacosInputConfig { + keyboard: false, + pointer: false, + epoch: 1, + clock: Arc::new(|| 0), + }, + |_| {}, + ) + .err() + .expect("empty capture must fail"); + + #[cfg(target_os = "macos")] + assert_eq!(error, MacosInputError::NothingToCapture); + #[cfg(not(target_os = "macos"))] + assert_eq!(error, MacosInputError::UnsupportedPlatform); +} + +#[cfg(target_os = "macos")] +#[test] +fn current_virtual_desktop_reports_positive_finite_geometry() { + let desktop = hypercolor_macos_input::current_virtual_desktop() + .expect("the test host has an active display"); + + assert!(desktop.origin_x.is_finite()); + assert!(desktop.origin_y.is_finite()); + assert!(desktop.width.is_finite() && desktop.width > 0.0); + assert!(desktop.height.is_finite() && desktop.height > 0.0); +} From a35e72502db7a1c5f3fdef6d2c03d2071da03f52 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 16:54:25 -0700 Subject: [PATCH 016/144] feat(macos): fold native host input in core 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) --- crates/hypercolor-core/Cargo.toml | 5 +- crates/hypercolor-core/src/input/macos.rs | 1257 +++++++++++++++++ crates/hypercolor-core/src/input/mod.rs | 4 + crates/hypercolor-core/src/input/traits.rs | 8 +- .../tests/macos_host_input_tests.rs | 468 ++++++ crates/hypercolor-macos-input/src/lib.rs | 3 +- crates/hypercolor-macos-input/src/macos.rs | 17 +- crates/hypercolor-macos-input/src/queue.rs | 33 +- crates/hypercolor-macos-input/src/shared.rs | 26 +- 9 files changed, 1803 insertions(+), 18 deletions(-) create mode 100644 crates/hypercolor-core/src/input/macos.rs create mode 100644 crates/hypercolor-core/tests/macos_host_input_tests.rs diff --git a/crates/hypercolor-core/Cargo.toml b/crates/hypercolor-core/Cargo.toml index 25a642d2d..ac1a3436e 100644 --- a/crates/hypercolor-core/Cargo.toml +++ b/crates/hypercolor-core/Cargo.toml @@ -16,6 +16,7 @@ default = [] allocation-contract-tests = [] spatial-workspace-test-hooks = [] windows-capture-fixtures = [] +macos-native-fixtures = [] media-lottie = ["dep:rlottie"] media-video = ["dep:gstreamer", "dep:gstreamer-app", "dep:gstreamer-video"] servo = [ @@ -45,6 +46,7 @@ hypercolor-windows-input = { path = "../hypercolor-windows-input" } # Unconditional: monitor selector parsing and persistence are platform-neutral; # the capture crate supplies stubs when DXGI is unavailable. hypercolor-windows-capture = { path = "../hypercolor-windows-capture" } +hypercolor-macos-input = { workspace = true } hypercolor-driver-api = { workspace = true } hypercolor-hal = { workspace = true } hypercolor-platform-fs = { workspace = true } @@ -150,7 +152,4 @@ name = "core_pipeline" harness = false [target.'cfg(target_os = "macos")'.dependencies] -# The last consumer of the device_query polling bridge. Linux has evdev and -# Windows has Raw Input, so neither should compile or ship a keylogging-capable -# crate it no longer uses; the macOS backend spec deletes the rest. device_query = { workspace = true } diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs new file mode 100644 index 000000000..1c64ae9f8 --- /dev/null +++ b/crates/hypercolor-core/src/input/macos.rs @@ -0,0 +1,1257 @@ +//! macOS host input folded from Core Graphics event-tap batches. + +use std::collections::{BTreeSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use hypercolor_macos_input::{ + MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, MacosInputGapReason, + MacosInputSession, MacosModifierFlags, MacosPointerButton, MacosScrollPhase, MacosScrollUnit, + MacosVirtualDesktop, MacosWorkerDegradation, MacosWorkerState, input_monitoring_granted, +}; +use tracing::{info, warn}; + +use crate::input::keymap::{macos_key_name, macos_media_key_name}; +use crate::input::traits::{ + InputData, InputSource, InteractionData, InteractionDegradation, MotionAggregate, PointerMode, +}; +use crate::input::{ + LegacyWheelProjector, SourceIssue, SourceKind, SourceSessionSlot, SourceStatusHandle, + SourceStatusReporter, +}; +use crate::types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, +}; + +const SOURCE_ID: &str = "host:macos"; +const DEFAULT_EVENT_LIMIT: usize = crate::input::InteractionBatch::MAX_EVENTS; + +type HeldStateKey = (Vec, Vec, i32, i32, i32, i32, bool); + +#[derive(Debug, Clone, Copy, PartialEq)] +struct PointerSnapshot { + x: i32, + y: i32, + norm_x: f32, + norm_y: f32, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MacosInputFoldDiagnostics { + pub impossible_key_edges: u64, + pub impossible_button_edges: u64, + pub unsupported_keys: u64, + pub unsupported_media_keys: u64, + pub scroll_overflows: u64, + pub state_gaps: u64, + pub topology_resets: u64, +} + +#[derive(Default)] +struct SharedState { + events: VecDeque, + dropped: u32, + pressed_keys: BTreeSet, + recent_keys: VecDeque, + held_buttons: BTreeSet, + motion: MotionAggregate, + pointer: Option, + pointer_present: bool, + topology_generation: Option, + legacy_wheel_projector: LegacyWheelProjector, + diagnostics: MacosInputFoldDiagnostics, + epoch: u64, +} + +impl SharedState { + fn clear_live_state(&mut self) { + self.events.clear(); + self.dropped = 0; + self.pressed_keys.clear(); + self.recent_keys.clear(); + self.held_buttons.clear(); + self.motion = MotionAggregate::default(); + self.pointer = None; + self.pointer_present = false; + self.topology_generation = None; + self.legacy_wheel_projector.reset(); + } +} + +static NEXT_EPOCH: AtomicU64 = AtomicU64::new(1); + +pub struct MacosHostInput { + name: String, + running: bool, + capture_active: bool, + capture_keyboard: bool, + capture_pointer: bool, + event_limit: usize, + generation: u64, + last_state_key: Option, + shared: Arc>, + session: Option, + degraded: Option, + status: SourceStatusReporter, + status_session: SourceSessionSlot, + #[cfg(feature = "macos-native-fixtures")] + fixture: Option>, +} + +#[cfg(feature = "macos-native-fixtures")] +#[derive(Debug, Clone, PartialEq)] +pub struct MacosInputFixtureBackend { + pub preflight_granted: bool, + pub request_granted: bool, + pub effective_masks: hypercolor_macos_input::EffectiveEventMasks, + pub owner_restart_succeeds: bool, + pub virtual_desktop: MacosVirtualDesktop, +} + +#[cfg(feature = "macos-native-fixtures")] +impl MacosInputFixtureBackend { + #[must_use] + pub fn new( + preflight_granted: bool, + request_granted: bool, + effective_masks: hypercolor_macos_input::EffectiveEventMasks, + owner_restart_succeeds: bool, + virtual_desktop: MacosVirtualDesktop, + ) -> Self { + Self { + preflight_granted, + request_granted, + effective_masks, + owner_restart_succeeds, + virtual_desktop, + } + } +} + +#[cfg(feature = "macos-native-fixtures")] +struct FixtureState { + backend: Mutex, + active_epoch: Mutex>, +} + +#[cfg(feature = "macos-native-fixtures")] +pub struct MacosHostInputFixture { + state: Arc, + shared: Arc>, + event_limit: usize, +} + +#[cfg(feature = "macos-native-fixtures")] +impl MacosHostInputFixture { + #[must_use] + pub fn is_active(&self) -> bool { + self.state + .active_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + } + + #[must_use] + pub fn effective_masks(&self) -> hypercolor_macos_input::EffectiveEventMasks { + self.state + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .effective_masks + } + + #[must_use] + pub fn active_epoch(&self) -> Option { + *self + .state + .active_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn publish(&self, events: &[MacosInputEvent], at_ms: u64) -> anyhow::Result { + let epoch = self + .active_epoch() + .ok_or_else(|| anyhow::anyhow!("deterministic macOS input source is inactive"))?; + self.publish_with_epoch(epoch, events, at_ms) + } + + pub fn publish_with_epoch( + &self, + epoch: u64, + events: &[MacosInputEvent], + at_ms: u64, + ) -> anyhow::Result { + let desktop = self + .state + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .virtual_desktop; + Ok(publish_macos_batch( + &self.shared, + MacosInputBatch { + epoch, + at_ms, + events, + virtual_desktop: desktop, + }, + self.event_limit, + )) + } + + pub fn request_input_monitoring_and_restart_owner(&self) -> anyhow::Result { + let mut backend = self + .state + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !backend.request_granted { + return Ok(false); + } + if !backend.owner_restart_succeeds { + anyhow::bail!("deterministic macOS input owner restart failed"); + } + backend.preflight_granted = true; + Ok(true) + } +} + +impl MacosHostInput { + #[must_use] + pub fn new(capture_keyboard: bool, capture_pointer: bool) -> Self { + Self { + name: "MacosHostInput".to_owned(), + running: false, + capture_active: false, + capture_keyboard, + capture_pointer, + event_limit: DEFAULT_EVENT_LIMIT, + generation: 0, + last_state_key: None, + shared: Arc::new(Mutex::new(SharedState::default())), + session: None, + degraded: None, + status: SourceStatusReporter::new( + "macos_host_input", + SourceKind::Interaction, + "cg_event_tap", + true, + true, + false, + ), + status_session: SourceSessionSlot::new(), + #[cfg(feature = "macos-native-fixtures")] + fixture: None, + } + } + + #[cfg(feature = "macos-native-fixtures")] + #[must_use] + pub fn new_deterministic_fixture( + capture_keyboard: bool, + capture_pointer: bool, + backend: MacosInputFixtureBackend, + ) -> (Self, MacosHostInputFixture) { + let mut source = Self::new(capture_keyboard, capture_pointer); + let state = Arc::new(FixtureState { + backend: Mutex::new(backend), + active_epoch: Mutex::new(None), + }); + source.fixture = Some(Arc::clone(&state)); + let fixture = MacosHostInputFixture { + state, + shared: Arc::clone(&source.shared), + event_limit: source.event_limit, + }; + (source, fixture) + } + + #[must_use] + pub fn epoch(&self) -> u64 { + self.shared.lock().map_or(0, |state| state.epoch) + } + + #[must_use] + pub fn degradation(&self) -> Option { + self.degraded.clone() + } + + #[must_use] + pub fn fold_diagnostics(&self) -> MacosInputFoldDiagnostics { + self.shared + .lock() + .map(|state| state.diagnostics) + .unwrap_or_default() + } + + pub fn fold_and_snapshot( + &mut self, + batch: MacosInputBatch<'_>, + ) -> (InteractionData, Vec) { + publish_macos_batch(&self.shared, batch, self.event_limit); + let shared = Arc::clone(&self.shared); + let Ok(mut state) = shared.lock() else { + return (InteractionData::default(), Vec::new()); + }; + let events = drain_events(&mut state.events); + let snapshot = self.build_snapshot(&mut state); + (snapshot, events) + } + + fn build_snapshot(&mut self, state: &mut SharedState) -> InteractionData { + let mut data = InteractionData::default(); + data.keyboard.pressed_keys = state.pressed_keys.iter().cloned().collect(); + data.keyboard.recent_keys = state.recent_keys.drain(..).collect(); + data.mouse.buttons = state.held_buttons.iter().cloned().collect(); + data.mouse.down = !data.mouse.buttons.is_empty(); + if state.pointer_present + && let Some(pointer) = state.pointer + { + data.mouse.mode = PointerMode::Absolute; + data.mouse.x = pointer.x; + data.mouse.y = pointer.y; + data.mouse.norm_x = pointer.norm_x; + data.mouse.norm_y = pointer.norm_y; + } + data.batch.motion = std::mem::take(&mut state.motion); + data.batch.dropped_events = std::mem::take(&mut state.dropped); + + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "normalized coordinates are clamped before scaling" + )] + let cursor_key = ( + (data.mouse.norm_x * 10_000.0) as i32, + (data.mouse.norm_y * 10_000.0) as i32, + ); + let state_key = ( + data.keyboard.pressed_keys.clone(), + data.mouse.buttons.clone(), + cursor_key.0, + cursor_key.1, + data.mouse.x, + data.mouse.y, + state.pointer_present, + ); + if self.last_state_key.as_ref() != Some(&state_key) || !data.keyboard.recent_keys.is_empty() + { + self.generation = self.generation.wrapping_add(1); + self.last_state_key = Some(state_key); + } + data.generation = self.generation; + data + } + + fn rotate_epoch_and_clear(&mut self) -> u64 { + let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed); + let mut state = self + .shared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.clear_live_state(); + state.epoch = epoch; + drop(state); + self.last_state_key = None; + epoch + } + + fn permission_granted(&self) -> bool { + #[cfg(feature = "macos-native-fixtures")] + if let Some(fixture) = &self.fixture { + return fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .preflight_granted; + } + input_monitoring_granted() + } + + fn active_kind_count(&self) -> usize { + if let Some(session) = &self.session { + let masks = session.effective_masks(); + return usize::from(masks.keyboard != 0) + usize::from(masks.pointer != 0); + } + #[cfg(feature = "macos-native-fixtures")] + if let Some(fixture) = &self.fixture + && self.fixture_session_active() + { + let masks = fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .effective_masks; + return usize::from(self.capture_keyboard && masks.keyboard != 0) + + usize::from(self.capture_pointer && masks.pointer != 0); + } + 0 + } + + fn start_session(&mut self) { + if self.session.is_some() || self.fixture_session_active() { + return; + } + self.degraded = None; + let keyboard_granted = !self.capture_keyboard || self.permission_granted(); + let requested_keyboard = self.capture_keyboard && keyboard_granted; + let requested_pointer = self.capture_pointer; + #[cfg(feature = "macos-native-fixtures")] + let (effective_keyboard, effective_pointer) = + self.fixture + .as_ref() + .map_or((requested_keyboard, requested_pointer), |fixture| { + let masks = fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .effective_masks; + ( + requested_keyboard && masks.keyboard != 0, + requested_pointer && masks.pointer != 0, + ) + }); + #[cfg(not(feature = "macos-native-fixtures"))] + let (effective_keyboard, effective_pointer) = (requested_keyboard, requested_pointer); + let epoch = self.rotate_epoch_and_clear(); + + if !keyboard_granted { + self.degraded = Some(InteractionDegradation::InputMonitoringPermissionDenied); + } + + #[cfg(feature = "macos-native-fixtures")] + if let Some(fixture) = &self.fixture { + if keyboard_granted + && ((self.capture_keyboard && !effective_keyboard) + || (self.capture_pointer && !effective_pointer)) + { + self.degraded = Some(InteractionDegradation::Unavailable( + "macOS event tap did not activate every requested input kind".to_owned(), + )); + } + *fixture + .active_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + (effective_keyboard || effective_pointer).then_some(epoch); + self.publish_started_status(effective_keyboard, effective_pointer); + return; + } + + if !effective_keyboard && !effective_pointer { + self.publish_started_status(false, false); + return; + } + + let shared = Arc::clone(&self.shared); + let event_limit = self.event_limit; + let config = MacosInputConfig { + keyboard: effective_keyboard, + pointer: effective_pointer, + epoch, + clock: Arc::new(crate::input::input_mono_ms), + }; + match MacosInputSession::start(config, move |batch| { + publish_macos_batch(&shared, batch, event_limit); + }) { + Ok(session) => { + info!( + source = %self.name, + keyboard = effective_keyboard, + pointer = effective_pointer, + "Started macOS event-tap input capture" + ); + self.session = Some(session); + self.publish_started_status(effective_keyboard, effective_pointer); + } + Err(error) => { + self.rotate_epoch_and_clear(); + self.degraded = Some(classify_start_error(&error)); + warn!(source = %self.name, %error, "macOS event-tap input capture unavailable"); + if let Some(status) = self.status.session() { + status.unavailable(issue_for_error(&error)); + } + } + } + } + + fn publish_started_status(&self, keyboard: bool, pointer: bool) { + let Some(status) = self.status.session() else { + return; + }; + let resources = usize::from(keyboard) + usize::from(pointer); + let missing_keyboard = self.capture_keyboard && !keyboard; + let missing_pointer = self.capture_pointer && !pointer; + if missing_keyboard || missing_pointer { + let issue = if missing_keyboard && !self.permission_granted() { + permission_issue() + } else { + event_mask_issue() + }; + if resources == 0 { + status.unavailable(issue); + } else { + status.degraded_with_resources(issue, resources); + } + } else { + status.mark_event_driven_live_without_deadline(resources); + } + } + + fn stop_session(&mut self) { + #[cfg(feature = "macos-native-fixtures")] + if let Some(fixture) = &self.fixture { + *fixture + .active_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + if let Some(mut session) = self.session.take() { + session.stop(); + } + self.rotate_epoch_and_clear(); + } + + fn fixture_session_active(&self) -> bool { + #[cfg(feature = "macos-native-fixtures")] + { + self.fixture.as_ref().is_some_and(|fixture| { + fixture + .active_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + }) + } + #[cfg(not(feature = "macos-native-fixtures"))] + { + false + } + } + + fn capture_session_active(&self) -> bool { + self.session.is_some() || self.fixture_session_active() + } + + fn refresh_worker_health(&mut self) { + let Some(session) = &self.session else { + return; + }; + let state = session.worker_state(); + match state { + MacosWorkerState::Running => {} + MacosWorkerState::Degraded(reason) => { + self.degraded = Some(InteractionDegradation::Unavailable(reason.to_string())); + if let Some(status) = self.status.session() { + status.degraded_with_resources( + worker_degradation_issue(&reason), + self.active_kind_count(), + ); + } + } + MacosWorkerState::PermissionRevoked => { + self.degraded = Some(InteractionDegradation::InputMonitoringPermissionRevoked); + if let Some(status) = self.status.session() { + status.unavailable(permission_revoked_issue()); + } + } + MacosWorkerState::Failed(reason) => { + self.degraded = Some(InteractionDegradation::Unavailable(reason.clone())); + if let Some(status) = self.status.session() { + status.failed(SourceIssue::new( + "macos_input_run_loop_exited", + reason, + true, + )); + } + } + } + } +} + +impl InputSource for MacosHostInput { + fn name(&self) -> &str { + &self.name + } + + fn start(&mut self) -> anyhow::Result<()> { + if self.running { + return Ok(()); + } + if self.capture_active { + if let Some(session) = self.status.begin_session()? { + self.status_session.store(session); + } + self.start_session(); + } + self.running = true; + Ok(()) + } + + fn stop(&mut self) { + self.status_session.clear(); + self.status.stop(); + self.stop_session(); + self.running = false; + } + + fn sample(&mut self) -> anyhow::Result { + self.refresh_worker_health(); + if !self.running || !self.capture_session_active() { + return Ok(InputData::None); + } + let shared = Arc::clone(&self.shared); + let Ok(mut state) = shared.lock() else { + return Ok(InputData::None); + }; + Ok(InputData::Interaction(self.build_snapshot(&mut state))) + } + + fn sample_and_drain_with_delta_secs( + &mut self, + _delta_secs: f32, + ) -> (anyhow::Result, Vec) { + self.refresh_worker_health(); + if !self.running || !self.capture_session_active() { + return (Ok(InputData::None), Vec::new()); + } + let shared = Arc::clone(&self.shared); + let Ok(mut state) = shared.lock() else { + return (Ok(InputData::None), Vec::new()); + }; + let events = drain_events(&mut state.events); + let snapshot = self.build_snapshot(&mut state); + (Ok(InputData::Interaction(snapshot)), events) + } + + fn drain_events(&mut self) -> Vec { + if !self.running || !self.capture_session_active() { + return Vec::new(); + } + self.shared + .lock() + .map_or_else(|_| Vec::new(), |mut state| drain_events(&mut state.events)) + } + + fn is_running(&self) -> bool { + self.running + } + + fn source_status_handle(&self) -> Option { + Some(self.status.handle()) + } + + fn source_status_reporter(&mut self) -> Option<&mut SourceStatusReporter> { + Some(&mut self.status) + } + + fn is_interaction_source(&self) -> bool { + true + } + + fn is_host_capture_source(&self) -> bool { + true + } + + fn interaction_diagnostics(&self) -> Option { + let worker_degradation = + self.session + .as_ref() + .and_then(|session| match session.worker_state() { + MacosWorkerState::Running => None, + MacosWorkerState::Degraded(reason) => { + Some(InteractionDegradation::Unavailable(reason.to_string())) + } + MacosWorkerState::Failed(reason) => { + Some(InteractionDegradation::Unavailable(reason)) + } + MacosWorkerState::PermissionRevoked => { + Some(InteractionDegradation::InputMonitoringPermissionRevoked) + } + }); + Some(crate::input::InteractionDiagnostics { + backend: "cg_event_tap", + host_capture: true, + capturing: self.capture_active && self.capture_session_active(), + devices_opened: self.active_kind_count(), + devices_denied: 0, + degraded: worker_degradation.or_else(|| self.degraded.clone()), + }) + } + + fn set_interaction_capture_active(&mut self, active: bool) -> anyhow::Result<()> { + self.status.set_policy(true, true, active)?; + if self.capture_active == active { + return Ok(()); + } + self.capture_active = active; + if !self.running { + return Ok(()); + } + if active { + if let Some(session) = self.status.begin_session()? { + self.status_session.store(session); + } + self.start_session(); + } else { + self.status_session.clear(); + self.stop_session(); + } + Ok(()) + } +} + +fn publish_macos_batch( + shared: &Arc>, + batch: MacosInputBatch<'_>, + event_limit: usize, +) -> bool { + let Ok(mut state) = shared.lock() else { + return false; + }; + if state.epoch != batch.epoch { + return false; + } + for event in batch.events { + fold_event( + &mut state, + event, + batch.virtual_desktop, + batch.at_ms, + event_limit, + ); + } + true +} + +fn fold_event( + state: &mut SharedState, + event: &MacosInputEvent, + desktop: MacosVirtualDesktop, + at_ms: u64, + event_limit: usize, +) { + match event { + MacosInputEvent::Key { + virtual_keycode, + pressed, + autorepeat, + } => fold_key( + state, + *virtual_keycode, + *pressed, + *autorepeat, + at_ms, + event_limit, + ), + MacosInputEvent::ModifierFlags { + virtual_keycode, + flags, + } => fold_modifier(state, *virtual_keycode, *flags, at_ms, event_limit), + MacosInputEvent::Button { button, pressed } => { + fold_button(state, *button, *pressed, at_ms, event_limit); + } + MacosInputEvent::Motion { + x, + y, + delta_x, + delta_y, + } => fold_motion(state, desktop, *x, *y, *delta_x, *delta_y), + MacosInputEvent::Wheel { + fixed_delta_x, + fixed_delta_y, + unit, + phase, + momentum_phase, + } => fold_scroll( + state, + *fixed_delta_x, + *fixed_delta_y, + *unit, + *phase, + *momentum_phase, + at_ms, + event_limit, + ), + MacosInputEvent::MediaKey { + nx_key_type, + pressed, + repeat, + } => fold_media_key(state, *nx_key_type, *pressed, *repeat, at_ms, event_limit), + MacosInputEvent::StateGap { reason: _ } => { + state.diagnostics.state_gaps = state.diagnostics.state_gaps.saturating_add(1); + synthesize_releases(state, at_ms, event_limit); + state.topology_generation = None; + state.legacy_wheel_projector.reset(); + } + } +} + +fn fold_key( + state: &mut SharedState, + virtual_keycode: u16, + pressed: bool, + autorepeat: bool, + at_ms: u64, + event_limit: usize, +) { + let Some(key) = macos_key_name(virtual_keycode) else { + state.diagnostics.unsupported_keys = state.diagnostics.unsupported_keys.saturating_add(1); + return; + }; + fold_named_key( + state, + key, + pressed, + autorepeat, + Some(format!("macos:key:{virtual_keycode:02x}")), + at_ms, + event_limit, + ); +} + +fn fold_media_key( + state: &mut SharedState, + nx_key_type: u16, + pressed: bool, + repeat: bool, + at_ms: u64, + event_limit: usize, +) { + let Some(key) = macos_media_key_name(nx_key_type) else { + state.diagnostics.unsupported_media_keys = + state.diagnostics.unsupported_media_keys.saturating_add(1); + return; + }; + fold_named_key( + state, + key, + pressed, + repeat, + Some(format!("macos:nx:{nx_key_type}")), + at_ms, + event_limit, + ); +} + +fn fold_named_key( + state: &mut SharedState, + key: &str, + pressed: bool, + autorepeat: bool, + physical_code: Option, + at_ms: u64, + event_limit: usize, +) { + let held = state.pressed_keys.contains(key); + let button_state = if pressed && autorepeat { + if !held { + state.diagnostics.impossible_key_edges = + state.diagnostics.impossible_key_edges.saturating_add(1); + } + InputButtonState::Repeated + } else if pressed { + if held { + state.diagnostics.impossible_key_edges = + state.diagnostics.impossible_key_edges.saturating_add(1); + InputButtonState::Repeated + } else { + state.pressed_keys.insert(key.to_owned()); + state.recent_keys.push_back(key.to_owned()); + cap_recent(&mut state.recent_keys, event_limit); + InputButtonState::Pressed + } + } else { + if !state.pressed_keys.remove(key) { + state.diagnostics.impossible_key_edges = + state.diagnostics.impossible_key_edges.saturating_add(1); + } + InputButtonState::Released + }; + push_event( + state, + TimedInputEvent { + event: InputEvent::Key { + source_id: SOURCE_ID.to_owned(), + key: key.to_owned(), + state: button_state, + }, + at_ms, + seq: 0, + physical_code, + repeat_count: 1, + }, + event_limit, + ); +} + +fn fold_modifier( + state: &mut SharedState, + virtual_keycode: u16, + flags: MacosModifierFlags, + at_ms: u64, + event_limit: usize, +) { + let Some((key, mask, counterpart)) = modifier_key(virtual_keycode) else { + state.diagnostics.unsupported_keys = state.diagnostics.unsupported_keys.saturating_add(1); + return; + }; + let held = state.pressed_keys.contains(key); + let active = flags.contains(mask); + let pressed = if key == "CapsLock" || active != held { + active + } else if active && counterpart.is_some_and(|other| state.pressed_keys.contains(other)) { + false + } else { + state.diagnostics.impossible_key_edges = + state.diagnostics.impossible_key_edges.saturating_add(1); + return; + }; + fold_named_key( + state, + key, + pressed, + false, + Some(format!("macos:key:{virtual_keycode:02x}")), + at_ms, + event_limit, + ); +} + +fn modifier_key( + virtual_keycode: u16, +) -> Option<(&'static str, MacosModifierFlags, Option<&'static str>)> { + match virtual_keycode { + 0x38 => Some(("ShiftLeft", MacosModifierFlags::SHIFT, Some("ShiftRight"))), + 0x3c => Some(("ShiftRight", MacosModifierFlags::SHIFT, Some("ShiftLeft"))), + 0x3b => Some(( + "ControlLeft", + MacosModifierFlags::CONTROL, + Some("ControlRight"), + )), + 0x3e => Some(( + "ControlRight", + MacosModifierFlags::CONTROL, + Some("ControlLeft"), + )), + 0x3a => Some(("AltLeft", MacosModifierFlags::ALTERNATE, Some("AltRight"))), + 0x3d => Some(("AltRight", MacosModifierFlags::ALTERNATE, Some("AltLeft"))), + 0x37 => Some(("MetaLeft", MacosModifierFlags::COMMAND, Some("MetaRight"))), + 0x36 => Some(("MetaRight", MacosModifierFlags::COMMAND, Some("MetaLeft"))), + 0x39 => Some(("CapsLock", MacosModifierFlags::ALPHA_SHIFT, None)), + _ => None, + } +} + +fn fold_button( + state: &mut SharedState, + button: MacosPointerButton, + pressed: bool, + at_ms: u64, + event_limit: usize, +) { + let name = pointer_button_name(button); + let changed = if pressed { + state.held_buttons.insert(name.clone()) + } else { + state.held_buttons.remove(&name) + }; + if !changed { + state.diagnostics.impossible_button_edges = + state.diagnostics.impossible_button_edges.saturating_add(1); + } + push_event( + state, + TimedInputEvent { + event: InputEvent::MouseButton { + source_id: SOURCE_ID.to_owned(), + button: name.clone(), + state: if pressed { + InputButtonState::Pressed + } else { + InputButtonState::Released + }, + }, + at_ms, + seq: 0, + physical_code: Some(format!("macos:button:{name}")), + repeat_count: 1, + }, + event_limit, + ); +} + +fn pointer_button_name(button: MacosPointerButton) -> String { + match button { + MacosPointerButton::Left => "left".to_owned(), + MacosPointerButton::Right => "right".to_owned(), + MacosPointerButton::Middle => "middle".to_owned(), + MacosPointerButton::Other(number) => { + format!("button{}", u32::from(number).saturating_add(1)) + } + } +} + +fn fold_motion( + state: &mut SharedState, + desktop: MacosVirtualDesktop, + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, +) { + let (norm_x, norm_y) = desktop.normalize(x, y); + state.pointer = Some(PointerSnapshot { + x: saturating_i32(x), + y: saturating_i32(y), + norm_x: norm_x as f32, + norm_y: norm_y as f32, + }); + state.pointer_present = true; + if state.topology_generation != Some(desktop.topology_generation) { + state.topology_generation = Some(desktop.topology_generation); + state.diagnostics.topology_resets = state.diagnostics.topology_resets.saturating_add(1); + return; + } + let dx = (delta_x / desktop.width) as f32; + let dy = (delta_y / desktop.height) as f32; + state.motion.dx += dx; + state.motion.dy += dy; + state.motion.distance += dx.hypot(dy); +} + +#[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "finite desktop coordinates saturate at the public i32 boundary" +)] +fn saturating_i32(value: f64) -> i32 { + value + .round() + .clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 +} + +#[expect( + clippy::too_many_arguments, + reason = "the native wheel record and fold context are one atomic event" +)] +fn fold_scroll( + state: &mut SharedState, + fixed_delta_x: i64, + fixed_delta_y: i64, + unit: MacosScrollUnit, + phase: MacosScrollPhase, + momentum_phase: MacosScrollPhase, + at_ms: u64, + event_limit: usize, +) { + let (delta_x_q16_16, delta_y_q16_16, canonical_unit) = match unit { + MacosScrollUnit::Notches => { + let (x, overflow_x) = checked_line120(fixed_delta_x); + let (y, overflow_y) = checked_line120(fixed_delta_y); + if overflow_x || overflow_y { + state.diagnostics.scroll_overflows = + state.diagnostics.scroll_overflows.saturating_add(1); + } + (x, y, PointerScrollUnit::Line120) + } + MacosScrollUnit::Pixels => (fixed_delta_x, fixed_delta_y, PointerScrollUnit::Pixels), + }; + push_event( + state, + TimedInputEvent { + event: InputEvent::PointerScroll { + source_id: SOURCE_ID.to_owned(), + delta_x_q16_16, + delta_y_q16_16, + unit: canonical_unit, + phase: canonical_phase(phase), + momentum_phase: canonical_phase(momentum_phase), + }, + at_ms, + seq: 0, + physical_code: Some("macos:scroll".to_owned()), + repeat_count: 1, + }, + event_limit, + ); + if canonical_unit != PointerScrollUnit::Line120 { + return; + } + let legacy_delta = state.legacy_wheel_projector.project(delta_y_q16_16); + if legacy_delta == 0 { + return; + } + push_event( + state, + TimedInputEvent { + event: InputEvent::MouseWheel { + source_id: SOURCE_ID.to_owned(), + delta_hi_res: legacy_delta, + }, + at_ms, + seq: 0, + physical_code: Some("macos:legacy-wheel-shadow".to_owned()), + repeat_count: 1, + }, + event_limit, + ); +} + +fn checked_line120(value: i64) -> (i64, bool) { + value.checked_mul(120).map_or_else( + || { + ( + if value.is_negative() { + i64::MIN + } else { + i64::MAX + }, + true, + ) + }, + |scaled| (scaled, false), + ) +} + +const fn canonical_phase(phase: MacosScrollPhase) -> PointerScrollPhase { + match phase { + MacosScrollPhase::None => PointerScrollPhase::None, + MacosScrollPhase::MayBegin => PointerScrollPhase::MayBegin, + MacosScrollPhase::Began => PointerScrollPhase::Began, + MacosScrollPhase::Stationary => PointerScrollPhase::Stationary, + MacosScrollPhase::Changed => PointerScrollPhase::Changed, + MacosScrollPhase::Ended => PointerScrollPhase::Ended, + MacosScrollPhase::Cancelled => PointerScrollPhase::Cancelled, + } +} + +fn synthesize_releases(state: &mut SharedState, at_ms: u64, event_limit: usize) { + let keys = std::mem::take(&mut state.pressed_keys); + for key in keys { + push_event( + state, + TimedInputEvent { + event: InputEvent::Key { + source_id: SOURCE_ID.to_owned(), + key, + state: InputButtonState::Released, + }, + at_ms, + seq: 0, + physical_code: None, + repeat_count: 1, + }, + event_limit, + ); + } + let buttons = std::mem::take(&mut state.held_buttons); + for button in buttons { + push_event( + state, + TimedInputEvent { + event: InputEvent::MouseButton { + source_id: SOURCE_ID.to_owned(), + button, + state: InputButtonState::Released, + }, + at_ms, + seq: 0, + physical_code: None, + repeat_count: 1, + }, + event_limit, + ); + } +} + +fn push_event(state: &mut SharedState, event: TimedInputEvent, limit: usize) { + if limit == 0 { + state.dropped = state + .dropped + .saturating_add(u32::try_from(state.events.len()).unwrap_or(u32::MAX)) + .saturating_add(1); + state.events.clear(); + return; + } + while state.events.len() >= limit { + state.events.pop_front(); + state.dropped = state.dropped.saturating_add(1); + } + state.events.push_back(event); +} + +fn cap_recent(recent: &mut VecDeque, limit: usize) { + while recent.len() > limit { + recent.pop_front(); + } +} + +fn drain_events(events: &mut VecDeque) -> Vec { + events.drain(..).collect() +} + +fn permission_issue() -> SourceIssue { + SourceIssue::new( + InteractionDegradation::InputMonitoringPermissionDenied.code(), + "keyboard capture requires macOS Input Monitoring permission", + true, + ) + .with_remediation( + "open System Settings > Privacy & Security > Input Monitoring, enable Hypercolor, then relaunch the signed app", + ) +} + +fn event_mask_issue() -> SourceIssue { + SourceIssue::new( + "macos_input_tap_create_failed", + "macOS event tap did not activate every requested input kind", + true, + ) +} + +fn permission_revoked_issue() -> SourceIssue { + SourceIssue::new( + InteractionDegradation::InputMonitoringPermissionRevoked.code(), + "macOS revoked Input Monitoring during host input capture", + true, + ) + .with_remediation( + "open System Settings > Privacy & Security > Input Monitoring, enable Hypercolor, then relaunch the signed app", + ) +} + +fn worker_degradation_issue(reason: &MacosWorkerDegradation) -> SourceIssue { + let code = match reason { + MacosWorkerDegradation::TapDisabled(MacosInputGapReason::TapDisabledTimeout) => { + "macos_input_tap_disabled_timeout" + } + MacosWorkerDegradation::TapDisabled(MacosInputGapReason::TapDisabledUserInput) => { + "macos_input_tap_disabled_user_input" + } + MacosWorkerDegradation::TapDisabled(_) | MacosWorkerDegradation::DisplayTopology(_) => { + "macos_input_run_loop_exited" + } + }; + SourceIssue::new(code, reason.to_string(), true) +} + +fn classify_start_error(error: &MacosInputError) -> InteractionDegradation { + if matches!(error, MacosInputError::PermissionDenied) { + InteractionDegradation::InputMonitoringPermissionDenied + } else { + InteractionDegradation::Unavailable(error.to_string()) + } +} + +fn issue_for_error(error: &MacosInputError) -> SourceIssue { + if matches!(error, MacosInputError::PermissionDenied) { + permission_issue() + } else if matches!(error, MacosInputError::TapCreation(_)) { + SourceIssue::new("macos_input_tap_create_failed", error.to_string(), true) + } else { + SourceIssue::new("macos_input_run_loop_exited", error.to_string(), true) + } +} diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index e3d9624e1..e4bf5129c 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -12,6 +12,7 @@ mod graph; #[cfg(target_os = "macos")] pub mod interaction; pub mod keymap; +pub mod macos; pub mod media; pub mod net; pub mod routing; @@ -37,6 +38,9 @@ pub use graph::{ }; #[cfg(target_os = "macos")] pub use interaction::InteractionInput; +pub use macos::{MacosHostInput, MacosInputFoldDiagnostics}; +#[cfg(feature = "macos-native-fixtures")] +pub use macos::{MacosHostInputFixture, MacosInputFixtureBackend}; pub use media::MediaSource; pub use net::NetSource; pub use screen::{ScreenCaptureDemand, ScreenPublicationDemandSnapshot}; diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 7847d5cd2..93f206dd5 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -173,7 +173,7 @@ impl InteractionData { /// Health snapshot for one interaction source. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InteractionDiagnostics { - /// Backend identifier: `"evdev"`, `"device_query"`, or `"browser"`. + /// Backend identifier such as `"evdev"`, `"cg_event_tap"`, or `"browser"`. pub backend: &'static str, /// Whether this source captures from host hardware (vs injected input). pub host_capture: bool, @@ -201,6 +201,10 @@ pub enum InteractionDegradation { /// scheduled task running without an interactive desktop. Raw Input /// registers happily in that state and simply never delivers a message. NoInteractiveSession, + /// The signed process lacks macOS Input Monitoring permission. + InputMonitoringPermissionDenied, + /// macOS revoked Input Monitoring during an active source session. + InputMonitoringPermissionRevoked, /// Device nodes present but unreadable — Linux udev rules missing. AccessDenied, /// The backend could not initialize, or its worker died. @@ -213,6 +217,8 @@ impl InteractionDegradation { pub const fn code(&self) -> &'static str { match self { Self::NoInteractiveSession => "no_interactive_session", + Self::InputMonitoringPermissionDenied => "macos_input_permission_denied", + Self::InputMonitoringPermissionRevoked => "macos_input_permission_revoked", Self::AccessDenied => "access_denied", Self::Unavailable(_) => "unavailable", } diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs new file mode 100644 index 000000000..f35926b9f --- /dev/null +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -0,0 +1,468 @@ +//! macOS host-input folding and deterministic adapter-boundary contracts. + +use hypercolor_core::input::{MacosHostInput, PointerMode, Q16_16_SCALE}; +use hypercolor_core::types::event::{ + InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, +}; +use hypercolor_macos_input::{ + MacosInputBatch, MacosInputEvent, MacosInputGapReason, MacosModifierFlags, MacosPointerButton, + MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, +}; + +fn desktop(topology_generation: u64) -> MacosVirtualDesktop { + MacosVirtualDesktop::new(-200.0, -100.0, 400.0, 200.0, topology_generation) + .expect("fixture desktop is valid") +} + +fn fold( + input: &mut MacosHostInput, + events: &[MacosInputEvent], +) -> ( + hypercolor_core::input::InteractionData, + Vec, +) { + input.fold_and_snapshot(MacosInputBatch { + epoch: input.epoch(), + at_ms: 100, + events, + virtual_desktop: desktop(1), + }) +} + +fn key_states(events: &[hypercolor_core::types::event::TimedInputEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event.event { + InputEvent::Key { state, .. } => Some(state), + _ => None, + }) + .collect() +} + +#[test] +fn native_repeat_and_impossible_edges_preserve_canonical_state() { + let mut input = MacosHostInput::new(true, false); + let events = [ + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: true, + autorepeat: false, + }, + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: true, + autorepeat: true, + }, + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: false, + autorepeat: false, + }, + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: false, + autorepeat: false, + }, + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: true, + autorepeat: true, + }, + ]; + + let (data, folded) = fold(&mut input, &events); + + assert!(data.keyboard.pressed_keys.is_empty()); + assert_eq!(data.keyboard.recent_keys, ["a"]); + assert_eq!( + key_states(&folded), + [ + InputButtonState::Pressed, + InputButtonState::Repeated, + InputButtonState::Released, + InputButtonState::Released, + InputButtonState::Repeated, + ] + ); + assert_eq!(input.fold_diagnostics().impossible_key_edges, 2); +} + +#[test] +fn modifier_flags_keep_sides_distinct_and_toggle_caps_lock() { + let mut input = MacosHostInput::new(true, false); + let shift = MacosModifierFlags::SHIFT; + let caps = MacosModifierFlags::ALPHA_SHIFT; + let events = [ + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x38, + flags: shift, + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x3c, + flags: shift, + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x38, + flags: shift, + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x3c, + flags: MacosModifierFlags::default(), + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x39, + flags: caps, + }, + MacosInputEvent::ModifierFlags { + virtual_keycode: 0x39, + flags: MacosModifierFlags::default(), + }, + ]; + + let (data, folded) = fold(&mut input, &events); + + assert!(data.keyboard.pressed_keys.is_empty()); + assert_eq!( + key_states(&folded), + [ + InputButtonState::Pressed, + InputButtonState::Pressed, + InputButtonState::Released, + InputButtonState::Released, + InputButtonState::Pressed, + InputButtonState::Released, + ] + ); +} + +#[test] +fn media_keys_and_extra_buttons_use_canonical_names() { + let mut input = MacosHostInput::new(true, true); + let events = [ + MacosInputEvent::MediaKey { + nx_key_type: 16, + pressed: true, + repeat: false, + }, + MacosInputEvent::Button { + button: MacosPointerButton::Other(3), + pressed: true, + }, + ]; + + let (data, folded) = fold(&mut input, &events); + + assert_eq!(data.keyboard.pressed_keys, ["MediaPlayPause"]); + assert_eq!(data.mouse.buttons, ["button4"]); + assert!(matches!( + &folded[0].event, + InputEvent::Key { key, .. } if key == "MediaPlayPause" + )); + assert!(matches!( + &folded[1].event, + InputEvent::MouseButton { button, .. } if button == "button4" + )); +} + +#[test] +fn physical_wheel_emits_exact_axes_then_legacy_vertical_shadow() { + let mut input = MacosHostInput::new(false, true); + let events = [MacosInputEvent::Wheel { + fixed_delta_x: Q16_16_SCALE, + fixed_delta_y: -2 * Q16_16_SCALE, + unit: MacosScrollUnit::Notches, + phase: MacosScrollPhase::Changed, + momentum_phase: MacosScrollPhase::None, + }]; + + let (_, folded) = fold(&mut input, &events); + + assert!(matches!( + folded[0].event, + InputEvent::PointerScroll { + delta_x_q16_16: 7_864_320, + delta_y_q16_16: -15_728_640, + unit: PointerScrollUnit::Line120, + phase: PointerScrollPhase::Changed, + momentum_phase: PointerScrollPhase::None, + .. + } + )); + assert!(matches!( + folded[1].event, + InputEvent::MouseWheel { + delta_hi_res: -240, + .. + } + )); +} + +#[test] +fn subunit_wheel_motion_carries_fractional_remainder() { + let mut input = MacosHostInput::new(false, true); + let wheel = [MacosInputEvent::Wheel { + fixed_delta_x: 0, + fixed_delta_y: 1, + unit: MacosScrollUnit::Notches, + phase: MacosScrollPhase::None, + momentum_phase: MacosScrollPhase::None, + }]; + let mut legacy_total = 0; + + for _ in 0..547 { + let (_, folded) = fold(&mut input, &wheel); + legacy_total += folded + .iter() + .filter_map(|event| match event.event { + InputEvent::MouseWheel { delta_hi_res, .. } => Some(delta_hi_res), + _ => None, + }) + .sum::(); + } + + assert_eq!(legacy_total, 1); +} + +#[test] +fn continuous_scroll_preserves_pixels_and_phases_without_legacy_shadow() { + let mut input = MacosHostInput::new(false, true); + let events = [MacosInputEvent::Wheel { + fixed_delta_x: 3 * Q16_16_SCALE, + fixed_delta_y: -4 * Q16_16_SCALE, + unit: MacosScrollUnit::Pixels, + phase: MacosScrollPhase::Began, + momentum_phase: MacosScrollPhase::MayBegin, + }]; + + let (_, folded) = fold(&mut input, &events); + + assert_eq!(folded.len(), 1); + assert!(matches!( + folded[0].event, + InputEvent::PointerScroll { + unit: PointerScrollUnit::Pixels, + phase: PointerScrollPhase::Began, + momentum_phase: PointerScrollPhase::MayBegin, + .. + } + )); +} + +#[test] +fn motion_normalizes_negative_origins_and_resets_on_topology_change() { + let mut input = MacosHostInput::new(false, true); + let first = [MacosInputEvent::Motion { + x: -100.0, + y: 0.0, + delta_x: 90.0, + delta_y: 90.0, + }]; + let second = [MacosInputEvent::Motion { + x: 100.0, + y: 50.0, + delta_x: 20.0, + delta_y: -10.0, + }]; + + let (first_data, _) = fold(&mut input, &first); + let (second_data, _) = fold(&mut input, &second); + let (reset_data, _) = input.fold_and_snapshot(MacosInputBatch { + epoch: input.epoch(), + at_ms: 101, + events: &second, + virtual_desktop: desktop(2), + }); + + assert_eq!(first_data.mouse.mode, PointerMode::Absolute); + assert_eq!((first_data.mouse.x, first_data.mouse.y), (-100, 0)); + assert_eq!( + (first_data.mouse.norm_x, first_data.mouse.norm_y), + (0.25, 0.5) + ); + assert!((second_data.batch.motion.dx - 0.05).abs() < f32::EPSILON); + assert!((second_data.batch.motion.dy + 0.05).abs() < f32::EPSILON); + assert_eq!(reset_data.batch.motion.dx, 0.0); + assert_eq!(reset_data.batch.motion.dy, 0.0); + assert_eq!(input.fold_diagnostics().topology_resets, 2); +} + +#[test] +fn state_gap_synthesizes_releases_and_stale_epoch_is_inert() { + let mut input = MacosHostInput::new(true, true); + let held = [ + MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: true, + autorepeat: false, + }, + MacosInputEvent::Button { + button: MacosPointerButton::Left, + pressed: true, + }, + ]; + fold(&mut input, &held); + let gap = [MacosInputEvent::StateGap { + reason: MacosInputGapReason::QueueOverflow, + }]; + + let (data, releases) = fold(&mut input, &gap); + let (_, stale) = input.fold_and_snapshot(MacosInputBatch { + epoch: input.epoch().wrapping_add(1), + at_ms: 102, + events: &held, + virtual_desktop: desktop(1), + }); + + assert!(data.keyboard.pressed_keys.is_empty()); + assert!(data.mouse.buttons.is_empty()); + assert_eq!(releases.len(), 2); + assert!(releases.iter().all(|event| matches!( + event.event, + InputEvent::Key { + state: InputButtonState::Released, + .. + } | InputEvent::MouseButton { + state: InputButtonState::Released, + .. + } + ))); + assert!(stale.is_empty()); + assert_eq!(input.fold_diagnostics().state_gaps, 1); +} + +#[cfg(feature = "macos-native-fixtures")] +mod fixtures { + use hypercolor_core::input::{ + InputData, InputSource, MacosHostInput, MacosInputFixtureBackend, SourceState, + }; + use hypercolor_macos_input::{MacosInputEvent, event_masks}; + + use super::desktop; + + #[test] + fn denied_keyboard_permission_keeps_pointer_capture_live() { + let backend = + MacosInputFixtureBackend::new(false, true, event_masks(false, true), true, desktop(1)); + let (mut source, fixture) = MacosHostInput::new_deterministic_fixture(true, true, backend); + let status = source + .source_status_handle() + .expect("macOS host source exposes status"); + + source.set_source_graph_generation(1); + source.start().expect("fixture starts idle"); + assert!(!fixture.is_active()); + source + .set_interaction_capture_active(true) + .expect("pointer capture activates without keyboard permission"); + assert!(fixture.is_active()); + assert_eq!(status.snapshot().state, SourceState::Degraded); + assert_eq!(status.snapshot().resource_count, 1); + assert_eq!( + status + .snapshot() + .issue + .as_ref() + .expect("permission issue is published") + .code + .as_ref(), + "macos_input_permission_denied" + ); + + fixture + .publish( + &[MacosInputEvent::Button { + button: hypercolor_macos_input::MacosPointerButton::Left, + pressed: true, + }], + 100, + ) + .expect("pointer batch publishes"); + let InputData::Interaction(sample) = source.sample().expect("fixture sample succeeds") + else { + panic!("expected interaction sample"); + }; + assert_eq!(sample.mouse.buttons, ["left"]); + assert_eq!(status.snapshot().state, SourceState::Degraded); + } + + #[test] + fn fixture_masks_and_epochs_enforce_demand_lifecycle() { + let backend = + MacosInputFixtureBackend::new(true, true, event_masks(true, false), true, desktop(1)); + let (mut source, fixture) = MacosHostInput::new_deterministic_fixture(true, false, backend); + source.set_source_graph_generation(1); + source.start().expect("fixture starts idle"); + source + .set_interaction_capture_active(true) + .expect("keyboard fixture activates"); + let first_epoch = fixture.active_epoch().expect("fixture owns one epoch"); + + source + .set_interaction_capture_active(false) + .expect("fixture deactivates"); + assert!(!fixture.is_active()); + source.set_source_graph_generation(2); + source + .set_interaction_capture_active(true) + .expect("fixture reactivates"); + assert_ne!(fixture.active_epoch(), Some(first_epoch)); + assert!( + !fixture + .publish_with_epoch( + first_epoch, + &[MacosInputEvent::Key { + virtual_keycode: 0x00, + pressed: true, + autorepeat: false, + }], + 200, + ) + .expect("stale publication is rejected without an error") + ); + + source.stop(); + assert!(!fixture.is_active()); + } + + #[test] + fn empty_effective_masks_publish_unavailable_status() { + let backend = + MacosInputFixtureBackend::new(true, true, event_masks(false, false), true, desktop(1)); + let (mut source, fixture) = MacosHostInput::new_deterministic_fixture(true, true, backend); + let status = source + .source_status_handle() + .expect("macOS host source exposes status"); + + source.set_source_graph_generation(1); + source.start().expect("fixture starts idle"); + source + .set_interaction_capture_active(true) + .expect("empty masks produce typed status"); + + assert!(!fixture.is_active()); + assert_eq!(status.snapshot().state, SourceState::Unavailable); + assert_eq!( + status + .snapshot() + .issue + .as_ref() + .expect("mask issue is published") + .code + .as_ref(), + "macos_input_tap_create_failed" + ); + } + + #[test] + fn permission_request_fixture_reports_owner_restart_result() { + let backend = + MacosInputFixtureBackend::new(false, true, event_masks(true, true), true, desktop(1)); + let (_, fixture) = MacosHostInput::new_deterministic_fixture(true, true, backend); + + assert!( + fixture + .request_input_monitoring_and_restart_owner() + .expect("owner restart succeeds") + ); + } +} diff --git a/crates/hypercolor-macos-input/src/lib.rs b/crates/hypercolor-macos-input/src/lib.rs index a0baa1c5e..b6b9aeb67 100644 --- a/crates/hypercolor-macos-input/src/lib.rs +++ b/crates/hypercolor-macos-input/src/lib.rs @@ -15,7 +15,8 @@ pub use decode::{ pub use shared::{ EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, MacosInputEvent, MacosInputGapReason, MacosInputResult, MacosMediaKey, MacosModifierFlags, - MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, + MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, + MacosWorkerDegradation, MacosWorkerState, }; #[cfg(target_os = "macos")] diff --git a/crates/hypercolor-macos-input/src/macos.rs b/crates/hypercolor-macos-input/src/macos.rs index 10d800ff9..8604ed512 100644 --- a/crates/hypercolor-macos-input/src/macos.rs +++ b/crates/hypercolor-macos-input/src/macos.rs @@ -19,8 +19,8 @@ use crate::queue::{DEFAULT_QUEUE_CAPACITY, EventQueue}; use crate::{ EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, MacosInputEvent, MacosInputGapReason, MacosInputResult, MacosModifierFlags, MacosScrollPhase, - MacosScrollUnit, MacosVirtualDesktop, MacosWorkerState, decode_button_event, decode_media_key, - decode_momentum_phase, decode_scroll_phase, event_masks, + MacosScrollUnit, MacosVirtualDesktop, MacosWorkerDegradation, MacosWorkerState, + decode_button_event, decode_media_key, decode_momentum_phase, decode_scroll_phase, event_masks, }; const READY_TIMEOUT: Duration = Duration::from_secs(2); @@ -434,7 +434,10 @@ fn handle_tap_disable(context: &TapContext, reason: MacosInputGapReason) { let previous = context.last_disable_ms.swap(elapsed_ms, Ordering::AcqRel); let health_window_ms = u64::try_from(TAP_DISABLE_HEALTH_WINDOW.as_millis()).unwrap_or(u64::MAX); let repeated = previous != 0 && elapsed_ms.saturating_sub(previous) < health_window_ms; - context.queue.diagnostics().record_tap_disable(repeated); + context + .queue + .diagnostics() + .record_tap_disable(repeated, reason); context.queue.enqueue(MacosInputEvent::StateGap { reason }); if repeated { return; @@ -599,10 +602,10 @@ fn drain_batches( loop { queue.wait(HEALTH_INTERVAL); let now = Instant::now(); - if queue.diagnostics().take_repeated_tap_disable() { + if let Some(reason) = queue.diagnostics().take_repeated_tap_disable() { set_worker_state( state, - MacosWorkerState::Degraded("event tap disabled repeatedly".to_owned()), + MacosWorkerState::Degraded(MacosWorkerDegradation::TapDisabled(reason)), ); } if config.keyboard && !input_monitoring_granted() { @@ -622,7 +625,9 @@ fn drain_batches( Ok(_) => {} Err(error) => set_worker_state( state, - MacosWorkerState::Degraded(format!("display topology refresh failed: {error}")), + MacosWorkerState::Degraded(MacosWorkerDegradation::DisplayTopology( + error.to_string(), + )), ), } next_topology_check = now + TOPOLOGY_INTERVAL; diff --git a/crates/hypercolor-macos-input/src/queue.rs b/crates/hypercolor-macos-input/src/queue.rs index 989f6b70c..4636b7543 100644 --- a/crates/hypercolor-macos-input/src/queue.rs +++ b/crates/hypercolor-macos-input/src/queue.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(target_os = "macos"), allow(dead_code))] use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64, Ordering}; use std::sync::{Mutex, mpsc}; use std::time::Duration; @@ -19,7 +19,7 @@ pub(crate) struct Diagnostics { invalid_scroll_phases: AtomicU64, last_point_delta_x: AtomicI64, last_point_delta_y: AtomicI64, - repeated_tap_disable: AtomicBool, + repeated_tap_disable: AtomicU8, } impl Diagnostics { @@ -38,10 +38,15 @@ impl Diagnostics { self.dropped_events.fetch_add(1, Ordering::Relaxed); } - pub(crate) fn record_tap_disable(&self, repeated: bool) { + pub(crate) fn record_tap_disable(&self, repeated: bool, reason: MacosInputGapReason) { self.tap_disable_count.fetch_add(1, Ordering::Relaxed); if repeated { - self.repeated_tap_disable.store(true, Ordering::Release); + let encoded = match reason { + MacosInputGapReason::TapDisabledTimeout => 1, + MacosInputGapReason::TapDisabledUserInput => 2, + _ => 0, + }; + self.repeated_tap_disable.store(encoded, Ordering::Release); } } @@ -59,8 +64,12 @@ impl Diagnostics { self.last_point_delta_y.store(y, Ordering::Relaxed); } - pub(crate) fn take_repeated_tap_disable(&self) -> bool { - self.repeated_tap_disable.swap(false, Ordering::AcqRel) + pub(crate) fn take_repeated_tap_disable(&self) -> Option { + match self.repeated_tap_disable.swap(0, Ordering::AcqRel) { + 1 => Some(MacosInputGapReason::TapDisabledTimeout), + 2 => Some(MacosInputGapReason::TapDisabledUserInput), + _ => None, + } } } @@ -220,4 +229,16 @@ mod tests { assert!(queue.is_closed()); assert!(queue.is_empty()); } + + #[test] + fn repeated_tap_disable_retains_the_native_reason() { + let diagnostics = Diagnostics::default(); + diagnostics.record_tap_disable(true, MacosInputGapReason::TapDisabledUserInput); + + assert_eq!( + diagnostics.take_repeated_tap_disable(), + Some(MacosInputGapReason::TapDisabledUserInput) + ); + assert_eq!(diagnostics.take_repeated_tap_disable(), None); + } } diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs index 82e162fa4..b29dcc5d8 100644 --- a/crates/hypercolor-macos-input/src/shared.rs +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -219,11 +219,35 @@ pub struct EffectiveEventMasks { pub pointer: u64, } +/// A recoverable event-tap worker failure with stable native meaning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacosWorkerDegradation { + TapDisabled(MacosInputGapReason), + DisplayTopology(String), +} + +impl std::fmt::Display for MacosWorkerDegradation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TapDisabled(MacosInputGapReason::TapDisabledTimeout) => { + f.write_str("event tap was repeatedly disabled by timeout") + } + Self::TapDisabled(MacosInputGapReason::TapDisabledUserInput) => { + f.write_str("event tap was repeatedly disabled by user input") + } + Self::TapDisabled(reason) => write!(f, "event tap was repeatedly disabled: {reason:?}"), + Self::DisplayTopology(reason) => { + write!(f, "display topology refresh failed: {reason}") + } + } + } +} + /// Liveness of the event-tap worker. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MacosWorkerState { Running, - Degraded(String), + Degraded(MacosWorkerDegradation), PermissionRevoked, Failed(String), } From 6ad43fae089f07d31379bbda1b678c0c44e64aef Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 17:06:30 -0700 Subject: [PATCH 017/144] refactor(macos): retire device query input 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) --- Cargo.lock | 46 -- Cargo.toml | 1 - crates/hypercolor-core/Cargo.toml | 3 - .../src/effect/servo/renderer/tests.rs | 10 +- .../src/input/interaction/mod.rs | 698 ------------------ crates/hypercolor-core/src/input/macos.rs | 5 + crates/hypercolor-core/src/input/mod.rs | 4 - crates/hypercolor-core/tests/input_tests.rs | 2 +- .../tests/interaction_input_tests.rs | 152 ---- .../hypercolor-daemon/src/mcp/tools/system.rs | 57 +- .../hypercolor-daemon/src/startup/services.rs | 60 +- docs/design/32-lock-ordering.md | 2 +- docs/specs/72-windows-host-input.md | 20 +- 13 files changed, 115 insertions(+), 945 deletions(-) delete mode 100644 crates/hypercolor-core/src/input/interaction/mod.rs delete mode 100644 crates/hypercolor-core/tests/interaction_input_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0bc07f418..b1a34a382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2662,20 +2662,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "device_query" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7331225604b9b097b41872d550134933d4be83465d70a2c4a132b929f99aaca" -dependencies = [ - "macos-accessibility-client", - "pkg-config", - "readkey", - "readmouse", - "windows 0.48.0", - "x11", -] - [[package]] name = "digest" version = "0.10.7" @@ -5001,7 +4987,6 @@ dependencies = [ "chrono", "cpal", "criterion", - "device_query", "dirs 6.0.0", "dpi", "evdev", @@ -7226,16 +7211,6 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" -[[package]] -name = "macos-accessibility-client" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb" -dependencies = [ - "core-foundation 0.9.4", - "core-foundation-sys", -] - [[package]] name = "malloc_buf" version = "0.0.6" @@ -10247,18 +10222,6 @@ dependencies = [ "font-types", ] -[[package]] -name = "readkey" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a36870cefdfcff57edbc0fa62165f42dfd4e5a0d8965117c1ea84c5700e4450" - -[[package]] -name = "readmouse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be105c72a1e6a5a1198acee3d5b506a15676b74a02ecd78060042a447f408d94" - [[package]] name = "realfft" version = "3.5.0" @@ -16356,15 +16319,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows" version = "0.56.0" diff --git a/Cargo.toml b/Cargo.toml index f3d026043..cc74ae954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,7 +162,6 @@ realfft = "3.4" rustfft = "6.2" cpal = "0.17.3" libpulse-binding = "2.30.1" -device_query = "4.0.1" evdev = "0.13.2" # HTTP client diff --git a/crates/hypercolor-core/Cargo.toml b/crates/hypercolor-core/Cargo.toml index ac1a3436e..21ade36f1 100644 --- a/crates/hypercolor-core/Cargo.toml +++ b/crates/hypercolor-core/Cargo.toml @@ -150,6 +150,3 @@ required-features = ["allocation-contract-tests"] [[bench]] name = "core_pipeline" harness = false - -[target.'cfg(target_os = "macos")'.dependencies] -device_query = { workspace = true } diff --git a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs index 9c9299931..726826085 100644 --- a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs +++ b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs @@ -1341,11 +1341,11 @@ fn queued_frames_merge_recent_keys_from_superseded_inputs() { } #[test] -fn queued_device_query_transition_keeps_canonical_edges_and_latest_state() { +fn queued_macos_transition_keeps_canonical_edges_and_latest_state() { let audio = custom_audio(0.0); let mut first_interaction = custom_interaction(&["legacy-a"], &["a"]); first_interaction.batch.events = vec![timed_key( - "host:device_query", + "host:macos", "a", InputButtonState::Pressed, 1, @@ -1353,8 +1353,8 @@ fn queued_device_query_transition_keeps_canonical_edges_and_latest_state() { )]; let mut second_interaction = custom_interaction(&["legacy-b"], &["b"]); second_interaction.batch.events = vec![ - timed_key("host:device_query", "a", InputButtonState::Released, 2, 1), - timed_key("host:device_query", "b", InputButtonState::Pressed, 3, 1), + timed_key("host:macos", "a", InputButtonState::Released, 2, 1), + timed_key("host:macos", "b", InputButtonState::Pressed, 3, 1), ]; let first = frame_input_with(1.0 / 60.0, 1, &audio, &first_interaction, 320, 200); let second = frame_input_with(1.0 / 60.0, 2, &audio, &second_interaction, 320, 200); @@ -1369,7 +1369,7 @@ fn queued_device_query_transition_keeps_canonical_edges_and_latest_state() { .queued_frame .as_ref() .and_then(QueuedFrameInput::queued_interaction) - .expect("coalesced device_query interaction"); + .expect("coalesced macOS interaction"); assert_eq!(interaction.keyboard.pressed_keys, ["b"]); assert_eq!(interaction.keyboard.recent_keys, ["a", "b"]); assert_eq!( diff --git a/crates/hypercolor-core/src/input/interaction/mod.rs b/crates/hypercolor-core/src/input/interaction/mod.rs deleted file mode 100644 index cc00aac2b..000000000 --- a/crates/hypercolor-core/src/input/interaction/mod.rs +++ /dev/null @@ -1,698 +0,0 @@ -//! Host keyboard and mouse capture for interactive `LightScript` effects. -//! -//! The capture backend runs on a dedicated polling thread so the public input -//! source stays `Send` even when the platform device handle is not. - -use std::sync::{Arc, Mutex, mpsc}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use anyhow::Context; -use device_query::{DeviceQuery, DeviceState, Keycode}; -use hypercolor_types::event::{InputButtonState, InputEvent, TimedInputEvent}; -use tracing::warn; - -use crate::input::traits::{InputData, InputSource, InteractionData, MouseData}; -use crate::input::worker_retention::{retain_input_worker, spawn_input_worker}; -use crate::input::{SourceIssue, SourceKind, SourceStatusHandle, SourceStatusReporter}; - -const POLL_INTERVAL: Duration = Duration::from_millis(10); -const READY_TIMEOUT: Duration = Duration::from_secs(1); -const STOP_TIMEOUT: Duration = Duration::from_secs(1); -const DEFAULT_RECENT_KEY_LIMIT: usize = 32; -const DEFAULT_EVENT_LIMIT: usize = crate::input::InteractionBatch::MAX_EVENTS; -const DEVICE_QUERY_SOURCE_ID: &str = "host:device_query"; - -#[derive(Default)] -struct SharedInteractionState { - interaction: InteractionData, - events: Vec, -} - -/// Global host input source for `LightScript` keyboard and mouse helpers. -/// -/// Interim bridge backend for platforms without a native event backend -/// (Windows/macOS). Never constructed on Linux — evdev owns host input -/// there. Capture is demand-driven: the worker thread only runs while -/// [`set_interaction_capture_active`](InputSource::set_interaction_capture_active) -/// is on. -pub struct InteractionInput { - name: String, - running: bool, - capture_active: bool, - recent_key_limit: usize, - generation: u64, - last_held: Option<(Vec, MouseData)>, - shared: Arc>, - worker: Option, - status: SourceStatusReporter, -} - -struct InteractionWorker { - stop_tx: mpsc::Sender<()>, - exit_rx: mpsc::Receiver<()>, - join_handle: JoinHandle<()>, -} - -impl InteractionInput { - /// Create a new host input capture source. - #[must_use] - pub fn new() -> Self { - Self { - name: "HostInput".to_owned(), - running: false, - capture_active: false, - recent_key_limit: DEFAULT_RECENT_KEY_LIMIT, - generation: 0, - last_held: None, - shared: Arc::new(Mutex::new(SharedInteractionState::default())), - worker: None, - status: SourceStatusReporter::new( - "host_input", - SourceKind::Interaction, - "device_query", - true, - true, - false, - ), - } - } - - fn stop_worker(&mut self) { - if let Some(worker) = &self.worker { - let _ = worker.stop_tx.send(()); - let _ = worker.exit_rx.recv_timeout(STOP_TIMEOUT); - } - if self - .worker - .as_ref() - .is_some_and(|worker| worker.join_handle.is_finished()) - { - let worker = self.worker.take().expect("finished worker remains owned"); - if let Err(panic) = worker.join_handle.join() { - warn!(source = %self.name, message = ?panic, "Host input worker panicked"); - } - } else if self.worker.is_some() { - warn!(source = %self.name, "Host input worker did not stop before the deadline"); - } - if let Ok(mut guard) = self.shared.lock() { - *guard = SharedInteractionState::default(); - } - self.last_held = None; - } - - fn spawn_worker(&mut self) -> anyhow::Result<()> { - self.observe_worker_exit(false); - if self.worker.is_some() { - anyhow::bail!("previous host input worker is still stopping"); - } - - let shared = Arc::clone(&self.shared); - let recent_key_limit = self.recent_key_limit; - let source_name = self.name.clone(); - let status = self.status.session(); - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - let (stop_tx, stop_rx) = mpsc::channel(); - let (exit_tx, exit_rx) = mpsc::sync_channel(1); - - let join_handle = spawn_input_worker( - thread::Builder::new().name("hypercolor-host-input".to_owned()), - move || { - let Some(device_state) = try_create_device_state() else { - warn!( - source = %source_name, - "Host input capture unavailable; interactive LightScript input will stay idle" - ); - if let Some(status) = &status { - status.unavailable( - SourceIssue::new( - "host_input_backend_unavailable", - "host input capture backend is unavailable", - true, - ) - .with_remediation( - "run Hypercolor inside an interactive desktop session", - ), - ); - } - let _ = ready_tx.send(false); - let _ = exit_tx.send(()); - return; - }; - - if let Some(status) = &status { - status.mark_event_driven_live_without_deadline(1); - } - let _ = ready_tx.send(true); - let mut previous_keys: Vec = Vec::new(); - - loop { - let current_keys = sorted_keys(device_state.get_keys()); - let mouse_state = device_state.get_mouse(); - - publish_poll( - &shared, - &previous_keys, - ¤t_keys, - &mouse_state, - recent_key_limit, - DEFAULT_EVENT_LIMIT, - crate::input::input_mono_ms(), - ); - previous_keys.clone_from(¤t_keys); - - match stop_rx.recv_timeout(POLL_INTERVAL) { - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break, - Err(mpsc::RecvTimeoutError::Timeout) => {} - } - } - let _ = exit_tx.send(()); - }, - ) - .context("failed to spawn host input capture worker")?; - - self.worker = Some(InteractionWorker { - stop_tx, - exit_rx, - join_handle, - }); - let ready = match ready_rx.recv_timeout(READY_TIMEOUT) { - Ok(ready) => ready, - Err(error) => { - self.stop_worker(); - anyhow::bail!("timed out waiting for host input worker readiness: {error}"); - } - }; - if !ready { - self.stop_worker(); - return Ok(()); - } - if self.observe_worker_exit(true) { - anyhow::bail!("host input worker exited during startup"); - } - Ok(()) - } - - fn observe_worker_exit(&mut self, publish_failure: bool) -> bool { - let Some(worker) = self.worker.as_ref() else { - return false; - }; - if !worker.join_handle.is_finished() { - return false; - } - let worker = self.worker.take().expect("finished worker remains owned"); - let failure = worker.join_handle.join().err(); - if publish_failure && let Some(status) = self.status.session() { - let detail = failure.map_or_else( - || "host input worker exited unexpectedly".to_owned(), - |panic| format!("host input worker panicked: {panic:?}"), - ); - status.failed(SourceIssue::new("host_input_worker_exited", detail, true)); - } - if let Ok(mut guard) = self.shared.lock() { - *guard = SharedInteractionState::default(); - } - self.last_held = None; - true - } - - fn build_snapshot(&mut self, guard: &mut SharedInteractionState) -> InteractionData { - let mut snapshot = guard.interaction.clone(); - snapshot.keyboard.recent_keys = std::mem::take(&mut guard.interaction.keyboard.recent_keys); - snapshot.batch.dropped_events = std::mem::take(&mut guard.interaction.batch.dropped_events); - - let held = ( - snapshot.keyboard.pressed_keys.clone(), - snapshot.mouse.clone(), - ); - if self.last_held.as_ref() != Some(&held) || !snapshot.keyboard.recent_keys.is_empty() { - self.generation = self.generation.wrapping_add(1); - self.last_held = Some(held); - } - snapshot.generation = self.generation; - snapshot - } - - fn take_snapshot_and_events(&mut self) -> Option<(InteractionData, Vec)> { - let shared = Arc::clone(&self.shared); - let mut guard = shared.lock().ok()?; - let events = std::mem::take(&mut guard.events); - let mut snapshot = self.build_snapshot(&mut guard); - project_recent_keys(&mut snapshot.keyboard.recent_keys, &events); - Some((snapshot, events)) - } - - /// Fold deterministic `device_query` snapshots through the production - /// publication path without starting an operating-system capture worker. - #[doc(hidden)] - pub fn fold_polled_key_sequence_for_testing( - &mut self, - polls: &[(Vec, u64)], - event_limit: usize, - ) -> (InteractionData, Vec) { - let mut previous = Vec::new(); - let mouse = device_query::MouseState { - coords: (0, 0), - button_pressed: Vec::new(), - }; - for (keys, at_ms) in polls { - let current = sorted_keys(keys.clone()); - publish_poll( - &self.shared, - &previous, - ¤t, - &mouse, - DEFAULT_RECENT_KEY_LIMIT, - event_limit, - *at_ms, - ); - previous = current; - } - self.take_snapshot_and_events().unwrap_or_default() - } -} - -impl Drop for InteractionInput { - fn drop(&mut self) { - let Some(worker) = self.worker.take() else { - return; - }; - let _ = worker.stop_tx.send(()); - if worker.join_handle.is_finished() { - let _ = worker.join_handle.join(); - return; - } - retain_input_worker( - worker.join_handle, - Arc::::from(format!("host input source {}", self.name)), - ); - } -} - -impl InputSource for InteractionInput { - fn name(&self) -> &str { - &self.name - } - - fn start(&mut self) -> anyhow::Result<()> { - if self.running { - return Ok(()); - } - - if self.capture_active { - self.status.begin_session()?; - if let Err(error) = self.spawn_worker() { - self.status.stop(); - self.stop_worker(); - return Err(error); - } - } - self.running = true; - Ok(()) - } - - fn stop(&mut self) { - self.status.stop(); - self.stop_worker(); - self.running = false; - } - - fn sample(&mut self) -> anyhow::Result { - self.observe_worker_exit(self.running && self.capture_active); - if !self.running || self.worker.is_none() { - return Ok(InputData::None); - } - - let shared = Arc::clone(&self.shared); - let snapshot = shared.lock().map_or_else( - |_| InteractionData::default(), - |mut guard| self.build_snapshot(&mut guard), - ); - - Ok(InputData::Interaction(snapshot)) - } - - fn sample_and_drain_with_delta_secs( - &mut self, - _delta_secs: f32, - ) -> (anyhow::Result, Vec) { - self.observe_worker_exit(self.running && self.capture_active); - if !self.running || self.worker.is_none() { - return (Ok(InputData::None), Vec::new()); - } - - self.take_snapshot_and_events().map_or_else( - || (Ok(InputData::None), Vec::new()), - |(snapshot, events)| (Ok(InputData::Interaction(snapshot)), events), - ) - } - - fn drain_events(&mut self) -> Vec { - if !self.running || self.worker.is_none() { - return Vec::new(); - } - self.shared.lock().map_or_else( - |_| Vec::new(), - |mut guard| std::mem::take(&mut guard.events), - ) - } - - fn is_running(&self) -> bool { - self.running - } - - fn source_status_handle(&self) -> Option { - Some(self.status.handle()) - } - - fn source_status_reporter(&mut self) -> Option<&mut SourceStatusReporter> { - Some(&mut self.status) - } - - fn is_interaction_source(&self) -> bool { - true - } - - fn is_host_capture_source(&self) -> bool { - true - } - - fn interaction_diagnostics(&self) -> Option { - Some(crate::input::InteractionDiagnostics { - backend: "device_query", - host_capture: true, - capturing: self.capture_active && self.worker.is_some(), - devices_opened: usize::from(self.worker.is_some()), - devices_denied: 0, - degraded: None, - }) - } - - fn set_interaction_capture_active(&mut self, active: bool) -> anyhow::Result<()> { - let previous = self.capture_active; - self.status.set_policy(true, true, active)?; - if previous == active { - return Ok(()); - } - if !self.running { - self.capture_active = active; - return Ok(()); - } - - if active { - self.status.begin_session()?; - if let Err(error) = self.spawn_worker() { - self.status.stop(); - self.status.set_policy(true, true, previous)?; - self.stop_worker(); - return Err(error); - } - } else { - self.status.stop(); - self.stop_worker(); - } - self.capture_active = active; - Ok(()) - } -} - -impl Default for InteractionInput { - fn default() -> Self { - Self::new() - } -} - -fn sorted_keys(mut keys: Vec) -> Vec { - keys.sort_by_key(Keycode::to_string); - keys -} - -fn publish_poll( - shared: &Arc>, - previous: &[Keycode], - current: &[Keycode], - mouse_state: &device_query::MouseState, - recent_key_limit: usize, - event_limit: usize, - at_ms: u64, -) { - let (recent_keys, events) = key_transitions(previous, current, at_ms); - let pressed_keys = current.iter().copied().map(canonical_key_name).collect(); - let mouse = mouse_data_from_state(mouse_state); - - if let Ok(mut guard) = shared.lock() { - guard.interaction.keyboard.pressed_keys = pressed_keys; - extend_recent_keys( - &mut guard.interaction.keyboard.recent_keys, - recent_keys, - recent_key_limit, - ); - let dropped = extend_events(&mut guard.events, events, event_limit); - guard.interaction.batch.dropped_events = guard - .interaction - .batch - .dropped_events - .saturating_add(dropped); - guard.interaction.mouse = mouse; - } -} - -fn key_transitions( - previous: &[Keycode], - current: &[Keycode], - at_ms: u64, -) -> (Vec, Vec) { - let released = previous.iter().filter(|key| !current.contains(key)); - let pressed = current.iter().filter(|key| !previous.contains(key)); - let mut recent_keys = Vec::new(); - let mut events = Vec::new(); - - // Snapshot polling has no within-poll ordering. Release-before-press keeps - // replacement chords from briefly exposing both old and new held state. - for key in released { - events.push(key_event( - canonical_key_name(*key), - InputButtonState::Released, - at_ms, - )); - } - for key in pressed { - let key = canonical_key_name(*key); - recent_keys.push(key.clone()); - events.push(key_event(key, InputButtonState::Pressed, at_ms)); - } - - (recent_keys, events) -} - -fn key_event(key: String, state: InputButtonState, at_ms: u64) -> TimedInputEvent { - TimedInputEvent { - event: InputEvent::Key { - source_id: DEVICE_QUERY_SOURCE_ID.to_owned(), - key, - state, - }, - at_ms, - seq: 0, - physical_code: None, - repeat_count: 1, - } -} - -fn extend_recent_keys(target: &mut Vec, mut recent: Vec, limit: usize) { - target.append(&mut recent); - if target.len() > limit { - let overflow = target.len() - limit; - target.drain(..overflow); - } -} - -fn extend_events( - target: &mut Vec, - mut events: Vec, - limit: usize, -) -> u32 { - target.append(&mut events); - let overflow = target.len().saturating_sub(limit); - if overflow > 0 { - target.drain(..overflow); - } - u32::try_from(overflow).unwrap_or(u32::MAX) -} - -fn project_recent_keys(target: &mut Vec, events: &[TimedInputEvent]) { - target.clear(); - target.extend(events.iter().filter_map(|event| match &event.event { - InputEvent::Key { - key, - state: InputButtonState::Pressed, - .. - } => Some(key.clone()), - InputEvent::Key { .. } - | InputEvent::MouseButton { .. } - | InputEvent::MouseWheel { .. } - | InputEvent::PointerScroll { .. } - | InputEvent::MidiNote { .. } - | InputEvent::MidiControlChange { .. } - | InputEvent::MidiPitchBend { .. } - | InputEvent::MidiRealtime { .. } => None, - })); -} - -fn mouse_data_from_state(mouse_state: &device_query::MouseState) -> MouseData { - let buttons = mouse_state - .button_pressed - .iter() - .enumerate() - .filter(|(_, pressed)| **pressed) - .map(|(idx, _)| mouse_button_name(idx)) - .collect::>(); - let (x, y) = mouse_state.coords; - // device_query reports desktop pixels without screen geometry, so the - // normalized fields stay unavailable until the native backends land. - MouseData { - x, - y, - down: !buttons.is_empty(), - buttons, - norm_x: 0.0, - norm_y: 0.0, - mode: crate::input::traits::PointerMode::None, - injected: false, - } -} - -fn mouse_button_name(index: usize) -> String { - match index { - 1 => "left", - 2 => "middle", - 3 => "right", - 4 => "button4", - 5 => "button5", - _ => "button", - } - .to_owned() -} - -fn canonical_key_name(key: Keycode) -> String { - match key { - Keycode::Key0 => "0", - Keycode::Key1 => "1", - Keycode::Key2 => "2", - Keycode::Key3 => "3", - Keycode::Key4 => "4", - Keycode::Key5 => "5", - Keycode::Key6 => "6", - Keycode::Key7 => "7", - Keycode::Key8 => "8", - Keycode::Key9 => "9", - Keycode::A => "a", - Keycode::B => "b", - Keycode::C => "c", - Keycode::D => "d", - Keycode::E => "e", - Keycode::F => "f", - Keycode::G => "g", - Keycode::H => "h", - Keycode::I => "i", - Keycode::J => "j", - Keycode::K => "k", - Keycode::L => "l", - Keycode::M => "m", - Keycode::N => "n", - Keycode::O => "o", - Keycode::P => "p", - Keycode::Q => "q", - Keycode::R => "r", - Keycode::S => "s", - Keycode::T => "t", - Keycode::U => "u", - Keycode::V => "v", - Keycode::W => "w", - Keycode::X => "x", - Keycode::Y => "y", - Keycode::Z => "z", - Keycode::Up => "ArrowUp", - Keycode::Down => "ArrowDown", - Keycode::Left => "ArrowLeft", - Keycode::Right => "ArrowRight", - Keycode::LControl => "ControlLeft", - Keycode::RControl => "ControlRight", - Keycode::LShift => "ShiftLeft", - Keycode::RShift => "ShiftRight", - Keycode::LAlt | Keycode::LOption => "AltLeft", - Keycode::RAlt | Keycode::ROption => "AltRight", - Keycode::LMeta | Keycode::Command => "MetaLeft", - Keycode::RMeta | Keycode::RCommand => "MetaRight", - other => match other { - Keycode::Grave => "`", - Keycode::Minus => "-", - Keycode::Equal => "=", - Keycode::LeftBracket => "[", - Keycode::RightBracket => "]", - Keycode::BackSlash => "\\", - Keycode::Semicolon => ";", - Keycode::Apostrophe => "'", - Keycode::Comma => ",", - Keycode::Dot => ".", - Keycode::Slash => "/", - _ => return other.to_string(), - }, - } - .to_owned() -} - -fn try_create_device_state() -> Option { - #[cfg(target_os = "linux")] - { - if !host_input_session_available() { - return None; - } - DeviceState::checked_new() - } - - #[cfg(not(target_os = "linux"))] - { - std::panic::catch_unwind(DeviceState::new).ok() - } -} - -#[cfg(target_os = "linux")] -fn host_input_session_available() -> bool { - std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - use device_query::Keycode; - - #[test] - fn canonical_key_names_follow_browser_style_for_common_keys() { - assert_eq!(canonical_key_name(Keycode::A), "a"); - assert_eq!(canonical_key_name(Keycode::Escape), "Escape"); - assert_eq!(canonical_key_name(Keycode::Left), "ArrowLeft"); - assert_eq!(canonical_key_name(Keycode::Space), "Space"); - assert_eq!(canonical_key_name(Keycode::LControl), "ControlLeft"); - } - - #[test] - fn extend_recent_keys_caps_queue_size() { - let mut recent = vec!["a".to_owned(), "b".to_owned()]; - extend_recent_keys(&mut recent, vec!["c".to_owned(), "d".to_owned()], 3); - assert_eq!(recent, vec!["b", "c", "d"]); - } - - #[test] - fn mouse_state_maps_common_buttons() { - let mouse_state = device_query::MouseState { - coords: (12, 34), - button_pressed: vec![false, true, false, true], - }; - let mouse = mouse_data_from_state(&mouse_state); - assert_eq!(mouse.x, 12); - assert_eq!(mouse.y, 34); - assert!(mouse.down); - assert_eq!(mouse.buttons, vec!["left", "right"]); - } -} diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index 1c64ae9f8..b97eba230 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -278,6 +278,11 @@ impl MacosHostInput { self.degraded.clone() } + #[must_use] + pub const fn capture_kinds(&self) -> (bool, bool) { + (self.capture_keyboard, self.capture_pointer) + } + #[must_use] pub fn fold_diagnostics(&self) -> MacosInputFoldDiagnostics { self.shared diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index e4bf5129c..3c178ae26 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -9,8 +9,6 @@ pub mod browser; #[cfg(target_os = "linux")] pub mod evdev; mod graph; -#[cfg(target_os = "macos")] -pub mod interaction; pub mod keymap; pub mod macos; pub mod media; @@ -36,8 +34,6 @@ pub use graph::{ INPUT_EVENT_RING_CAPACITY, InputEventRead, InputGraphHandle, InputGraphSnapshot, InputPublicationRead, InputSourceSlot, InteractionSourceOrigin, InteractionTransientTotals, }; -#[cfg(target_os = "macos")] -pub use interaction::InteractionInput; pub use macos::{MacosHostInput, MacosInputFoldDiagnostics}; #[cfg(feature = "macos-native-fixtures")] pub use macos::{MacosHostInputFixture, MacosInputFixtureBackend}; diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index d8ace633a..4677b37c9 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -1476,7 +1476,7 @@ fn production_source_constructors_expose_status_handles() { #[cfg(target_os = "macos")] { - let mut source = hypercolor_core::input::InteractionInput::new(); + let mut source = hypercolor_core::input::MacosHostInput::new(true, true); assert!(source.source_status_handle().is_some()); assert!(source.source_status_reporter().is_some()); } diff --git a/crates/hypercolor-core/tests/interaction_input_tests.rs b/crates/hypercolor-core/tests/interaction_input_tests.rs deleted file mode 100644 index 1640b5078..000000000 --- a/crates/hypercolor-core/tests/interaction_input_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -#![cfg(target_os = "macos")] - -use device_query::Keycode; -use hypercolor_core::input::{InteractionBatch, InteractionInput}; -use hypercolor_core::types::event::{InputButtonState, InputEvent}; - -#[test] -fn publishes_canonical_key_edges_with_sampled_state() { - let mut source = InteractionInput::new(); - let polls = [ - (vec![Keycode::A], 10), - (vec![Keycode::A], 15), - (vec![Keycode::B], 20), - ]; - - let (snapshot, events) = - source.fold_polled_key_sequence_for_testing(&polls, InteractionBatch::MAX_EVENTS); - - assert_eq!(snapshot.keyboard.pressed_keys, ["b"]); - assert_eq!(snapshot.keyboard.recent_keys, ["a", "b"]); - assert_eq!(snapshot.batch.dropped_events, 0); - assert_eq!(events.len(), 3); - assert!(matches!( - &events[0].event, - InputEvent::Key { - source_id, - key, - state: InputButtonState::Pressed, - } if source_id == "host:device_query" && key == "a" - )); - assert!(matches!( - &events[1].event, - InputEvent::Key { - source_id, - key, - state: InputButtonState::Released, - } if source_id == "host:device_query" && key == "a" - )); - assert!(matches!( - &events[2].event, - InputEvent::Key { - source_id, - key, - state: InputButtonState::Pressed, - } if source_id == "host:device_query" && key == "b" - )); - assert_eq!( - events.iter().map(|event| event.at_ms).collect::>(), - [10, 20, 20] - ); - assert!(events.iter().all(|event| { - event.seq == 0 && event.physical_code.is_none() && event.repeat_count == 1 - })); -} - -#[test] -fn canonical_events_exceed_the_legacy_recent_limit() { - let mut source = InteractionInput::new(); - let keys = vec![ - Keycode::A, - Keycode::B, - Keycode::C, - Keycode::D, - Keycode::E, - Keycode::F, - Keycode::G, - Keycode::H, - Keycode::I, - Keycode::J, - Keycode::K, - Keycode::L, - Keycode::M, - Keycode::N, - Keycode::O, - Keycode::P, - Keycode::Q, - Keycode::R, - Keycode::S, - Keycode::T, - Keycode::U, - Keycode::V, - Keycode::W, - Keycode::X, - Keycode::Y, - Keycode::Z, - Keycode::Key0, - Keycode::Key1, - Keycode::Key2, - Keycode::Key3, - Keycode::Key4, - Keycode::Key5, - Keycode::Key6, - Keycode::Key7, - Keycode::Key8, - Keycode::Key9, - Keycode::Up, - Keycode::Down, - Keycode::Left, - Keycode::Right, - ]; - - let (snapshot, events) = - source.fold_polled_key_sequence_for_testing(&[(keys, 10)], InteractionBatch::MAX_EVENTS); - let projected = projected_recent_keys(&events); - - assert_eq!(events.len(), 40); - assert_eq!(snapshot.keyboard.pressed_keys.len(), 40); - assert_eq!(snapshot.keyboard.recent_keys, projected); - assert_eq!(snapshot.keyboard.recent_keys.len(), 40); - assert_eq!(snapshot.batch.dropped_events, 0); -} - -#[test] -fn overflow_projects_recents_from_256_retained_events() { - let mut source = InteractionInput::new(); - let mut polls = Vec::with_capacity(261); - for index in 0_u64..130 { - polls.push((vec![Keycode::A], index * 2 + 1)); - polls.push((Vec::new(), index * 2 + 2)); - } - polls.push((vec![Keycode::B], 261)); - - let (snapshot, events) = - source.fold_polled_key_sequence_for_testing(&polls, InteractionBatch::MAX_EVENTS); - let projected = projected_recent_keys(&events); - - assert_eq!(snapshot.keyboard.pressed_keys, ["b"]); - assert_eq!(events.len(), InteractionBatch::MAX_EVENTS); - assert_eq!(events.first().map(|event| event.at_ms), Some(6)); - assert_eq!(events.last().map(|event| event.at_ms), Some(261)); - assert_eq!(snapshot.keyboard.recent_keys, projected); - assert_eq!(snapshot.keyboard.recent_keys.len(), 128); - assert_eq!( - snapshot.keyboard.recent_keys.last().map(String::as_str), - Some("b") - ); - assert_eq!(snapshot.batch.dropped_events, 5); -} - -fn projected_recent_keys(events: &[hypercolor_core::types::event::TimedInputEvent]) -> Vec { - events - .iter() - .filter_map(|event| match &event.event { - InputEvent::Key { - key, - state: InputButtonState::Pressed, - .. - } => Some(key.clone()), - _ => None, - }) - .collect() -} diff --git a/crates/hypercolor-daemon/src/mcp/tools/system.rs b/crates/hypercolor-daemon/src/mcp/tools/system.rs index 4c0faa34e..025f9910a 100644 --- a/crates/hypercolor-daemon/src/mcp/tools/system.rs +++ b/crates/hypercolor-daemon/src/mcp/tools/system.rs @@ -351,20 +351,7 @@ pub(super) async fn handle_get_status_with_state(state: &AppState) -> Result { - "blocked_permissions" - } - Some(code) if code == InteractionDegradation::NoInteractiveSession.code() => { - "no_interactive_session" - } - Some(_) => "unavailable", - None => "enabled", - } - } else { - "disabled" - }; + let input_state = interaction_state(input.enabled, input.degraded.as_deref()); let power = *state.power_state.borrow(); let paused = power.reported_paused(); @@ -405,6 +392,29 @@ pub(super) async fn handle_get_status_with_state(state: &AppState) -> Result) -> &'static str { + if enabled { + match degraded { + Some(code) if code == InteractionDegradation::AccessDenied.code() => { + "blocked_permissions" + } + Some(code) if code == InteractionDegradation::NoInteractiveSession.code() => { + "no_interactive_session" + } + Some(code) + if code == InteractionDegradation::InputMonitoringPermissionDenied.code() + || code == InteractionDegradation::InputMonitoringPermissionRevoked.code() => + { + "blocked_permissions" + } + Some(_) => "unavailable", + None => "enabled", + } + } else { + "disabled" + } +} + pub(super) async fn handle_get_sensor_data_with_state( params: &Value, state: &AppState, @@ -659,3 +669,22 @@ pub(super) async fn handle_diagnose_with_state( } })) } + +#[cfg(test)] +mod tests { + use super::interaction_state; + use hypercolor_core::input::InteractionDegradation; + + #[test] + fn macos_permission_failures_report_blocked_permissions() { + for degradation in [ + InteractionDegradation::InputMonitoringPermissionDenied, + InteractionDegradation::InputMonitoringPermissionRevoked, + ] { + assert_eq!( + interaction_state(true, Some(degradation.code())), + "blocked_permissions" + ); + } + } +} diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index e3afe4628..2ae2357d2 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -27,9 +27,8 @@ use hypercolor_core::effect::{EffectRegistry, default_effect_search_paths, regis use hypercolor_core::engine::{FpsTier, RenderLoop}; #[cfg(target_os = "linux")] use hypercolor_core::input::EvdevHostInput; -#[cfg(not(target_os = "linux"))] #[cfg(target_os = "macos")] -use hypercolor_core::input::InteractionInput; +use hypercolor_core::input::MacosHostInput; #[cfg(target_os = "windows")] use hypercolor_core::input::WindowsHostInput; use hypercolor_core::input::audio::AudioInput; @@ -1189,10 +1188,9 @@ pub(crate) fn build_windows_screen_capture_source( /// Build the platform host-input capture source, when config allows one. /// -/// Linux uses evdev and Windows uses Raw Input, both event-driven and both -/// reporting physical key positions. macOS is still on the device_query -/// polling bridge until its CGEventTap backend ships. Returns `None` when -/// input capture is disabled or no source kind is enabled. +/// Every supported platform uses an event-driven native backend that reports +/// physical key positions. Returns `None` when input capture is disabled or +/// no source kind is enabled. pub(crate) fn build_interaction_source( input: &hypercolor_types::config::InputConfig, ) -> Option> { @@ -1221,9 +1219,8 @@ pub(crate) fn build_interaction_source( #[cfg(target_os = "macos")] { - (input.keyboard || input.mouse).then(|| { - Box::new(InteractionInput::new()) as Box - }) + build_macos_host_input_source(input) + .map(|source| Box::new(source) as Box) } #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] @@ -1233,6 +1230,14 @@ pub(crate) fn build_interaction_source( } } +#[cfg(target_os = "macos")] +pub(crate) fn build_macos_host_input_source( + input: &hypercolor_types::config::InputConfig, +) -> Option { + (input.enabled && (input.keyboard || input.mouse)) + .then(|| MacosHostInput::new(input.keyboard, input.mouse)) +} + /// Build the Wayland screen capture source with a restore-token sink that /// persists the portal's source selection back into the daemon config. #[cfg(target_os = "linux")] @@ -1372,3 +1377,40 @@ mod library_startup_tests { )); } } + +#[cfg(all(test, target_os = "macos"))] +mod macos_input_tests { + use super::build_macos_host_input_source; + + #[test] + fn startup_preserves_per_kind_consent() { + let disabled = hypercolor_types::config::InputConfig::default(); + assert!(build_macos_host_input_source(&disabled).is_none()); + + let keyboard = hypercolor_types::config::InputConfig { + enabled: true, + keyboard: true, + mouse: false, + ..Default::default() + }; + let pointer = hypercolor_types::config::InputConfig { + enabled: true, + keyboard: false, + mouse: true, + ..Default::default() + }; + + assert_eq!( + build_macos_host_input_source(&keyboard) + .expect("keyboard source is configured") + .capture_kinds(), + (true, false) + ); + assert_eq!( + build_macos_host_input_source(&pointer) + .expect("pointer source is configured") + .capture_kinds(), + (false, true) + ); + } +} diff --git a/docs/design/32-lock-ordering.md b/docs/design/32-lock-ordering.md index dfc0b3a94..291a0dabb 100644 --- a/docs/design/32-lock-ordering.md +++ b/docs/design/32-lock-ordering.md @@ -42,7 +42,7 @@ a canonical acquisition order to prevent deadlocks, and flags code that violates | `UsbBackend::prism_s` | `tokio::RwLock` | PrismS device config cache | `device/usb_backend.rs:214` | | `AudioCaptureManager::analyzer` | `std::Mutex` | Audio FFT/beat analyzer state | `input/audio/mod.rs:265` | | `EvdevInputSource::shared` | `std::Mutex` | Keyboard/evdev latest snapshot | `input/evdev.rs:42` | -| `InteractionInputSource::shared` | `std::Mutex` | Mouse/interaction latest snapshot | `input/interaction/mod.rs:31` | +| `MacosHostInput::shared` | `std::Mutex` | macOS held state and event batches | `input/macos.rs:47` | | `WaylandScreenCapture::latest_snapshot` | `std::Mutex` | Latest screen capture frame | `input/screen/wayland.rs:34` | | `ServoDelegate::last_url` | `std::Mutex` | Servo navigation URL | `effect/servo/delegate.rs:37` | | `ServoDelegate::console_messages` | `std::Mutex` | Servo console message ring | `effect/servo/delegate.rs:38` | diff --git a/docs/specs/72-windows-host-input.md b/docs/specs/72-windows-host-input.md index d3142dea6..3da53bd96 100644 --- a/docs/specs/72-windows-host-input.md +++ b/docs/specs/72-windows-host-input.md @@ -890,16 +890,13 @@ W3 also updates the MCP heuristic at `mcp/tools/system.rs:279`, which independently reimplements the `denied > 0 && opened == 0` rule and would otherwise keep giving udev advice on Windows after the UI stopped. -## D9. device_query retirement, partially executed +## D9. device_query retirement, complete -Spec 71 W6 retires device_query once Windows and macOS both have native -backends. macOS (CGEventTap) is a separate spec, so `InteractionInput` must -survive this wave. What this spec can do — and does — is narrow the blast -radius immediately: `device_query` moves from an unconditional dependency -(`core/Cargo.toml:68`) to `[target.'cfg(target_os = "macos")'.dependencies]`, -and `interaction/mod.rs` gains a matching `#[cfg]`. Linux and Windows builds -stop compiling and shipping a keylogging-capable crate they no longer use, and -the macOS spec deletes the last of it. +Spec 76 ships the native macOS `CGEventTap` backend and removes the final +`InteractionInput` consumer. The workspace dependency, macOS-only core +dependency, polling source, exports, tests, fixture labels, and lock inventory +entry are gone. Linux uses evdev, Windows uses Raw Input, and macOS uses Core +Graphics event taps. No supported build compiles or ships `device_query`. ## Testing @@ -1025,8 +1022,9 @@ keyboard. See the status notes on each wave. plus the `merge_from` pointer-precedence rule and its test (source registration order in `services.rs:577-585` is left **unchanged**), degraded-health types through `InteractionDiagnostics` → `InputStatus` → - MCP diagnose (`system.rs:279`) → UI remedy, device_query narrowed to macOS. - **Done.** `InputStatus.degraded` is additive and optional, so the vendored + MCP diagnose (`system.rs:279`) → UI remedy. The native macOS backend from + spec 76 completes the planned `device_query` retirement. **Done.** + `InputStatus.degraded` is additive and optional, so the vendored Python client regenerated without an API break. - **W4** — Hardware acceptance pass, parity check against the Linux daemon, docs (permissions/session-model page), cross-model review. **Docs and From 0ca9c07d4571aebb87a5f0ce0a5a4cbf982f3939 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 17:11:27 -0700 Subject: [PATCH 018/144] docs(macos): correct ScreenCaptureKit rect units 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) --- .../76-macos-screen-capture-and-host-input.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/specs/76-macos-screen-capture-and-host-input.md b/docs/specs/76-macos-screen-capture-and-host-input.md index a4825c371..dbba65e4d 100644 --- a/docs/specs/76-macos-screen-capture-and-host-input.md +++ b/docs/specs/76-macos-screen-capture-and-host-input.md @@ -1021,12 +1021,16 @@ The adapter maps these attachment keys into canonical metadata: - screen rect; and - bounding rect for multi-window content. -ScreenCaptureKit rect attachments are in logical points. Storage and -destination extents are in pixels. The adapter uses the delivered scale-factor -and content-scale attachments to convert rects, applies outward rounding for -coverage, clips only after conversion, and rejects a frame whose converted -bounds exceed its plane storage. Dirty rect fixtures include fractional Retina -origins so a point value can never be mistaken for a pixel value. +ScreenCaptureKit content and bounding rect attachments are in logical points. +Dirty rects are already in pixels. Storage and destination extents are also in +pixels. The adapter validates both delivered scale attachments, converts point +rects with the display scale factor, applies outward rounding for coverage, +clips only after conversion, and rejects a frame whose converted storage-local +bounds exceed its plane storage. Content and bounding rect fixtures include +fractional Retina origins so a point value can never be mistaken for a pixel +value. Content scale remains explicit geometry metadata describing how the +original content was scaled into the surface; applying it again during point to +pixel conversion would double-scale the frame. `Idle`, `Blank`, `Suspended`, `Started`, and `Stopped` frames update lifecycle telemetry but do not masquerade as complete image data. A malformed present @@ -1934,8 +1938,8 @@ Fixture tests cover: YUV44410 bi-planar, and unsupported formats; - content rect, display scale, content scale, negative screen origin, and multi-window bounding rect; -- point-to-pixel conversion with fractional Retina origins and outward - rounding; +- point-to-pixel conversion for content and bounding rects with fractional + Retina origins and outward rounding; - dirty rect validation; - cursor composed and hidden capability matching; - source, topology, session, resource, and plan generation fencing; From 080e7388fe2ed314a87f73a2cc9699af3e3cea44 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 17:19:27 -0700 Subject: [PATCH 019/144] feat(macos): add capture frame contract 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) --- Cargo.lock | 7 + crates/hypercolor-macos-capture/Cargo.toml | 28 + crates/hypercolor-macos-capture/src/frame.rs | 576 ++++++++++++++++++ .../hypercolor-macos-capture/src/geometry.rs | 190 ++++++ crates/hypercolor-macos-capture/src/lib.rs | 19 + .../tests/capture_contract_tests.rs | 443 ++++++++++++++ 6 files changed, 1263 insertions(+) create mode 100644 crates/hypercolor-macos-capture/Cargo.toml create mode 100644 crates/hypercolor-macos-capture/src/frame.rs create mode 100644 crates/hypercolor-macos-capture/src/geometry.rs create mode 100644 crates/hypercolor-macos-capture/src/lib.rs create mode 100644 crates/hypercolor-macos-capture/tests/capture_contract_tests.rs diff --git a/Cargo.lock b/Cargo.lock index b1a34a382..3fef8cdf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5315,6 +5315,13 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "hypercolor-macos-capture" +version = "0.3.1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "hypercolor-macos-gpu-interop" version = "0.3.2" diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml new file mode 100644 index 000000000..260def822 --- /dev/null +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "hypercolor-macos-capture" +description = "ScreenCaptureKit acquisition and frame validation for Hypercolor" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +undocumented_unsafe_blocks = "deny" +unwrap_used = "deny" + +[features] +default = [] +capture-fixtures = [] + +[dependencies] +thiserror = { workspace = true } + +[[test]] +name = "capture_contract_tests" +path = "tests/capture_contract_tests.rs" +required-features = ["capture-fixtures"] diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs new file mode 100644 index 000000000..2863656f3 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -0,0 +1,576 @@ +use std::fmt; +use std::sync::Arc; + +use thiserror::Error; + +use crate::geometry::{ + MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosScale, +}; + +pub const MACOS_STREAM_QUEUE_DEPTH: usize = 8; + +const BGRA8: u32 = 0x4247_5241; +const ARGB2101010: u32 = 0x5231_306b; +const RGBA16_FLOAT: u32 = 0x5247_6841; +const YUV420_VIDEO_RANGE: u32 = 0x3432_3076; +const YUV420_FULL_RANGE: u32 = 0x3432_3066; +const YUV44410_VIDEO_RANGE: u32 = 0x7834_3434; +const YUV44410_FULL_RANGE: u32 = 0x7866_3434; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosProtectedSourceState { + Disabled, + NeedsUserAction, + PermissionDenied, + NeedsProcessRestart, + NeedsSelection, + ReadyIdle, + Starting, + Live, + Interrupted, + Revoked, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosFrameStatus { + Complete, + Idle, + Blank, + Suspended, + Started, + Stopped, +} + +impl TryFrom for MacosFrameStatus { + type Error = MacosCaptureError; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::Complete), + 1 => Ok(Self::Idle), + 2 => Ok(Self::Blank), + 3 => Ok(Self::Suspended), + 4 => Ok(Self::Started), + 5 => Ok(Self::Stopped), + _ => Err(MacosCaptureError::UnknownFrameStatus(value)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosCapturePixelFormat { + Bgra8, + Argb2101010, + Rgba16Float, + Yuv420VideoRange, + Yuv420FullRange, + Yuv44410BiPlanar, +} + +impl MacosCapturePixelFormat { + pub fn from_fourcc(fourcc: u32) -> Result { + match fourcc { + BGRA8 => Ok(Self::Bgra8), + ARGB2101010 => Ok(Self::Argb2101010), + RGBA16_FLOAT => Ok(Self::Rgba16Float), + YUV420_VIDEO_RANGE => Ok(Self::Yuv420VideoRange), + YUV420_FULL_RANGE => Ok(Self::Yuv420FullRange), + YUV44410_VIDEO_RANGE | YUV44410_FULL_RANGE => Ok(Self::Yuv44410BiPlanar), + _ => Err(MacosCaptureError::UnsupportedPixelFormat(fourcc)), + } + } + + pub fn fourcc(self, range: MacosColorRange) -> Result { + match (self, range) { + (Self::Bgra8, MacosColorRange::Full) => Ok(BGRA8), + (Self::Argb2101010, MacosColorRange::Full) => Ok(ARGB2101010), + (Self::Rgba16Float, MacosColorRange::Full) => Ok(RGBA16_FLOAT), + (Self::Yuv420VideoRange, MacosColorRange::Video) => Ok(YUV420_VIDEO_RANGE), + (Self::Yuv420FullRange, MacosColorRange::Full) => Ok(YUV420_FULL_RANGE), + (Self::Yuv44410BiPlanar, MacosColorRange::Video) => Ok(YUV44410_VIDEO_RANGE), + (Self::Yuv44410BiPlanar, MacosColorRange::Full) => Ok(YUV44410_FULL_RANGE), + _ => Err(MacosCaptureError::ColorMetadataMismatch), + } + } + + fn plane_layout(self, storage: MacosPixelExtent) -> Vec<(MacosPixelExtent, u64)> { + match self { + Self::Bgra8 | Self::Argb2101010 => vec![(storage, 4)], + Self::Rgba16Float => vec![(storage, 8)], + Self::Yuv420VideoRange | Self::Yuv420FullRange => { + let chroma = MacosPixelExtent { + width: storage.width.div_ceil(2), + height: storage.height.div_ceil(2), + }; + vec![(storage, 1), (chroma, 2)] + } + Self::Yuv44410BiPlanar => vec![(storage, 2), (storage, 4)], + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosColorPrimaries { + Srgb, + DisplayP3, + Rec2020, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosTransferFunction { + Srgb, + Linear, + Pq, + Hlg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosYuvMatrix { + Bt601, + Bt709, + Bt2020, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosColorRange { + Full, + Video, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosChromaLocation { + Left, + Center, + TopLeft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosCaptureColorimetry { + pub primaries: MacosColorPrimaries, + pub transfer: MacosTransferFunction, + pub matrix: Option, + pub range: MacosColorRange, + pub chroma_location: Option, +} + +impl MacosCaptureColorimetry { + pub fn validate_for(self, format: MacosCapturePixelFormat) -> Result<(), MacosCaptureError> { + let rgb = matches!( + format, + MacosCapturePixelFormat::Bgra8 + | MacosCapturePixelFormat::Argb2101010 + | MacosCapturePixelFormat::Rgba16Float + ); + if rgb { + if self.matrix.is_some() + || self.chroma_location.is_some() + || self.range != MacosColorRange::Full + { + return Err(MacosCaptureError::ColorMetadataMismatch); + } + return Ok(()); + } + if self.matrix.is_none() || self.chroma_location.is_none() { + return Err(MacosCaptureError::MissingYuvColorMetadata); + } + match format { + MacosCapturePixelFormat::Yuv420VideoRange if self.range != MacosColorRange::Video => { + Err(MacosCaptureError::ColorMetadataMismatch) + } + MacosCapturePixelFormat::Yuv420FullRange if self.range != MacosColorRange::Full => { + Err(MacosCaptureError::ColorMetadataMismatch) + } + _ => Ok(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacosRawCapturePlane { + pub index: u32, + pub extent: MacosPixelExtent, + pub bytes_per_row: usize, + pub length_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacosCapturePlane { + pub index: u32, + pub extent: MacosPixelExtent, + pub bytes_per_row: usize, + pub length_bytes: u64, +} + +#[derive(Clone)] +pub struct MacosCaptureSurface { + pub iosurface_id: u32, + pub allocation_bytes: u64, + owner: Arc, +} + +impl MacosCaptureSurface { + #[cfg(feature = "capture-fixtures")] + pub fn new_fixture( + iosurface_id: u32, + allocation_bytes: u64, + fixture_id: u64, + ) -> Result { + if iosurface_id == 0 || allocation_bytes == 0 { + return Err(MacosCaptureError::InvalidSurface); + } + Ok(Self { + iosurface_id, + allocation_bytes, + owner: Arc::new(MacosRetainedPixelBuffer { fixture_id }), + }) + } + + pub fn retained_owner_count(&self) -> usize { + Arc::strong_count(&self.owner) + } + + #[cfg(feature = "capture-fixtures")] + pub fn fixture_id(&self) -> u64 { + self.owner.fixture_id + } +} + +impl fmt::Debug for MacosCaptureSurface { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacosCaptureSurface") + .field("iosurface_id", &self.iosurface_id) + .field("allocation_bytes", &self.allocation_bytes) + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +struct MacosRetainedPixelBuffer { + #[cfg(feature = "capture-fixtures")] + fixture_id: u64, +} + +#[derive(Debug, Clone)] +pub struct MacosCaptureFrame { + pub epoch: u64, + pub sequence: u64, + pub display_time: u64, + pub storage_extent: MacosPixelExtent, + pub planes: Arc<[MacosCapturePlane]>, + pub pixel_format: MacosCapturePixelFormat, + pub color: MacosCaptureColorimetry, + pub geometry: MacosCaptureGeometry, + pub damage: Arc<[MacosPixelRect]>, + pub cursor_composed: bool, + pub surface: MacosCaptureSurface, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MacosAttachment { + Missing, + Malformed, + Value(T), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MacosRawFrameAttachments { + pub status: MacosAttachment, + pub display_time: MacosAttachment, + pub display_scale_factor: MacosAttachment, + pub content_scale: MacosAttachment, + pub content_rect: MacosAttachment, + pub dirty_rects: MacosAttachment>, + pub screen_rect: MacosAttachment, + pub bounding_rect: MacosAttachment, +} + +#[derive(Debug, Clone)] +pub struct MacosRawCompleteFrame { + pub storage_extent: MacosPixelExtent, + pub planes: Vec, + pub pixel_format_fourcc: u32, + pub color: MacosCaptureColorimetry, + pub cursor_composed: bool, + pub surface: MacosCaptureSurface, +} + +#[derive(Debug, Clone)] +pub struct MacosRawCaptureSample { + pub frame: Option, + pub attachments: MacosRawFrameAttachments, +} + +#[derive(Debug, Clone)] +pub enum MacosFrameEvent { + Frame(Box), + Lifecycle(MacosFrameStatus), +} + +#[derive(Debug, Clone)] +pub struct MacosFrameDecoder { + epoch: u64, + next_sequence: u64, +} + +impl MacosFrameDecoder { + pub fn new(epoch: u64) -> Self { + Self { + epoch, + next_sequence: 0, + } + } + + pub fn next_sequence(&self) -> u64 { + self.next_sequence + } + + pub fn decode( + &mut self, + sample: MacosRawCaptureSample, + ) -> Result { + let status = + MacosFrameStatus::try_from(required(sample.attachments.status.clone(), "status")?)?; + if status != MacosFrameStatus::Complete { + return Ok(MacosFrameEvent::Lifecycle(status)); + } + + let MacosRawCompleteFrame { + storage_extent, + planes: raw_planes, + pixel_format_fourcc, + color, + cursor_composed, + surface, + } = sample.frame.ok_or(MacosCaptureError::MissingFramePayload)?; + let pixel_format = MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?; + color.validate_for(pixel_format)?; + if pixel_format.fourcc(color.range)? != pixel_format_fourcc { + return Err(MacosCaptureError::ColorMetadataMismatch); + } + let planes = validate_planes( + storage_extent, + pixel_format, + raw_planes, + surface.allocation_bytes, + )?; + let geometry = validate_geometry(storage_extent, &sample.attachments)?; + let damage = validate_damage(storage_extent, &geometry, sample.attachments.dirty_rects)?; + let display_time = required(sample.attachments.display_time, "display_time")?; + let sequence = self.next_sequence; + self.next_sequence = self + .next_sequence + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + + Ok(MacosFrameEvent::Frame(Box::new(MacosCaptureFrame { + epoch: self.epoch, + sequence, + display_time, + storage_extent, + planes: planes.into(), + pixel_format, + color, + geometry, + damage: damage.into(), + cursor_composed, + surface, + }))) + } +} + +fn validate_planes( + storage: MacosPixelExtent, + format: MacosCapturePixelFormat, + raw_planes: Vec, + allocation_bytes: u64, +) -> Result, MacosCaptureError> { + let expected = format.plane_layout(storage); + if raw_planes.len() != expected.len() { + return Err(MacosCaptureError::PlaneCount { + expected: expected.len(), + actual: raw_planes.len(), + }); + } + let mut total_length = 0_u64; + let mut planes = Vec::with_capacity(raw_planes.len()); + for (position, (plane, (expected_extent, bytes_per_pixel))) in + raw_planes.into_iter().zip(expected).enumerate() + { + if usize::try_from(plane.index).ok() != Some(position) { + return Err(MacosCaptureError::InvalidPlaneIndex { + expected: position, + actual: plane.index, + }); + } + if plane.extent != expected_extent { + return Err(MacosCaptureError::InvalidPlaneExtent { + plane: plane.index, + expected: expected_extent, + actual: plane.extent, + }); + } + let minimum_stride = u64::from(expected_extent.width) + .checked_mul(bytes_per_pixel) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let stride = u64::try_from(plane.bytes_per_row) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + if stride < minimum_stride { + return Err(MacosCaptureError::StrideTooSmall { + plane: plane.index, + minimum: minimum_stride, + actual: stride, + }); + } + let minimum_length = stride + .checked_mul(u64::from(expected_extent.height)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if plane.length_bytes < minimum_length { + return Err(MacosCaptureError::PlaneLengthTooSmall { + plane: plane.index, + minimum: minimum_length, + actual: plane.length_bytes, + }); + } + total_length = total_length + .checked_add(plane.length_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + planes.push(MacosCapturePlane { + index: plane.index, + extent: plane.extent, + bytes_per_row: plane.bytes_per_row, + length_bytes: plane.length_bytes, + }); + } + if total_length > allocation_bytes { + return Err(MacosCaptureError::AllocationTooSmall { + required: total_length, + actual: allocation_bytes, + }); + } + Ok(planes) +} + +fn validate_geometry( + storage: MacosPixelExtent, + attachments: &MacosRawFrameAttachments, +) -> Result { + let display_scale_factor = MacosScale::display(required( + attachments.display_scale_factor.clone(), + "scale_factor", + )?)?; + let content_scale = MacosScale::new(required( + attachments.content_scale.clone(), + "content_scale", + )?)?; + let content_rect_points = required(attachments.content_rect.clone(), "content_rect")?; + let content_rect_pixels = content_rect_points.to_pixel_rect(display_scale_factor)?; + if !content_rect_pixels.fits_within(storage) { + return Err(MacosCaptureError::GeometryOutsideStorage("content_rect")); + } + let screen_rect_points = optional(attachments.screen_rect.clone(), "screen_rect")?; + let bounding_rect_points = optional(attachments.bounding_rect.clone(), "bounding_rect")?; + let bounding_rect_pixels = bounding_rect_points + .map(|rect| rect.to_pixel_rect(display_scale_factor)) + .transpose()?; + if bounding_rect_pixels.is_some_and(|rect| !rect.fits_within(storage)) { + return Err(MacosCaptureError::GeometryOutsideStorage("bounding_rect")); + } + Ok(MacosCaptureGeometry { + display_scale_factor, + content_scale, + content_rect_points, + content_rect_pixels, + screen_rect_points, + bounding_rect_points, + bounding_rect_pixels, + }) +} + +fn validate_damage( + storage: MacosPixelExtent, + geometry: &MacosCaptureGeometry, + dirty_rects: MacosAttachment>, +) -> Result, MacosCaptureError> { + match dirty_rects { + MacosAttachment::Missing => Ok(vec![geometry.content_rect_pixels]), + MacosAttachment::Malformed => Err(MacosCaptureError::MalformedAttachment("dirty_rects")), + MacosAttachment::Value(rects) => rects + .into_iter() + .map(|rect| rect.clip_to(storage).map_err(MacosCaptureError::from)) + .collect(), + } +} + +fn required(attachment: MacosAttachment, name: &'static str) -> Result { + match attachment { + MacosAttachment::Missing => Err(MacosCaptureError::MissingAttachment(name)), + MacosAttachment::Malformed => Err(MacosCaptureError::MalformedAttachment(name)), + MacosAttachment::Value(value) => Ok(value), + } +} + +fn optional( + attachment: MacosAttachment, + name: &'static str, +) -> Result, MacosCaptureError> { + match attachment { + MacosAttachment::Missing => Ok(None), + MacosAttachment::Malformed => Err(MacosCaptureError::MalformedAttachment(name)), + MacosAttachment::Value(value) => Ok(Some(value)), + } +} + +#[derive(Debug, Clone, PartialEq, Error)] +pub enum MacosCaptureError { + #[error("missing ScreenCaptureKit attachment: {0}")] + MissingAttachment(&'static str), + #[error("malformed ScreenCaptureKit attachment: {0}")] + MalformedAttachment(&'static str), + #[error("unknown ScreenCaptureKit frame status {0}")] + UnknownFrameStatus(i64), + #[error("unsupported Core Video pixel format {0:#010x}")] + UnsupportedPixelFormat(u32), + #[error("complete frame has no image payload")] + MissingFramePayload, + #[error("pixel plane count mismatch: expected {expected}, got {actual}")] + PlaneCount { expected: usize, actual: usize }, + #[error("pixel plane index mismatch: expected {expected}, got {actual}")] + InvalidPlaneIndex { expected: usize, actual: u32 }, + #[error("pixel plane {plane} extent mismatch: expected {expected:?}, got {actual:?}")] + InvalidPlaneExtent { + plane: u32, + expected: MacosPixelExtent, + actual: MacosPixelExtent, + }, + #[error("pixel plane {plane} stride is {actual}, minimum is {minimum}")] + StrideTooSmall { + plane: u32, + minimum: u64, + actual: u64, + }, + #[error("pixel plane {plane} length is {actual}, minimum is {minimum}")] + PlaneLengthTooSmall { + plane: u32, + minimum: u64, + actual: u64, + }, + #[error("pixel plane arithmetic overflowed")] + ArithmeticOverflow, + #[error("IOSurface allocation is {actual} bytes, minimum is {required}")] + AllocationTooSmall { required: u64, actual: u64 }, + #[error("pixel format and color metadata disagree")] + ColorMetadataMismatch, + #[error("YUV frames require matrix and chroma-location metadata")] + MissingYuvColorMetadata, + #[error("{0} exceeds pixel storage")] + GeometryOutsideStorage(&'static str), + #[error("IOSurface identity and allocation must be nonzero")] + InvalidSurface, + #[error("complete-frame sequence exhausted")] + SequenceExhausted, + #[error(transparent)] + Geometry(#[from] MacosGeometryError), +} diff --git a/crates/hypercolor-macos-capture/src/geometry.rs b/crates/hypercolor-macos-capture/src/geometry.rs new file mode 100644 index 000000000..5395bac19 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/geometry.rs @@ -0,0 +1,190 @@ +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosPixelExtent { + pub width: u32, + pub height: u32, +} + +impl MacosPixelExtent { + pub fn new(width: u32, height: u32) -> Result { + if width == 0 || height == 0 { + return Err(MacosGeometryError::EmptyExtent); + } + Ok(Self { width, height }) + } + + pub fn area(self) -> u64 { + u64::from(self.width) * u64::from(self.height) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacosScale(f64); + +impl MacosScale { + pub fn new(value: f64) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err(MacosGeometryError::InvalidScale(value)); + } + Ok(Self(value)) + } + + pub fn display(value: f64) -> Result { + let scale = Self::new(value)?; + if value > 4.0 { + return Err(MacosGeometryError::InvalidDisplayScale(value)); + } + Ok(scale) + } + + pub fn get(self) -> f64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacosPointRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl MacosPointRect { + pub fn new(x: f64, y: f64, width: f64, height: f64) -> Result { + if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() { + return Err(MacosGeometryError::NonFiniteRect); + } + if width <= 0.0 || height <= 0.0 { + return Err(MacosGeometryError::EmptyRect); + } + Ok(Self { + x, + y, + width, + height, + }) + } + + pub fn to_pixel_rect(self, scale: MacosScale) -> Result { + let min_x = checked_floor(self.x * scale.get())?; + let min_y = checked_floor(self.y * scale.get())?; + let max_x = checked_ceil((self.x + self.width) * scale.get())?; + let max_y = checked_ceil((self.y + self.height) * scale.get())?; + let width = max_x + .checked_sub(min_x) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(MacosGeometryError::RectOverflow)?; + let height = max_y + .checked_sub(min_y) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(MacosGeometryError::RectOverflow)?; + MacosPixelRect::new(min_x, min_y, width, height) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosPixelRect { + pub x: i64, + pub y: i64, + pub width: u32, + pub height: u32, +} + +impl MacosPixelRect { + pub fn new(x: i64, y: i64, width: u32, height: u32) -> Result { + if width == 0 || height == 0 { + return Err(MacosGeometryError::EmptyRect); + } + x.checked_add(i64::from(width)) + .ok_or(MacosGeometryError::RectOverflow)?; + y.checked_add(i64::from(height)) + .ok_or(MacosGeometryError::RectOverflow)?; + Ok(Self { + x, + y, + width, + height, + }) + } + + pub fn fits_within(self, extent: MacosPixelExtent) -> bool { + self.x >= 0 + && self.y >= 0 + && self + .x + .checked_add(i64::from(self.width)) + .is_some_and(|right| right <= i64::from(extent.width)) + && self + .y + .checked_add(i64::from(self.height)) + .is_some_and(|bottom| bottom <= i64::from(extent.height)) + } + + pub fn clip_to(self, extent: MacosPixelExtent) -> Result { + let right = self + .x + .checked_add(i64::from(self.width)) + .ok_or(MacosGeometryError::RectOverflow)?; + let bottom = self + .y + .checked_add(i64::from(self.height)) + .ok_or(MacosGeometryError::RectOverflow)?; + let min_x = self.x.max(0); + let min_y = self.y.max(0); + let max_x = right.min(i64::from(extent.width)); + let max_y = bottom.min(i64::from(extent.height)); + if max_x <= min_x || max_y <= min_y { + return Err(MacosGeometryError::RectOutsideStorage); + } + let width = u32::try_from(max_x - min_x).map_err(|_| MacosGeometryError::RectOverflow)?; + let height = u32::try_from(max_y - min_y).map_err(|_| MacosGeometryError::RectOverflow)?; + Self::new(min_x, min_y, width, height) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MacosCaptureGeometry { + pub display_scale_factor: MacosScale, + pub content_scale: MacosScale, + pub content_rect_points: MacosPointRect, + pub content_rect_pixels: MacosPixelRect, + pub screen_rect_points: Option, + pub bounding_rect_points: Option, + pub bounding_rect_pixels: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum MacosGeometryError { + #[error("pixel extent must be nonzero")] + EmptyExtent, + #[error("rectangle extent must be nonzero")] + EmptyRect, + #[error("rectangle contains a nonfinite coordinate")] + NonFiniteRect, + #[error("scale must be finite and positive, got {0}")] + InvalidScale(f64), + #[error("display scale must be within Apple's [1, 4] range, got {0}")] + InvalidDisplayScale(f64), + #[error("rectangle arithmetic overflowed")] + RectOverflow, + #[error("rectangle does not intersect pixel storage")] + RectOutsideStorage, +} + +fn checked_floor(value: f64) -> Result { + let value = value.floor(); + if !value.is_finite() || value < i64::MIN as f64 || value > i64::MAX as f64 { + return Err(MacosGeometryError::RectOverflow); + } + Ok(value as i64) +} + +fn checked_ceil(value: f64) -> Result { + let value = value.ceil(); + if !value.is_finite() || value < i64::MIN as f64 || value > i64::MAX as f64 { + return Err(MacosGeometryError::RectOverflow); + } + Ok(value as i64) +} diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs new file mode 100644 index 000000000..84db68ef4 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -0,0 +1,19 @@ +//! ScreenCaptureKit acquisition vocabulary and frame validation. +//! +//! Native framework ownership remains private to this crate. The public frame +//! boundary contains only plain Rust metadata plus an opaque retained surface. + +mod frame; +mod geometry; + +pub use frame::{ + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, + MacosCaptureFrame, MacosCapturePixelFormat, MacosCapturePlane, MacosCaptureSurface, + MacosChromaLocation, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, + MacosFrameStatus, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, + MacosRawCompleteFrame, MacosRawFrameAttachments, MacosTransferFunction, MacosYuvMatrix, +}; +pub use geometry::{ + MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosScale, +}; diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs new file mode 100644 index 000000000..19aec1a12 --- /dev/null +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -0,0 +1,443 @@ +use hypercolor_macos_capture::{ + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, + MacosCapturePixelFormat, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, + MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameStatus, MacosGeometryError, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, + MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, MacosTransferFunction, + MacosYuvMatrix, +}; + +const BGRA8: u32 = 0x4247_5241; +const ARGB2101010: u32 = 0x5231_306b; +const RGBA16_FLOAT: u32 = 0x5247_6841; +const YUV420_VIDEO_RANGE: u32 = 0x3432_3076; +const YUV420_FULL_RANGE: u32 = 0x3432_3066; +const YUV44410_VIDEO_RANGE: u32 = 0x7834_3434; +const YUV44410_FULL_RANGE: u32 = 0x7866_3434; + +#[test] +fn queue_depth_is_the_full_framework_limit() { + assert_eq!(MACOS_STREAM_QUEUE_DEPTH, 8); +} + +#[test] +fn all_native_frame_statuses_decode_exactly() { + let expected = [ + MacosFrameStatus::Complete, + MacosFrameStatus::Idle, + MacosFrameStatus::Blank, + MacosFrameStatus::Suspended, + MacosFrameStatus::Started, + MacosFrameStatus::Stopped, + ]; + for (raw, expected) in (0_i64..=5).zip(expected) { + assert_eq!(MacosFrameStatus::try_from(raw), Ok(expected)); + } + assert_eq!( + MacosFrameStatus::try_from(6), + Err(MacosCaptureError::UnknownFrameStatus(6)) + ); +} + +#[test] +fn lifecycle_frames_do_not_consume_complete_sequence_numbers() { + let mut decoder = MacosFrameDecoder::new(41); + for status in 1..=5 { + let mut sample = sample_with_status(status); + sample.frame = None; + let event = decoder + .decode(sample) + .expect("lifecycle status should decode"); + assert!(matches!(event, MacosFrameEvent::Lifecycle(_))); + assert_eq!(decoder.next_sequence(), 0); + } + let frame = decode_frame(&mut decoder, sample_with_status(0)); + assert_eq!(frame.epoch, 41); + assert_eq!(frame.sequence, 0); + assert_eq!(decoder.next_sequence(), 1); +} + +#[test] +fn complete_frames_require_well_formed_mandatory_attachments() { + let mut missing_frame = complete_sample(); + missing_frame.frame = None; + assert_eq!( + decode_error(missing_frame), + MacosCaptureError::MissingFramePayload + ); + + let mut missing = complete_sample(); + missing.attachments.display_time = MacosAttachment::Missing; + assert_eq!( + decode_error(missing), + MacosCaptureError::MissingAttachment("display_time") + ); + + let mut malformed = complete_sample(); + malformed.attachments.content_rect = MacosAttachment::Malformed; + assert_eq!( + decode_error(malformed), + MacosCaptureError::MalformedAttachment("content_rect") + ); +} + +#[test] +fn optional_attachments_have_explicit_absence_semantics() { + let frame = decode_frame(&mut MacosFrameDecoder::new(1), complete_sample()); + assert_eq!(frame.geometry.screen_rect_points, None); + assert_eq!(frame.geometry.bounding_rect_points, None); + assert_eq!(frame.damage.as_ref(), &[pixel_rect(0, 0, 8, 6)]); + + let mut malformed = complete_sample(); + malformed.attachments.screen_rect = MacosAttachment::Malformed; + assert_eq!( + decode_error(malformed), + MacosCaptureError::MalformedAttachment("screen_rect") + ); +} + +#[test] +fn fractional_retina_rects_round_outward_without_content_double_scale() { + let mut sample = complete_sample(); + sample.attachments.display_scale_factor = MacosAttachment::Value(2.0); + sample.attachments.content_scale = MacosAttachment::Value(0.75); + sample.attachments.content_rect = MacosAttachment::Value(point_rect(0.25, 0.25, 3.25, 2.25)); + sample.attachments.bounding_rect = MacosAttachment::Value(point_rect(0.75, 0.25, 2.5, 2.25)); + sample.attachments.screen_rect = + MacosAttachment::Value(point_rect(-1200.25, -40.5, 3.25, 2.25)); + + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample); + assert_eq!(frame.geometry.content_rect_pixels, pixel_rect(0, 0, 7, 5)); + assert_eq!( + frame.geometry.bounding_rect_pixels, + Some(pixel_rect(1, 0, 6, 5)) + ); + assert_eq!(frame.geometry.content_scale.get(), 0.75); + assert_eq!( + frame.geometry.screen_rect_points, + Some(point_rect(-1200.25, -40.5, 3.25, 2.25)) + ); +} + +#[test] +fn point_geometry_outside_storage_is_rejected_after_conversion() { + let mut sample = complete_sample(); + sample.attachments.content_rect = MacosAttachment::Value(point_rect(0.0, 0.0, 8.1, 6.0)); + assert_eq!( + decode_error(sample), + MacosCaptureError::GeometryOutsideStorage("content_rect") + ); +} + +#[test] +fn dirty_rects_remain_pixel_native_and_clip_to_storage() { + let mut sample = complete_sample(); + sample.attachments.display_scale_factor = MacosAttachment::Value(2.0); + sample.attachments.content_rect = MacosAttachment::Value(point_rect(0.0, 0.0, 4.0, 3.0)); + sample.attachments.dirty_rects = MacosAttachment::Value(vec![pixel_rect(-1, 1, 4, 3)]); + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample); + assert_eq!(frame.damage.as_ref(), &[pixel_rect(0, 1, 3, 3)]); +} + +#[test] +fn invalid_extent_scale_and_rect_arithmetic_fail_closed() { + assert_eq!( + MacosPixelExtent::new(0, 1), + Err(MacosGeometryError::EmptyExtent) + ); + assert!(matches!( + MacosScale::new(f64::NAN), + Err(MacosGeometryError::InvalidScale(value)) if value.is_nan() + )); + assert_eq!( + MacosScale::display(4.1), + Err(MacosGeometryError::InvalidDisplayScale(4.1)) + ); + assert_eq!( + MacosPixelRect::new(i64::MAX, 0, 1, 1), + Err(MacosGeometryError::RectOverflow) + ); +} + +#[test] +fn every_required_pixel_format_validates_its_exact_plane_layout() { + let cases = [ + (BGRA8, rgb_color(), packed_planes(4)), + (ARGB2101010, rgb_color(), packed_planes(4)), + (RGBA16_FLOAT, rgb_color(), packed_planes(8)), + ( + YUV420_VIDEO_RANGE, + yuv_color(MacosColorRange::Video), + yuv420_planes(), + ), + ( + YUV420_FULL_RANGE, + yuv_color(MacosColorRange::Full), + yuv420_planes(), + ), + ( + YUV44410_VIDEO_RANGE, + yuv_color(MacosColorRange::Video), + yuv44410_planes(), + ), + ( + YUV44410_FULL_RANGE, + yuv_color(MacosColorRange::Full), + yuv44410_planes(), + ), + ]; + for (fourcc, color, planes) in cases { + let mut sample = complete_sample(); + let frame = complete_frame_mut(&mut sample); + frame.pixel_format_fourcc = fourcc; + frame.color = color; + frame.planes = planes; + frame.surface = surface_for(&frame.planes); + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample); + assert_eq!( + frame.pixel_format, + MacosCapturePixelFormat::from_fourcc(fourcc).expect("format should decode") + ); + } +} + +#[test] +fn unknown_pixel_formats_never_fall_back_to_bgra() { + let mut sample = complete_sample(); + complete_frame_mut(&mut sample).pixel_format_fourcc = u32::from_be_bytes(*b"NOPE"); + assert_eq!( + decode_error(sample), + MacosCaptureError::UnsupportedPixelFormat(u32::from_be_bytes(*b"NOPE")) + ); +} + +#[test] +fn yuv_color_metadata_is_mandatory_and_range_checked() { + let mut missing = complete_sample(); + let frame = complete_frame_mut(&mut missing); + frame.pixel_format_fourcc = YUV420_VIDEO_RANGE; + frame.planes = yuv420_planes(); + frame.surface = surface_for(&frame.planes); + assert_eq!( + decode_error(missing), + MacosCaptureError::MissingYuvColorMetadata + ); + + let mut wrong_range = complete_sample(); + let frame = complete_frame_mut(&mut wrong_range); + frame.pixel_format_fourcc = YUV420_VIDEO_RANGE; + frame.color = yuv_color(MacosColorRange::Full); + frame.planes = yuv420_planes(); + frame.surface = surface_for(&frame.planes); + assert_eq!( + decode_error(wrong_range), + MacosCaptureError::ColorMetadataMismatch + ); + + let mut wrong_444_range = complete_sample(); + let frame = complete_frame_mut(&mut wrong_444_range); + frame.pixel_format_fourcc = YUV44410_VIDEO_RANGE; + frame.color = yuv_color(MacosColorRange::Full); + frame.planes = yuv44410_planes(); + frame.surface = surface_for(&frame.planes); + assert_eq!( + decode_error(wrong_444_range), + MacosCaptureError::ColorMetadataMismatch + ); +} + +#[test] +fn plane_index_extent_stride_length_and_count_are_checked() { + let mut index = complete_sample(); + complete_frame_mut(&mut index).planes[0].index = 1; + assert!(matches!( + MacosFrameDecoder::new(1).decode(index), + Err(MacosCaptureError::InvalidPlaneIndex { .. }) + )); + + let mut extent = complete_sample(); + complete_frame_mut(&mut extent).planes[0].extent = pixel_extent(7, 6); + assert!(matches!( + MacosFrameDecoder::new(1).decode(extent), + Err(MacosCaptureError::InvalidPlaneExtent { .. }) + )); + + let mut stride = complete_sample(); + complete_frame_mut(&mut stride).planes[0].bytes_per_row = 31; + assert!(matches!( + MacosFrameDecoder::new(1).decode(stride), + Err(MacosCaptureError::StrideTooSmall { .. }) + )); + + let mut length = complete_sample(); + complete_frame_mut(&mut length).planes[0].length_bytes = 191; + assert!(matches!( + MacosFrameDecoder::new(1).decode(length), + Err(MacosCaptureError::PlaneLengthTooSmall { .. }) + )); + + let mut count = complete_sample(); + complete_frame_mut(&mut count).planes.clear(); + assert_eq!( + decode_error(count), + MacosCaptureError::PlaneCount { + expected: 1, + actual: 0 + } + ); +} + +#[test] +fn summed_plane_lengths_must_fit_the_iosurface_allocation() { + let mut sample = complete_sample(); + complete_frame_mut(&mut sample).surface = + MacosCaptureSurface::new_fixture(7, 191, 99).expect("fixture surface should be valid"); + assert_eq!( + decode_error(sample), + MacosCaptureError::AllocationTooSmall { + required: 192, + actual: 191 + } + ); +} + +#[test] +fn decoded_frames_keep_the_pixel_buffer_owner_alive() { + let frame = decode_frame(&mut MacosFrameDecoder::new(1), complete_sample()); + let surface = frame.surface.clone(); + assert_eq!(frame.surface.retained_owner_count(), 2); + assert_eq!(surface.fixture_id(), 99); + drop(frame); + assert_eq!(surface.retained_owner_count(), 1); +} + +fn sample_with_status(status: i64) -> MacosRawCaptureSample { + let mut sample = complete_sample(); + sample.attachments.status = MacosAttachment::Value(status); + sample +} + +fn complete_sample() -> MacosRawCaptureSample { + let planes = packed_planes(4); + MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: pixel_extent(8, 6), + surface: surface_for(&planes), + planes, + pixel_format_fourcc: BGRA8, + color: rgb_color(), + cursor_composed: true, + }), + attachments: MacosRawFrameAttachments { + status: MacosAttachment::Value(0), + display_time: MacosAttachment::Value(12_345), + display_scale_factor: MacosAttachment::Value(1.0), + content_scale: MacosAttachment::Value(1.0), + content_rect: MacosAttachment::Value(point_rect(0.0, 0.0, 8.0, 6.0)), + dirty_rects: MacosAttachment::Missing, + screen_rect: MacosAttachment::Missing, + bounding_rect: MacosAttachment::Missing, + }, + } +} + +fn complete_frame_mut(sample: &mut MacosRawCaptureSample) -> &mut MacosRawCompleteFrame { + sample.frame.as_mut().expect("fixture frame should exist") +} + +fn decode_frame( + decoder: &mut MacosFrameDecoder, + sample: MacosRawCaptureSample, +) -> hypercolor_macos_capture::MacosCaptureFrame { + match decoder.decode(sample).expect("sample should decode") { + MacosFrameEvent::Frame(frame) => *frame, + MacosFrameEvent::Lifecycle(status) => panic!("expected frame, got {status:?}"), + } +} + +fn decode_error(sample: MacosRawCaptureSample) -> MacosCaptureError { + MacosFrameDecoder::new(1) + .decode(sample) + .expect_err("fixture should fail validation") +} + +fn packed_planes(bytes_per_pixel: usize) -> Vec { + let stride = 8 * bytes_per_pixel; + vec![MacosRawCapturePlane { + index: 0, + extent: pixel_extent(8, 6), + bytes_per_row: stride, + length_bytes: (stride * 6) as u64, + }] +} + +fn yuv420_planes() -> Vec { + vec![ + MacosRawCapturePlane { + index: 0, + extent: pixel_extent(8, 6), + bytes_per_row: 8, + length_bytes: 48, + }, + MacosRawCapturePlane { + index: 1, + extent: pixel_extent(4, 3), + bytes_per_row: 8, + length_bytes: 24, + }, + ] +} + +fn yuv44410_planes() -> Vec { + vec![ + MacosRawCapturePlane { + index: 0, + extent: pixel_extent(8, 6), + bytes_per_row: 16, + length_bytes: 96, + }, + MacosRawCapturePlane { + index: 1, + extent: pixel_extent(8, 6), + bytes_per_row: 32, + length_bytes: 192, + }, + ] +} + +fn surface_for(planes: &[MacosRawCapturePlane]) -> MacosCaptureSurface { + let allocation = planes.iter().map(|plane| plane.length_bytes).sum(); + MacosCaptureSurface::new_fixture(7, allocation, 99).expect("fixture surface should be valid") +} + +fn rgb_color() -> MacosCaptureColorimetry { + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + } +} + +fn yuv_color(range: MacosColorRange) -> MacosCaptureColorimetry { + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range, + chroma_location: Some(MacosChromaLocation::Left), + } +} + +fn pixel_extent(width: u32, height: u32) -> MacosPixelExtent { + MacosPixelExtent::new(width, height).expect("fixture extent should be valid") +} + +fn pixel_rect(x: i64, y: i64, width: u32, height: u32) -> MacosPixelRect { + MacosPixelRect::new(x, y, width, height).expect("fixture pixel rect should be valid") +} + +fn point_rect(x: f64, y: f64, width: f64, height: f64) -> MacosPointRect { + MacosPointRect::new(x, y, width, height).expect("fixture point rect should be valid") +} From 52ec1ecf64f2b481e78a6e9386df1ec9e1f37712 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 17:29:23 -0700 Subject: [PATCH 020/144] refactor(macos): isolate Servo GPU context 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) --- crates/hypercolor-core/Cargo.toml | 1 + .../hypercolor-macos-gpu-interop/Cargo.toml | 37 ++++++++++++++----- .../hypercolor-macos-gpu-interop/src/lib.rs | 4 +- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/hypercolor-core/Cargo.toml b/crates/hypercolor-core/Cargo.toml index 21ade36f1..6f38c7825 100644 --- a/crates/hypercolor-core/Cargo.toml +++ b/crates/hypercolor-core/Cargo.toml @@ -31,6 +31,7 @@ servo = [ servo-gpu-import = [ "servo", "hypercolor-linux-gpu-interop?/servo-context", + "hypercolor-macos-gpu-interop?/servo-context", "hypercolor-windows-gpu-interop?/servo-context", "dep:hypercolor-linux-gpu-interop", "dep:hypercolor-macos-gpu-interop", diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index b4bac9660..9e922a77e 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -10,32 +10,49 @@ description = "macOS IOSurface/Metal texture import boundary for Hypercolor" [features] default = [] +servo-context = [ + "dep:cgl", + "dep:dpi", + "dep:euclid", + "dep:gleam", + "dep:glow", + "dep:image", + "dep:paint_api", + "dep:surfman", + "dep:tracing", + "dep:webrender_api", +] [dependencies] thiserror = { workspace = true } wgpu = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] -cgl = "0.3.2" -dpi = { workspace = true } -euclid = "0.22" -gleam = "0.15" -glow = { workspace = true } -image = { workspace = true } +cgl = { version = "0.3.2", optional = true } +dpi = { workspace = true, optional = true } +euclid = { version = "0.22", optional = true } +gleam = { version = "0.15", optional = true } +glow = { workspace = true, optional = true } +image = { workspace = true, optional = true } libc = { workspace = true } objc2 = { workspace = true, features = ["std"] } objc2-core-foundation = { workspace = true, features = ["std", "CFDictionary", "CFNumber", "CFString"] } objc2-io-surface = { workspace = true, features = ["std", "IOSurfaceRef", "IOSurfaceTypes", "objc2-core-foundation", "libc", "bitflags"] } objc2-metal = { workspace = true, features = ["std", "MTLAllocation", "MTLDevice", "MTLPixelFormat", "MTLResource", "MTLTexture", "objc2-io-surface"] } -paint_api = { workspace = true } -surfman = { workspace = true } -tracing = { workspace = true } -webrender_api = { workspace = true } +paint_api = { workspace = true, optional = true } +surfman = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +webrender_api = { workspace = true, optional = true } wgpu-hal = { workspace = true, features = ["metal"] } [dev-dependencies] pollster = { workspace = true } +[[test]] +name = "servo_context_tests" +path = "tests/servo_context_tests.rs" +required-features = ["servo-context"] + [lints.rust] unsafe_code = "allow" diff --git a/crates/hypercolor-macos-gpu-interop/src/lib.rs b/crates/hypercolor-macos-gpu-interop/src/lib.rs index 2a6632f4f..04d1ccb2a 100644 --- a/crates/hypercolor-macos-gpu-interop/src/lib.rs +++ b/crates/hypercolor-macos-gpu-interop/src/lib.rs @@ -4,14 +4,14 @@ #[cfg(target_os = "macos")] mod macos; -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", feature = "servo-context"))] mod servo_context; #[cfg(not(target_os = "macos"))] mod stubs; #[cfg(target_os = "macos")] pub use macos::*; -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", feature = "servo-context"))] pub use servo_context::*; #[cfg(not(target_os = "macos"))] pub use stubs::*; From 6ee5a5a41e920a390f78b42f8d80f4dfc0c04a42 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 17:58:26 -0700 Subject: [PATCH 021/144] feat(macos): implement ScreenCaptureKit acquisition 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) --- Cargo.lock | 71 +- crates/hypercolor-macos-capture/Cargo.toml | 61 + .../src/diagnostics.rs | 119 ++ crates/hypercolor-macos-capture/src/frame.rs | 96 +- crates/hypercolor-macos-capture/src/lib.rs | 11 + .../hypercolor-macos-capture/src/mailbox.rs | 45 + crates/hypercolor-macos-capture/src/native.rs | 1184 +++++++++++++++++ .../hypercolor-macos-capture/src/session.rs | 47 + .../tests/capture_contract_tests.rs | 62 +- 9 files changed, 1669 insertions(+), 27 deletions(-) create mode 100644 crates/hypercolor-macos-capture/src/diagnostics.rs create mode 100644 crates/hypercolor-macos-capture/src/mailbox.rs create mode 100644 crates/hypercolor-macos-capture/src/native.rs create mode 100644 crates/hypercolor-macos-capture/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index 3fef8cdf3..812e5d51e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -317,7 +317,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2757,7 +2757,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2789,7 +2789,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -3195,7 +3195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4020,7 +4020,7 @@ dependencies = [ "gobject-sys 0.22.6", "libc", "system-deps 7.0.7", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5319,6 +5319,16 @@ dependencies = [ name = "hypercolor-macos-capture" version = "0.3.1" dependencies = [ + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-io-surface", + "objc2-screen-capture-kit", "thiserror 2.0.18", ] @@ -7884,7 +7894,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8010,7 +8020,7 @@ dependencies = [ "rustix 1.1.4", "slab", "tokio", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8241,6 +8251,21 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -8385,6 +8410,22 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -10708,7 +10749,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10766,7 +10807,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13096,7 +13137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -14110,10 +14151,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -15025,7 +15066,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -16302,7 +16343,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml index 260def822..e6137c5e8 100644 --- a/crates/hypercolor-macos-capture/Cargo.toml +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -22,6 +22,67 @@ capture-fixtures = [] [dependencies] thiserror = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6.2" +dispatch2 = "0.3.1" +objc2 = { workspace = true, features = ["std"] } +objc2-core-foundation = { workspace = true, features = [ + "std", + "CFArray", + "CFCGTypes", + "CFDictionary", + "CFNumber", + "CFString", + "objc2", +] } +objc2-core-graphics = { workspace = true, features = ["std", "CGGeometry", "CGWindow"] } +objc2-core-media = { workspace = true, features = [ + "std", + "CMSampleBuffer", + "CMTime", + "objc2", + "objc2-core-video", +] } +objc2-core-video = { workspace = true, features = [ + "std", + "CVBase", + "CVBuffer", + "CVImageBuffer", + "CVPixelBuffer", + "CVPixelBufferIOSurface", + "CVReturn", + "objc2", + "objc2-io-surface", +] } +objc2-foundation = { workspace = true, features = [ + "std", + "NSArray", + "NSError", + "NSGeometry", + "NSObject", + "NSString", + "NSValue", + "objc2-core-foundation", +] } +objc2-io-surface = { workspace = true, features = [ + "std", + "IOSurfaceRef", + "IOSurfaceTypes", + "objc2-core-foundation", +] } +objc2-screen-capture-kit = { workspace = true, features = [ + "std", + "block2", + "dispatch2", + "SCContentSharingPicker", + "SCError", + "SCShareableContent", + "SCStream", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", +] } + [[test]] name = "capture_contract_tests" path = "tests/capture_contract_tests.rs" diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs new file mode 100644 index 000000000..d47e7e68b --- /dev/null +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -0,0 +1,119 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::MacosCaptureError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(usize)] +pub enum MacosFrameDropReason { + InvalidSample = 0, + DataNotReady = 1, + UnexpectedOutput = 2, + Attachment = 3, + UnsupportedFormat = 4, + ColorMetadata = 5, + Surface = 6, + Validation = 7, +} + +impl MacosFrameDropReason { + pub const ALL: [Self; 8] = [ + Self::InvalidSample, + Self::DataNotReady, + Self::UnexpectedOutput, + Self::Attachment, + Self::UnsupportedFormat, + Self::ColorMetadata, + Self::Surface, + Self::Validation, + ]; + + pub(crate) const fn from_error(error: &MacosCaptureError) -> Self { + match error { + MacosCaptureError::InvalidSampleBuffer => Self::InvalidSample, + MacosCaptureError::SampleDataNotReady => Self::DataNotReady, + MacosCaptureError::UnexpectedStreamOutputType(_) => Self::UnexpectedOutput, + MacosCaptureError::MissingFrameAttachments + | MacosCaptureError::MissingAttachment(_) + | MacosCaptureError::MalformedAttachment(_) + | MacosCaptureError::UnknownFrameStatus(_) => Self::Attachment, + MacosCaptureError::UnsupportedPixelFormat(_) => Self::UnsupportedFormat, + MacosCaptureError::ColorMetadataMismatch + | MacosCaptureError::MissingYuvColorMetadata + | MacosCaptureError::MissingColorAttachment(_) + | MacosCaptureError::UnsupportedColorAttachment(_) => Self::ColorMetadata, + MacosCaptureError::MissingFramePayload + | MacosCaptureError::InvalidSurface + | MacosCaptureError::MissingIoSurface => Self::Surface, + MacosCaptureError::InvalidCadence(_) + | MacosCaptureError::NotMainThread + | MacosCaptureError::ScreenCapturePermissionRequired + | MacosCaptureError::NativeOperation { .. } + | MacosCaptureError::PlaneCount { .. } + | MacosCaptureError::InvalidPlaneIndex { .. } + | MacosCaptureError::InvalidPlaneExtent { .. } + | MacosCaptureError::StrideTooSmall { .. } + | MacosCaptureError::PlaneLengthTooSmall { .. } + | MacosCaptureError::ArithmeticOverflow + | MacosCaptureError::AllocationTooSmall { .. } + | MacosCaptureError::GeometryOutsideStorage(_) + | MacosCaptureError::SequenceExhausted + | MacosCaptureError::Geometry(_) => Self::Validation, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MacosCaptureCallbackDiagnostics { + pub frames_received: u64, + pub frames_published: u64, + pub lifecycle_events: u64, + pub superseded_deliveries: u64, + dropped: [u64; MacosFrameDropReason::ALL.len()], +} + +impl MacosCaptureCallbackDiagnostics { + pub const fn dropped(self, reason: MacosFrameDropReason) -> u64 { + self.dropped[reason as usize] + } + + pub fn total_dropped(self) -> u64 { + self.dropped.into_iter().sum() + } +} + +#[derive(Debug, Default)] +pub(crate) struct CallbackCounters { + frames_received: AtomicU64, + frames_published: AtomicU64, + lifecycle_events: AtomicU64, + dropped: [AtomicU64; MacosFrameDropReason::ALL.len()], +} + +impl CallbackCounters { + pub(crate) fn record_received(&self) { + self.frames_received.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_published(&self) { + self.frames_published.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_lifecycle(&self) { + self.lifecycle_events.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_drop(&self, error: &MacosCaptureError) { + self.dropped[MacosFrameDropReason::from_error(error) as usize] + .fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn snapshot(&self, superseded_deliveries: u64) -> MacosCaptureCallbackDiagnostics { + MacosCaptureCallbackDiagnostics { + frames_received: self.frames_received.load(Ordering::Relaxed), + frames_published: self.frames_published.load(Ordering::Relaxed), + lifecycle_events: self.lifecycle_events.load(Ordering::Relaxed), + superseded_deliveries, + dropped: std::array::from_fn(|index| self.dropped[index].load(Ordering::Relaxed)), + } + } +} diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 2863656f3..5f9a31644 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -1,6 +1,10 @@ use std::fmt; use std::sync::Arc; +#[cfg(target_os = "macos")] +use objc2_core_foundation::CFRetained; +#[cfg(target_os = "macos")] +use objc2_core_video::{CVPixelBuffer, CVPixelBufferGetIOSurface}; use thiserror::Error; use crate::geometry::{ @@ -121,6 +125,8 @@ pub enum MacosColorPrimaries { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MacosTransferFunction { Srgb, + Rec709, + Rec2020, Linear, Pq, Hlg, @@ -223,7 +229,28 @@ impl MacosCaptureSurface { Ok(Self { iosurface_id, allocation_bytes, - owner: Arc::new(MacosRetainedPixelBuffer { fixture_id }), + owner: Arc::new(MacosRetainedPixelBuffer::Fixture { fixture_id }), + }) + } + + #[cfg(target_os = "macos")] + pub(crate) fn from_pixel_buffer( + pixel_buffer: CFRetained, + ) -> Result { + let iosurface = CVPixelBufferGetIOSurface(Some(&pixel_buffer)) + .ok_or(MacosCaptureError::MissingIoSurface)?; + let allocation_bytes = u64::try_from(iosurface.alloc_size()) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let iosurface_id = iosurface.id(); + if iosurface_id == 0 || allocation_bytes == 0 { + return Err(MacosCaptureError::InvalidSurface); + } + Ok(Self { + iosurface_id, + allocation_bytes, + owner: Arc::new(MacosRetainedPixelBuffer::Native { + _pixel_buffer: pixel_buffer, + }), }) } @@ -232,8 +259,12 @@ impl MacosCaptureSurface { } #[cfg(feature = "capture-fixtures")] - pub fn fixture_id(&self) -> u64 { - self.owner.fixture_id + pub fn fixture_id(&self) -> Option { + match &*self.owner { + MacosRetainedPixelBuffer::Fixture { fixture_id } => Some(*fixture_id), + #[cfg(target_os = "macos")] + MacosRetainedPixelBuffer::Native { .. } => None, + } } } @@ -247,12 +278,39 @@ impl fmt::Debug for MacosCaptureSurface { } } -#[derive(Debug)] -struct MacosRetainedPixelBuffer { +enum MacosRetainedPixelBuffer { + #[cfg(target_os = "macos")] + Native { + _pixel_buffer: CFRetained, + }, #[cfg(feature = "capture-fixtures")] - fixture_id: u64, + Fixture { fixture_id: u64 }, } +impl fmt::Debug for MacosRetainedPixelBuffer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(target_os = "macos")] + Self::Native { .. } => formatter.write_str("MacosRetainedPixelBuffer::Native"), + #[cfg(feature = "capture-fixtures")] + Self::Fixture { fixture_id } => formatter + .debug_struct("MacosRetainedPixelBuffer::Fixture") + .field("fixture_id", fixture_id) + .finish(), + } + } +} + +#[cfg(target_os = "macos")] +// SAFETY: Core Video pixel buffers are reference-counted, immutable while +// published, and coordinate CPU access through CVPixelBufferLockBaseAddress. +unsafe impl Send for MacosRetainedPixelBuffer {} + +#[cfg(target_os = "macos")] +// SAFETY: Concurrent owners only retain or inspect metadata; mutable byte +// access is serialized by Core Video's lock contract. +unsafe impl Sync for MacosRetainedPixelBuffer {} + #[derive(Debug, Clone)] pub struct MacosCaptureFrame { pub epoch: u64, @@ -525,6 +583,26 @@ fn optional( #[derive(Debug, Clone, PartialEq, Error)] pub enum MacosCaptureError { + #[error("Core Media sample buffer is invalid")] + InvalidSampleBuffer, + #[error("Core Media sample data is not ready")] + SampleDataNotReady, + #[error("unexpected ScreenCaptureKit output type {0}")] + UnexpectedStreamOutputType(isize), + #[error("capture cadence {0} cannot be represented by Core Media")] + InvalidCadence(u32), + #[error("ScreenCaptureKit UI must be controlled from the main thread")] + NotMainThread, + #[error("screen-capture authorization requires explicit user action")] + ScreenCapturePermissionRequired, + #[error("{operation} failed with native error {code}: {message}")] + NativeOperation { + operation: &'static str, + code: isize, + message: String, + }, + #[error("sample buffer has no ScreenCaptureKit attachment dictionary")] + MissingFrameAttachments, #[error("missing ScreenCaptureKit attachment: {0}")] MissingAttachment(&'static str), #[error("malformed ScreenCaptureKit attachment: {0}")] @@ -565,10 +643,16 @@ pub enum MacosCaptureError { ColorMetadataMismatch, #[error("YUV frames require matrix and chroma-location metadata")] MissingYuvColorMetadata, + #[error("missing Core Video color attachment: {0}")] + MissingColorAttachment(&'static str), + #[error("unsupported Core Video color attachment: {0}")] + UnsupportedColorAttachment(&'static str), #[error("{0} exceeds pixel storage")] GeometryOutsideStorage(&'static str), #[error("IOSurface identity and allocation must be nonzero")] InvalidSurface, + #[error("complete frame has no IOSurface-backed pixel buffer")] + MissingIoSurface, #[error("complete-frame sequence exhausted")] SequenceExhausted, #[error(transparent)] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 84db68ef4..1cb96f1f9 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -3,9 +3,18 @@ //! Native framework ownership remains private to this crate. The public frame //! boundary contains only plain Rust metadata plus an opaque retained surface. +mod diagnostics; mod frame; mod geometry; +mod mailbox; +#[cfg(target_os = "macos")] +mod native; +mod session; +#[cfg(target_os = "macos")] +pub use native::MacosScreenCaptureSession; + +pub use diagnostics::{MacosCaptureCallbackDiagnostics, MacosFrameDropReason}; pub use frame::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, MacosCapturePlane, MacosCaptureSurface, @@ -17,3 +26,5 @@ pub use geometry::{ MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, }; +pub use mailbox::MacosFrameMailbox; +pub use session::{MacosCaptureCadence, MacosStreamRequest}; diff --git a/crates/hypercolor-macos-capture/src/mailbox.rs b/crates/hypercolor-macos-capture/src/mailbox.rs new file mode 100644 index 000000000..9923af8af --- /dev/null +++ b/crates/hypercolor-macos-capture/src/mailbox.rs @@ -0,0 +1,45 @@ +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::{MacosCaptureError, MacosFrameEvent}; + +#[derive(Debug, Clone, Default)] +pub struct MacosFrameMailbox { + state: Arc>, +} + +#[derive(Debug, Default)] +struct MailboxState { + latest: Option>, + superseded: u64, +} + +impl MacosFrameMailbox { + pub fn new() -> Self { + Self::default() + } + + pub fn publish(&self, delivery: Result) { + let mut state = self.lock(); + if state.latest.replace(delivery).is_some() { + state.superseded = state.superseded.saturating_add(1); + } + } + + pub fn take_latest(&self) -> Option> { + self.lock().latest.take() + } + + pub fn has_pending(&self) -> bool { + self.lock().latest.is_some() + } + + pub fn superseded_count(&self) -> u64 { + self.lock().superseded + } + + fn lock(&self) -> MutexGuard<'_, MailboxState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs new file mode 100644 index 000000000..d1d3289a1 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -0,0 +1,1184 @@ +use std::cell::RefCell; +use std::fmt; +use std::ptr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; + +use block2::RcBlock; +use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained}; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; +use objc2_core_foundation::{ + CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType, CGPoint, CGRect, CGSize, +}; +use objc2_core_graphics::{ + CGPreflightScreenCaptureAccess, CGRectMakeWithDictionaryRepresentation, + CGRequestScreenCaptureAccess, +}; +use objc2_core_media::{CMSampleBuffer, CMTime}; +use objc2_core_video::{ + CVBuffer, CVPixelBuffer, CVPixelBufferGetBytesPerRow, CVPixelBufferGetBytesPerRowOfPlane, + CVPixelBufferGetDataSize, CVPixelBufferGetHeight, CVPixelBufferGetHeightOfPlane, + CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, CVPixelBufferGetWidth, + CVPixelBufferGetWidthOfPlane, kCVImageBufferChromaLocation_Center, + kCVImageBufferChromaLocation_Left, kCVImageBufferChromaLocation_TopLeft, + kCVImageBufferChromaLocationTopFieldKey, kCVImageBufferColorPrimaries_ITU_R_709_2, + kCVImageBufferColorPrimaries_ITU_R_2020, kCVImageBufferColorPrimaries_P3_D65, + kCVImageBufferColorPrimariesKey, kCVImageBufferTransferFunction_ITU_R_709_2, + kCVImageBufferTransferFunction_ITU_R_2020, kCVImageBufferTransferFunction_ITU_R_2100_HLG, + kCVImageBufferTransferFunction_Linear, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, + kCVImageBufferTransferFunction_sRGB, kCVImageBufferTransferFunctionKey, + kCVImageBufferYCbCrMatrix_ITU_R_601_4, kCVImageBufferYCbCrMatrix_ITU_R_709_2, + kCVImageBufferYCbCrMatrix_ITU_R_2020, kCVImageBufferYCbCrMatrixKey, +}; +use objc2_foundation::{NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSValue}; +use objc2_screen_capture_kit::{ + SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, + SCContentSharingPickerConfiguration, SCContentSharingPickerMode, + SCContentSharingPickerObserver, SCStream, SCStreamConfiguration, SCStreamDelegate, + SCStreamErrorCode, SCStreamErrorDomain, SCStreamFrameInfoBoundingRect, + SCStreamFrameInfoContentRect, SCStreamFrameInfoContentScale, SCStreamFrameInfoDirtyRects, + SCStreamFrameInfoDisplayTime, SCStreamFrameInfoScaleFactor, SCStreamFrameInfoScreenRect, + SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, +}; + +use crate::diagnostics::CallbackCounters; +use crate::{ + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, + MacosCaptureColorimetry, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSurface, + MacosChromaLocation, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, + MacosFrameMailbox, MacosFrameStatus, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, + MacosRawFrameAttachments, MacosScale, MacosStreamRequest, MacosTransferFunction, + MacosYuvMatrix, +}; + +#[derive(Debug)] +struct SessionShared { + mailbox: MacosFrameMailbox, + status: Mutex, + counters: CallbackCounters, + current_epoch: AtomicU64, +} + +impl SessionShared { + fn new(status: MacosProtectedSourceState) -> Self { + Self { + mailbox: MacosFrameMailbox::new(), + status: Mutex::new(status), + counters: CallbackCounters::default(), + current_epoch: AtomicU64::new(0), + } + } + + fn status(&self) -> MacosProtectedSourceState { + *lock(&self.status) + } + + fn set_status(&self, status: MacosProtectedSourceState) { + *lock(&self.status) = status; + } + + fn current_epoch(&self) -> u64 { + self.current_epoch.load(Ordering::Acquire) + } + + fn activate_epoch(&self, epoch: u64) { + self.current_epoch.store(epoch, Ordering::Release); + } + + fn publish(&self, event: MacosFrameEvent) { + let status = match &event { + MacosFrameEvent::Frame(_) => { + self.counters.record_published(); + MacosProtectedSourceState::Live + } + MacosFrameEvent::Lifecycle(MacosFrameStatus::Started) => { + self.counters.record_lifecycle(); + MacosProtectedSourceState::Starting + } + MacosFrameEvent::Lifecycle(MacosFrameStatus::Suspended) + | MacosFrameEvent::Lifecycle(MacosFrameStatus::Stopped) => { + self.counters.record_lifecycle(); + MacosProtectedSourceState::Interrupted + } + MacosFrameEvent::Lifecycle(_) => { + self.counters.record_lifecycle(); + MacosProtectedSourceState::Live + } + }; + self.set_status(status); + self.mailbox.publish(Ok(event)); + } + + fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + self.counters.snapshot(self.mailbox.superseded_count()) + } + + fn publish_error(&self, error: MacosCaptureError) { + self.mailbox.publish(Err(error)); + } +} + +#[derive(Debug)] +struct CaptureOutputIvars { + decoder: Mutex, + shared: Arc, + streams: Weak, + epoch: u64, + cursor_composed: bool, + display_filter: bool, +} + +define_class!( + #[unsafe(super(NSObject))] + #[name = "HypercolorScreenCaptureOutput"] + #[ivars = CaptureOutputIvars] + struct CaptureOutput; + + unsafe impl NSObjectProtocol for CaptureOutput {} + + unsafe impl SCStreamOutput for CaptureOutput { + #[allow(non_snake_case)] + #[unsafe(method(stream:didOutputSampleBuffer:ofType:))] + fn stream_didOutputSampleBuffer_ofType( + &self, + _stream: &SCStream, + sample_buffer: &CMSampleBuffer, + output_type: SCStreamOutputType, + ) { + self.ivars().shared.counters.record_received(); + let result = if output_type == SCStreamOutputType::Screen { + let mut decoder = lock(&self.ivars().decoder); + decode_sample(&mut decoder, sample_buffer, self.ivars().cursor_composed) + } else { + Err(MacosCaptureError::UnexpectedStreamOutputType(output_type.0)) + }; + match result { + Ok(MacosFrameEvent::Frame(frame)) => { + let active = self.ivars().shared.current_epoch() == self.ivars().epoch + || self + .ivars() + .streams + .upgrade() + .is_some_and(|streams| streams.activate(self.ivars().epoch)); + if active { + self.ivars().shared.publish(MacosFrameEvent::Frame(frame)); + } + } + Ok(event) if self.ivars().shared.current_epoch() == self.ivars().epoch => { + self.ivars().shared.publish(event); + } + Ok(_) => {} + Err(error) => self.ivars().shared.counters.record_drop(&error), + } + } + } + + unsafe impl SCStreamDelegate for CaptureOutput { + #[allow(non_snake_case)] + #[unsafe(method(stream:didStopWithError:))] + fn stream_didStopWithError(&self, _stream: &SCStream, error: &NSError) { + handle_stream_error( + &self.ivars().streams, + self.ivars().epoch, + &self.ivars().shared, + error, + ); + } + + #[allow(non_snake_case)] + #[unsafe(method(streamDidBecomeActive:))] + fn streamDidBecomeActive(&self, _stream: &SCStream) { + if !self.ivars().display_filter + && self.ivars().shared.current_epoch() == self.ivars().epoch + { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::Live); + } + } + + #[allow(non_snake_case)] + #[unsafe(method(streamDidBecomeInactive:))] + fn streamDidBecomeInactive(&self, _stream: &SCStream) { + if !self.ivars().display_filter + && self.ivars().shared.current_epoch() == self.ivars().epoch + { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::NeedsSelection); + } + } + } +); + +impl CaptureOutput { + fn new( + epoch: u64, + shared: Arc, + streams: Weak, + cursor_composed: bool, + display_filter: bool, + ) -> Retained { + let this = Self::alloc().set_ivars(CaptureOutputIvars { + decoder: Mutex::new(MacosFrameDecoder::new(epoch)), + shared, + streams, + epoch, + cursor_composed, + display_filter, + }); + // SAFETY: NSObject has no additional initialization requirements for + // this callback subclass. + unsafe { msg_send![super(this), init] } + } +} + +struct NativeStream { + stream: Retained, + _output: Retained, + _queue: DispatchRetained, +} + +// SAFETY: ScreenCaptureKit owns callback execution across its queues, and all +// Rust access to this owner is serialized through StreamSlot. NativeStream is +// moved between owners but never exposes concurrent mutable Objective-C state. +unsafe impl Send for NativeStream {} + +impl NativeStream { + fn prepare( + filter: &SCContentFilter, + request: MacosStreamRequest, + epoch: u64, + shared: Arc, + streams: Weak, + ) -> Result { + let (configuration, display_filter) = stream_configuration(filter, request)?; + let output = CaptureOutput::new( + epoch, + shared, + streams, + request.cursor_composed, + display_filter, + ); + let delegate: &ProtocolObject = ProtocolObject::from_ref(&*output); + // SAFETY: The filter, configuration, and delegate remain retained by + // the returned stream and NativeStream owner. + let stream = unsafe { + SCStream::initWithFilter_configuration_delegate( + SCStream::alloc(), + filter, + &configuration, + Some(delegate), + ) + }; + let queue = DispatchQueue::new( + "tech.hyperbliss.hypercolor.screen-capture", + DispatchQueueAttr::SERIAL, + ); + let protocol: &ProtocolObject = ProtocolObject::from_ref(&*output); + // SAFETY: The protocol object and serial queue outlive their stream + // registration through the NativeStream owner. + unsafe { + stream + .addStreamOutput_type_sampleHandlerQueue_error( + protocol, + SCStreamOutputType::Screen, + Some(&queue), + ) + .map_err(|error| native_error("add ScreenCaptureKit output", &error))?; + } + Ok(Self { + stream, + _output: output, + _queue: queue, + }) + } + + fn stop(&self) { + // SAFETY: Stopping an owned SCStream without a completion callback is + // valid and retains no borrowed Rust state. + unsafe { self.stream.stopCaptureWithCompletionHandler(None) }; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StreamRole { + Current, + Candidate, + Stale, +} + +#[derive(Default)] +struct StreamState { + current: Option, + candidate: Option, +} + +struct StreamSlot { + state: Mutex, + shared: Arc, +} + +impl StreamSlot { + fn new(shared: Arc) -> Arc { + Arc::new(Self { + state: Mutex::new(StreamState::default()), + shared, + }) + } + + fn stage_candidate( + self: &Arc, + filter: &SCContentFilter, + request: MacosStreamRequest, + epoch: u64, + ) -> Result<(), MacosCaptureError> { + let candidate = NativeStream::prepare( + filter, + request, + epoch, + Arc::clone(&self.shared), + Arc::downgrade(self), + )?; + let stream = candidate.stream.clone(); + let replaced = lock(&self.state).candidate.replace(candidate); + if let Some(replaced) = replaced { + replaced.stop(); + } + self.shared.set_status(MacosProtectedSourceState::Starting); + start_stream( + &stream, + epoch, + Arc::downgrade(self), + Arc::clone(&self.shared), + ); + Ok(()) + } + + fn activate(&self, epoch: u64) -> bool { + let previous = { + let mut state = lock(&self.state); + let Some(candidate) = state + .candidate + .take_if(|candidate| candidate._output.ivars().epoch == epoch) + else { + return false; + }; + let previous = state.current.replace(candidate); + self.shared.activate_epoch(epoch); + previous + }; + if let Some(previous) = previous { + previous.stop(); + } + true + } + + fn remove(&self, epoch: u64) -> StreamRole { + let mut state = lock(&self.state); + if state + .candidate + .as_ref() + .is_some_and(|candidate| candidate._output.ivars().epoch == epoch) + { + state.candidate.take(); + return StreamRole::Candidate; + } + if state + .current + .as_ref() + .is_some_and(|current| current._output.ivars().epoch == epoch) + { + state.current.take(); + self.shared.activate_epoch(0); + return StreamRole::Current; + } + StreamRole::Stale + } + + fn has_current(&self) -> bool { + lock(&self.state).current.is_some() + } + + fn current_stream(&self) -> Option> { + lock(&self.state) + .current + .as_ref() + .map(|current| current.stream.clone()) + } + + fn stop(&self) { + let (current, candidate) = { + let mut state = lock(&self.state); + (state.current.take(), state.candidate.take()) + }; + self.shared.activate_epoch(0); + if let Some(candidate) = candidate { + candidate.stop(); + } + if let Some(current) = current { + current.stop(); + } + } +} + +fn start_stream( + stream: &SCStream, + epoch: u64, + streams: Weak, + shared: Arc, +) { + let completion = RcBlock::new(move |error: *mut NSError| { + // SAFETY: ScreenCaptureKit supplies either null or a live NSError for + // the duration of this completion invocation. + if let Some(error) = unsafe { error.as_ref() } { + handle_stream_error(&streams, epoch, &shared, error); + } + }); + // SAFETY: ScreenCaptureKit copies the heap block for asynchronous use, and + // the stream remains retained by StreamSlot until activation or failure. + unsafe { stream.startCaptureWithCompletionHandler(Some(&completion)) }; +} + +fn handle_stream_error( + streams: &Weak, + epoch: u64, + shared: &SessionShared, + error: &NSError, +) { + let role = streams + .upgrade() + .map_or(StreamRole::Stale, |streams| streams.remove(epoch)); + match role { + StreamRole::Candidate + if streams + .upgrade() + .is_some_and(|streams| streams.has_current()) => + { + shared.set_status(MacosProtectedSourceState::Live); + } + StreamRole::Candidate | StreamRole::Current => { + shared.set_status(classify_stream_error(error)); + } + StreamRole::Stale => return, + } + shared.publish_error(native_error("ScreenCaptureKit stream", error)); +} + +struct PickerObserverIvars { + shared: Arc, + streams: Arc, + request: MacosStreamRequest, + next_epoch: RefCell, +} + +define_class!( + #[unsafe(super(NSObject))] + #[name = "HypercolorContentSharingPickerObserver"] + #[thread_kind = MainThreadOnly] + #[ivars = PickerObserverIvars] + struct PickerObserver; + + unsafe impl NSObjectProtocol for PickerObserver {} + + unsafe impl SCContentSharingPickerObserver for PickerObserver { + #[allow(non_snake_case)] + #[unsafe(method(contentSharingPicker:didCancelForStream:))] + fn contentSharingPicker_didCancelForStream( + &self, + _picker: &SCContentSharingPicker, + _stream: Option<&SCStream>, + ) { + if !self.ivars().streams.has_current() { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::NeedsSelection); + } + } + + #[allow(non_snake_case)] + #[unsafe(method(contentSharingPicker:didUpdateWithFilter:forStream:))] + fn contentSharingPicker_didUpdateWithFilter_forStream( + &self, + _picker: &SCContentSharingPicker, + filter: &SCContentFilter, + _stream: Option<&SCStream>, + ) { + self.install_filter(filter); + } + + #[allow(non_snake_case)] + #[unsafe(method(contentSharingPickerStartDidFailWithError:))] + fn contentSharingPickerStartDidFailWithError(&self, error: &NSError) { + if !self.ivars().streams.has_current() { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::Failed); + } + self.ivars() + .shared + .publish_error(native_error("ScreenCaptureKit picker", error)); + } + } +); + +impl PickerObserver { + fn new( + mtm: MainThreadMarker, + request: MacosStreamRequest, + shared: Arc, + ) -> Retained { + let streams = StreamSlot::new(Arc::clone(&shared)); + let this = mtm.alloc::().set_ivars(PickerObserverIvars { + shared, + streams, + request, + next_epoch: RefCell::new(1), + }); + // SAFETY: NSObject has no additional initialization requirements for + // this main-thread observer subclass. + unsafe { msg_send![super(this), init] } + } + + fn install_filter(&self, filter: &SCContentFilter) { + let epoch = *self.ivars().next_epoch.borrow(); + let result = epoch + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted) + .and_then(|next_epoch| { + *self.ivars().next_epoch.borrow_mut() = next_epoch; + self.ivars() + .streams + .stage_candidate(filter, self.ivars().request, epoch) + }); + if let Err(error) = result { + let status = if self.ivars().streams.has_current() { + MacosProtectedSourceState::Live + } else { + MacosProtectedSourceState::Failed + }; + self.ivars().shared.set_status(status); + self.ivars().shared.publish_error(error); + } + } + + fn present(&self, picker: &SCContentSharingPicker) { + if let Some(stream) = self.ivars().streams.current_stream() { + // SAFETY: The stream is owned by this observer for the duration of + // picker presentation. + unsafe { picker.presentPickerForStream(&stream) }; + } else { + // SAFETY: The public session action is an explicit local request + // to present Apple's system picker. + unsafe { picker.present() }; + } + } + + fn stop(&self) { + self.ivars().streams.stop(); + } +} + +pub struct MacosScreenCaptureSession { + picker: Retained, + observer: Retained, + shared: Arc, +} + +impl MacosScreenCaptureSession { + pub fn new(request: MacosStreamRequest) -> Result { + request.cadence.timescale()?; + let mtm = MainThreadMarker::new().ok_or(MacosCaptureError::NotMainThread)?; + let status = if CGPreflightScreenCaptureAccess() { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::NeedsUserAction + }; + let shared = Arc::new(SessionShared::new(status)); + let observer = PickerObserver::new(mtm, request, Arc::clone(&shared)); + // SAFETY: These are main-thread ScreenCaptureKit setup calls. The + // observer remains retained by this session until it is removed. + let picker = unsafe { + let picker = SCContentSharingPicker::sharedPicker(); + let configuration: Retained = + SCContentSharingPickerConfiguration::new(); + configuration.setAllowedPickerModes( + SCContentSharingPickerMode::SingleWindow + | SCContentSharingPickerMode::MultipleWindows + | SCContentSharingPickerMode::SingleApplication + | SCContentSharingPickerMode::MultipleApplications + | SCContentSharingPickerMode::SingleDisplay, + ); + configuration.setAllowsChangingSelectedContent(true); + picker.setDefaultConfiguration(&configuration); + picker.setMaximumStreamCount(Some(&NSNumber::new_i32(2))); + let protocol: &ProtocolObject = + ProtocolObject::from_ref(&*observer); + picker.addObserver(protocol); + picker.setActive(true); + picker + }; + Ok(Self { + picker, + observer, + shared, + }) + } + + pub fn screen_authorized() -> bool { + CGPreflightScreenCaptureAccess() + } + + pub fn request_authorization(&self) -> MacosProtectedSourceState { + let status = if CGRequestScreenCaptureAccess() { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::PermissionDenied + }; + self.shared.set_status(status); + status + } + + pub fn present_picker(&self) -> Result<(), MacosCaptureError> { + if !CGPreflightScreenCaptureAccess() { + self.shared + .set_status(MacosProtectedSourceState::NeedsUserAction); + return Err(MacosCaptureError::ScreenCapturePermissionRequired); + } + self.observer.present(&self.picker); + Ok(()) + } + + pub fn status(&self) -> MacosProtectedSourceState { + self.shared.status() + } + + pub fn mailbox(&self) -> MacosFrameMailbox { + self.shared.mailbox.clone() + } + + pub fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + self.shared.diagnostics() + } + + pub fn stop(&self) { + self.observer.stop(); + self.shared.set_status(MacosProtectedSourceState::ReadyIdle); + } +} + +impl fmt::Debug for MacosScreenCaptureSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacosScreenCaptureSession") + .field("status", &self.status()) + .finish_non_exhaustive() + } +} + +impl Drop for MacosScreenCaptureSession { + fn drop(&mut self) { + self.observer.stop(); + // SAFETY: MainThreadOnly ownership prevents this session from moving + // to another thread while registered with the picker. + unsafe { + let protocol: &ProtocolObject = + ProtocolObject::from_ref(&*self.observer); + self.picker.removeObserver(protocol); + self.picker.setActive(false); + } + } +} + +fn stream_configuration( + filter: &SCContentFilter, + request: MacosStreamRequest, +) -> Result<(Retained, bool), MacosCaptureError> { + // SAFETY: Picker callbacks supply a live SCContentFilter for the duration + // of configuration, and returned collection values are retained. + let (content_rect, point_pixel_scale, display_filter) = unsafe { + ( + filter.contentRect(), + f64::from(filter.pointPixelScale()), + !filter.includedDisplays().is_empty(), + ) + }; + let point_rect = MacosPointRect::new( + content_rect.origin.x, + content_rect.origin.y, + content_rect.size.width, + content_rect.size.height, + )?; + let scale = MacosScale::display(point_pixel_scale)?; + let pixel_rect = point_rect.to_pixel_rect(scale)?; + let extent = MacosPixelExtent::new(pixel_rect.width, pixel_rect.height)?; + let cadence_timescale = request.cadence.timescale()?; + // SAFETY: Both constructors use a positive timescale. FramesPerSecond is + // validated before conversion, while the native-refresh sentinel is zero + // duration at the canonical unit timescale. + let minimum_frame_interval = unsafe { + cadence_timescale.map_or_else(|| CMTime::new(0, 1), |timescale| CMTime::new(1, timescale)) + }; + // SAFETY: Every setter receives validated point or pixel units, and the + // configuration is retained by the caller before stream creation. + let configuration = unsafe { + let configuration = SCStreamConfiguration::new(); + configuration.setCapturesAudio(false); + configuration.setCaptureMicrophone(false); + configuration.setCaptureResolution(SCCaptureResolutionType::Best); + configuration.setWidth(extent.width as usize); + configuration.setHeight(extent.height as usize); + configuration.setSourceRect(content_rect); + configuration.setDestinationRect(CGRect::new( + CGPoint::ZERO, + CGSize::new(f64::from(extent.width), f64::from(extent.height)), + )); + configuration.setPreservesAspectRatio(true); + configuration.setScalesToFit(false); + configuration.setMinimumFrameInterval(minimum_frame_interval); + configuration.setShowsCursor(request.cursor_composed); + configuration.setShowMouseClicks(false); + configuration.setStreamName(Some(&NSString::from_str("Hypercolor"))); + configuration.setQueueDepth(MACOS_STREAM_QUEUE_DEPTH as isize); + configuration.setPixelFormat(0x4247_5241); + configuration + }; + Ok((configuration, display_filter)) +} + +fn classify_stream_error(error: &NSError) -> MacosProtectedSourceState { + // SAFETY: ScreenCaptureKit and Foundation expose retained immutable error + // domain strings for the lifetime of this callback. + let is_stream_error = error + .domain() + .isEqualToString(unsafe { SCStreamErrorDomain }); + if !is_stream_error { + return MacosProtectedSourceState::Failed; + } + match SCStreamErrorCode(error.code()) { + SCStreamErrorCode::UserDeclined => MacosProtectedSourceState::PermissionDenied, + SCStreamErrorCode::NoCaptureSource => MacosProtectedSourceState::NeedsSelection, + SCStreamErrorCode::FailedApplicationConnectionInterrupted + | SCStreamErrorCode::SystemStoppedStream => MacosProtectedSourceState::Interrupted, + SCStreamErrorCode::UserStopped => MacosProtectedSourceState::ReadyIdle, + _ => MacosProtectedSourceState::Failed, + } +} + +fn native_error(operation: &'static str, error: &NSError) -> MacosCaptureError { + MacosCaptureError::NativeOperation { + operation, + code: error.code(), + message: error.localizedDescription().to_string(), + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +pub(crate) fn decode_sample( + decoder: &mut MacosFrameDecoder, + sample: &CMSampleBuffer, + cursor_composed: bool, +) -> Result { + // SAFETY: ScreenCaptureKit supplied a live CMSampleBuffer reference for + // the duration of this callback. + if !unsafe { sample.is_valid() } { + return Err(MacosCaptureError::InvalidSampleBuffer); + } + // SAFETY: The same callback lifetime makes the sample reference valid. + if !unsafe { sample.data_is_ready() } { + return Err(MacosCaptureError::SampleDataNotReady); + } + + let attachments = FrameAttachments::from_sample(sample)?; + let raw_attachments = attachments.decode(); + let status = match raw_attachments.status { + MacosAttachment::Value(status) => MacosFrameStatus::try_from(status)?, + MacosAttachment::Missing => return Err(MacosCaptureError::MissingAttachment("status")), + MacosAttachment::Malformed => { + return Err(MacosCaptureError::MalformedAttachment("status")); + } + }; + if status != MacosFrameStatus::Complete { + return decoder.decode(MacosRawCaptureSample { + frame: None, + attachments: raw_attachments, + }); + } + + // SAFETY: The valid, ready sample is retained by the callback while Core + // Media returns a retained image-buffer owner. + let pixel_buffer = + unsafe { sample.image_buffer() }.ok_or(MacosCaptureError::MissingFramePayload)?; + let frame = decode_complete_frame(pixel_buffer, cursor_composed)?; + decoder.decode(MacosRawCaptureSample { + frame: Some(frame), + attachments: raw_attachments, + }) +} + +fn decode_complete_frame( + pixel_buffer: CFRetained, + cursor_composed: bool, +) -> Result { + let storage_extent = extent( + CVPixelBufferGetWidth(&pixel_buffer), + CVPixelBufferGetHeight(&pixel_buffer), + )?; + let pixel_format_fourcc = CVPixelBufferGetPixelFormatType(&pixel_buffer); + let pixel_format = MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?; + let planes = planes(&pixel_buffer, storage_extent)?; + let color = colorimetry(&pixel_buffer, pixel_format_fourcc, pixel_format)?; + let surface = MacosCaptureSurface::from_pixel_buffer(pixel_buffer)?; + + Ok(MacosRawCompleteFrame { + storage_extent, + planes, + pixel_format_fourcc, + color, + cursor_composed, + surface, + }) +} + +fn planes( + pixel_buffer: &CVPixelBuffer, + storage_extent: MacosPixelExtent, +) -> Result, MacosCaptureError> { + let plane_count = CVPixelBufferGetPlaneCount(pixel_buffer); + if plane_count == 0 { + return Ok(vec![MacosRawCapturePlane { + index: 0, + extent: storage_extent, + bytes_per_row: CVPixelBufferGetBytesPerRow(pixel_buffer), + length_bytes: u64::try_from(CVPixelBufferGetDataSize(pixel_buffer)) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + }]); + } + + (0..plane_count) + .map(|index| { + let extent = extent( + CVPixelBufferGetWidthOfPlane(pixel_buffer, index), + CVPixelBufferGetHeightOfPlane(pixel_buffer, index), + )?; + let bytes_per_row = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, index); + let length_bytes = u64::try_from(bytes_per_row) + .ok() + .and_then(|stride| stride.checked_mul(u64::from(extent.height))) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + Ok(MacosRawCapturePlane { + index: u32::try_from(index).map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + extent, + bytes_per_row, + length_bytes, + }) + }) + .collect() +} + +fn extent(width: usize, height: usize) -> Result { + let width = u32::try_from(width).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let height = u32::try_from(height).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + Ok(MacosPixelExtent::new(width, height)?) +} + +fn colorimetry( + pixel_buffer: &CVBuffer, + fourcc: u32, + format: MacosCapturePixelFormat, +) -> Result { + // SAFETY: These Core Video constants are process-lifetime immutable CFString + // references supplied by the linked framework. + let (primaries_key, rec709, display_p3, rec2020) = unsafe { + ( + kCVImageBufferColorPrimariesKey, + kCVImageBufferColorPrimaries_ITU_R_709_2, + kCVImageBufferColorPrimaries_P3_D65, + kCVImageBufferColorPrimaries_ITU_R_2020, + ) + }; + let primaries_value = color_attachment(pixel_buffer, primaries_key, "color_primaries")?; + let primaries = match &*primaries_value { + value if value == rec709 => MacosColorPrimaries::Srgb, + value if value == display_p3 => MacosColorPrimaries::DisplayP3, + value if value == rec2020 => MacosColorPrimaries::Rec2020, + _ => { + return Err(MacosCaptureError::UnsupportedColorAttachment( + "color_primaries", + )); + } + }; + + // SAFETY: These Core Video constants are process-lifetime immutable CFString + // references supplied by the linked framework. + let (transfer_key, srgb, rec709, rec2020, linear, pq, hlg) = unsafe { + ( + kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_sRGB, + kCVImageBufferTransferFunction_ITU_R_709_2, + kCVImageBufferTransferFunction_ITU_R_2020, + kCVImageBufferTransferFunction_Linear, + kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, + kCVImageBufferTransferFunction_ITU_R_2100_HLG, + ) + }; + let transfer_value = color_attachment(pixel_buffer, transfer_key, "transfer_function")?; + let transfer = match &*transfer_value { + value if value == srgb => MacosTransferFunction::Srgb, + value if value == rec709 => MacosTransferFunction::Rec709, + value if value == rec2020 => MacosTransferFunction::Rec2020, + value if value == linear => MacosTransferFunction::Linear, + value if value == pq => MacosTransferFunction::Pq, + value if value == hlg => MacosTransferFunction::Hlg, + _ => { + return Err(MacosCaptureError::UnsupportedColorAttachment( + "transfer_function", + )); + } + }; + + let range = match fourcc { + 0x3432_3076 | 0x7834_3434 => MacosColorRange::Video, + _ => MacosColorRange::Full, + }; + let is_rgb = matches!( + format, + MacosCapturePixelFormat::Bgra8 + | MacosCapturePixelFormat::Argb2101010 + | MacosCapturePixelFormat::Rgba16Float + ); + let (matrix, chroma_location) = if is_rgb { + (None, None) + } else { + ( + Some(yuv_matrix(pixel_buffer)?), + Some(chroma_location(pixel_buffer)?), + ) + }; + + Ok(MacosCaptureColorimetry { + primaries, + transfer, + matrix, + range, + chroma_location, + }) +} + +fn yuv_matrix(pixel_buffer: &CVBuffer) -> Result { + // SAFETY: These Core Video constants are process-lifetime immutable CFString + // references supplied by the linked framework. + let (matrix_key, bt601, bt709, bt2020) = unsafe { + ( + kCVImageBufferYCbCrMatrixKey, + kCVImageBufferYCbCrMatrix_ITU_R_601_4, + kCVImageBufferYCbCrMatrix_ITU_R_709_2, + kCVImageBufferYCbCrMatrix_ITU_R_2020, + ) + }; + let value = color_attachment(pixel_buffer, matrix_key, "ycbcr_matrix")?; + match &*value { + value if value == bt601 => Ok(MacosYuvMatrix::Bt601), + value if value == bt709 => Ok(MacosYuvMatrix::Bt709), + value if value == bt2020 => Ok(MacosYuvMatrix::Bt2020), + _ => Err(MacosCaptureError::UnsupportedColorAttachment( + "ycbcr_matrix", + )), + } +} + +fn chroma_location(pixel_buffer: &CVBuffer) -> Result { + // SAFETY: These Core Video constants are process-lifetime immutable CFString + // references supplied by the linked framework. + let (location_key, left, center, top_left) = unsafe { + ( + kCVImageBufferChromaLocationTopFieldKey, + kCVImageBufferChromaLocation_Left, + kCVImageBufferChromaLocation_Center, + kCVImageBufferChromaLocation_TopLeft, + ) + }; + let value = color_attachment(pixel_buffer, location_key, "chroma_location")?; + match &*value { + value if value == left => Ok(MacosChromaLocation::Left), + value if value == center => Ok(MacosChromaLocation::Center), + value if value == top_left => Ok(MacosChromaLocation::TopLeft), + _ => Err(MacosCaptureError::UnsupportedColorAttachment( + "chroma_location", + )), + } +} + +fn color_attachment( + pixel_buffer: &CVBuffer, + key: &CFString, + name: &'static str, +) -> Result, MacosCaptureError> { + // SAFETY: A null mode pointer explicitly requests no attachment-mode + // output, and the retained result survives the pixel-buffer query. + let value = unsafe { pixel_buffer.attachment(key, ptr::null_mut()) } + .ok_or(MacosCaptureError::MissingColorAttachment(name))?; + value + .downcast::() + .map_err(|_| MacosCaptureError::UnsupportedColorAttachment(name)) +} + +struct FrameAttachments(CFRetained>); + +impl FrameAttachments { + fn from_sample(sample: &CMSampleBuffer) -> Result { + // SAFETY: The sample reference is valid for this callback. Passing + // false prevents Core Media from mutating it to create attachments. + let attachments = unsafe { sample.sample_attachments_array(false) } + .ok_or(MacosCaptureError::MissingFrameAttachments)?; + if attachments.len() != 1 { + return Err(MacosCaptureError::MalformedAttachment("frame_info")); + } + // SAFETY: Core Media documents this as an array of CF attachment + // dictionaries. The element is still type-checked before use. + let attachments = unsafe { attachments.cast_unchecked::() }; + let dictionary = attachments + .get(0) + .and_then(|value| value.downcast::().ok()) + .ok_or(MacosCaptureError::MalformedAttachment("frame_info"))?; + // SAFETY: ScreenCaptureKit frame dictionaries use NSString keys and + // Core Foundation object values. Both are toll-free bridge types. + let dictionary = + unsafe { CFRetained::cast_unchecked::>(dictionary) }; + Ok(Self(dictionary)) + } + + fn decode(&self) -> MacosRawFrameAttachments { + // SAFETY: ScreenCaptureKit exports process-lifetime immutable NSString + // constants for every frame-info dictionary key. + let (status, display_time, scale, content_scale, content, dirty, screen, bounding) = unsafe { + ( + SCStreamFrameInfoStatus, + SCStreamFrameInfoDisplayTime, + SCStreamFrameInfoScaleFactor, + SCStreamFrameInfoContentScale, + SCStreamFrameInfoContentRect, + SCStreamFrameInfoDirtyRects, + SCStreamFrameInfoScreenRect, + SCStreamFrameInfoBoundingRect, + ) + }; + MacosRawFrameAttachments { + status: self.number_i64(status), + display_time: self.number_u64(display_time), + display_scale_factor: self.number_f64(scale), + content_scale: self.number_f64(content_scale), + content_rect: self.point_rect(content), + dirty_rects: self.pixel_rects(dirty), + screen_rect: self.point_rect(screen), + bounding_rect: self.point_rect(bounding), + } + } + + fn value(&self, key: &NSString) -> Option> { + self.0.get(cf_string(key)) + } + + fn number_i64(&self, key: &NSString) -> MacosAttachment { + self.convert(key, |value| value.downcast_ref::()?.as_i64()) + } + + fn number_u64(&self, key: &NSString) -> MacosAttachment { + self.convert(key, |value| { + value + .downcast_ref::()? + .as_i64() + .and_then(|number| u64::try_from(number).ok()) + }) + } + + fn number_f64(&self, key: &NSString) -> MacosAttachment { + self.convert(key, |value| value.downcast_ref::()?.as_f64()) + } + + fn point_rect(&self, key: &NSString) -> MacosAttachment { + self.convert(key, point_rect) + } + + fn pixel_rects(&self, key: &NSString) -> MacosAttachment> { + self.convert(key, |value| { + let array = value.downcast_ref::()?; + // SAFETY: ScreenCaptureKit documents dirtyRects as an NSArray of + // NSValue objects. Every element is checked before conversion. + let array = unsafe { array.cast_unchecked::() }; + array.iter().map(|rect| pixel_rect(&rect)).collect() + }) + } + + fn convert( + &self, + key: &NSString, + convert: impl FnOnce(&CFType) -> Option, + ) -> MacosAttachment { + match self.value(key) { + None => MacosAttachment::Missing, + Some(value) => { + convert(&value).map_or(MacosAttachment::Malformed, MacosAttachment::Value) + } + } + } +} + +fn cf_string(value: &NSString) -> &CFString { + // SAFETY: NSString and CFString are toll-free bridged immutable string + // representations on macOS. + unsafe { &*(ptr::from_ref(value).cast::()) } +} + +fn point_rect(value: &CFType) -> Option { + let dictionary = value.downcast_ref::()?; + let mut rect = CGRect::ZERO; + // SAFETY: The output points to initialized CGRect storage, and the input + // was type-checked as a CFDictionary. + if !unsafe { CGRectMakeWithDictionaryRepresentation(Some(dictionary), &mut rect) } { + return None; + } + MacosPointRect::new( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ) + .ok() +} + +fn pixel_rect(value: &CFType) -> Option { + let object = >::as_ref(value); + let rect = object.downcast_ref::()?.get_rect()?; + let x = exact_i64(rect.origin.x)?; + let y = exact_i64(rect.origin.y)?; + let width = exact_u32(rect.size.width)?; + let height = exact_u32(rect.size.height)?; + MacosPixelRect::new(x, y, width, height).ok() +} + +fn exact_i64(value: f64) -> Option { + if !value.is_finite() + || value.fract() != 0.0 + || value < i64::MIN as f64 + || value > i64::MAX as f64 + { + return None; + } + Some(value as i64) +} + +fn exact_u32(value: f64) -> Option { + if !value.is_finite() || value.fract() != 0.0 || value <= 0.0 || value > f64::from(u32::MAX) { + return None; + } + Some(value as u32) +} diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs new file mode 100644 index 000000000..441735d30 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -0,0 +1,47 @@ +use crate::MacosCaptureError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosCaptureCadence { + NativeRefresh, + FramesPerSecond(u32), +} + +impl MacosCaptureCadence { + pub(crate) fn timescale(self) -> Result, MacosCaptureError> { + match self { + Self::NativeRefresh => Ok(None), + Self::FramesPerSecond(0) => Err(MacosCaptureError::InvalidCadence(0)), + Self::FramesPerSecond(value) => i32::try_from(value) + .map(Some) + .map_err(|_| MacosCaptureError::InvalidCadence(value)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosStreamRequest { + pub cadence: MacosCaptureCadence, + pub cursor_composed: bool, +} + +impl MacosStreamRequest { + pub fn new( + cadence: MacosCaptureCadence, + cursor_composed: bool, + ) -> Result { + cadence.timescale()?; + Ok(Self { + cadence, + cursor_composed, + }) + } +} + +impl Default for MacosStreamRequest { + fn default() -> Self { + Self { + cadence: MacosCaptureCadence::FramesPerSecond(60), + cursor_composed: true, + } + } +} diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 19aec1a12..123f6d1d3 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -1,10 +1,11 @@ use hypercolor_macos_capture::{ - MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, + MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, - MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameStatus, MacosGeometryError, - MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, - MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, MacosTransferFunction, - MacosYuvMatrix, + MacosColorRange, MacosFrameDecoder, MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, + MacosFrameStatus, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, + MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, }; const BGRA8: u32 = 0x4247_5241; @@ -20,6 +21,24 @@ fn queue_depth_is_the_full_framework_limit() { assert_eq!(MACOS_STREAM_QUEUE_DEPTH, 8); } +#[test] +fn stream_requests_preserve_native_refresh_and_reject_invalid_rates() { + assert_eq!( + MacosStreamRequest::new(MacosCaptureCadence::NativeRefresh, false) + .expect("native refresh should be supported") + .cadence, + MacosCaptureCadence::NativeRefresh + ); + assert_eq!( + MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(0), true), + Err(MacosCaptureError::InvalidCadence(0)) + ); + assert_eq!( + MacosStreamRequest::default().cadence, + MacosCaptureCadence::FramesPerSecond(60) + ); +} + #[test] fn all_native_frame_statuses_decode_exactly() { let expected = [ @@ -306,11 +325,42 @@ fn decoded_frames_keep_the_pixel_buffer_owner_alive() { let frame = decode_frame(&mut MacosFrameDecoder::new(1), complete_sample()); let surface = frame.surface.clone(); assert_eq!(frame.surface.retained_owner_count(), 2); - assert_eq!(surface.fixture_id(), 99); + assert_eq!(surface.fixture_id(), Some(99)); drop(frame); assert_eq!(surface.retained_owner_count(), 1); } +#[test] +fn mailbox_replaces_stale_deliveries_without_growing() { + let mailbox = MacosFrameMailbox::new(); + assert!(!mailbox.has_pending()); + assert_eq!(mailbox.superseded_count(), 0); + + mailbox.publish(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))); + mailbox.publish(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle))); + + assert!(mailbox.has_pending()); + assert_eq!(mailbox.superseded_count(), 1); + assert!(matches!( + mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle))) + )); + assert!(!mailbox.has_pending()); +} + +#[test] +fn callback_diagnostics_start_with_every_drop_reason_at_zero() { + let diagnostics = MacosCaptureCallbackDiagnostics::default(); + assert_eq!(diagnostics.frames_received, 0); + assert_eq!(diagnostics.frames_published, 0); + assert_eq!(diagnostics.lifecycle_events, 0); + assert_eq!(diagnostics.superseded_deliveries, 0); + assert_eq!(diagnostics.total_dropped(), 0); + for reason in MacosFrameDropReason::ALL { + assert_eq!(diagnostics.dropped(reason), 0); + } +} + fn sample_with_status(status: i64) -> MacosRawCaptureSample { let mut sample = complete_sample(); sample.attachments.status = MacosAttachment::Value(status); From aead5e51e1cef4611595c255b42747577b8a6d4a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:01:48 -0700 Subject: [PATCH 022/144] feat(macos): add safe capture CPU mapping 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) --- crates/hypercolor-macos-capture/src/cpu.rs | 87 +++++++++ .../src/diagnostics.rs | 8 + crates/hypercolor-macos-capture/src/frame.rs | 179 +++++++++++++++++- crates/hypercolor-macos-capture/src/lib.rs | 1 + .../tests/capture_contract_tests.rs | 45 +++++ 5 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 crates/hypercolor-macos-capture/src/cpu.rs diff --git a/crates/hypercolor-macos-capture/src/cpu.rs b/crates/hypercolor-macos-capture/src/cpu.rs new file mode 100644 index 000000000..954075c58 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/cpu.rs @@ -0,0 +1,87 @@ +use crate::{MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat}; + +impl MacosCaptureFrame { + pub fn copy_bgra8_to( + &self, + destination: &mut [u8], + destination_stride: usize, + ) -> Result<(), MacosCaptureError> { + if self.pixel_format != MacosCapturePixelFormat::Bgra8 { + return Err(MacosCaptureError::UnsupportedCpuPixelFormat( + self.pixel_format, + )); + } + let row_bytes = usize::try_from(self.storage_extent.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if destination_stride < row_bytes { + return Err(MacosCaptureError::InvalidCpuDestinationStride { + minimum: row_bytes, + actual: destination_stride, + }); + } + let height = usize::try_from(self.storage_extent.height) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let required = destination_stride + .checked_mul(height) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if destination.len() < required { + return Err(MacosCaptureError::CpuDestinationTooSmall { + required, + actual: destination.len(), + }); + } + let source = self + .planes + .first() + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + let lengths = [source.length_bytes]; + self.surface.with_plane_bytes(&lengths, |planes| { + copy_rows( + planes[0], + source.bytes_per_row, + destination, + destination_stride, + row_bytes, + height, + ) + })? + } +} + +fn copy_rows( + source: &[u8], + source_stride: usize, + destination: &mut [u8], + destination_stride: usize, + row_bytes: usize, + height: usize, +) -> Result<(), MacosCaptureError> { + for row in 0..height { + let source_start = row + .checked_mul(source_stride) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let source_end = source_start + .checked_add(row_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_start = row + .checked_mul(destination_stride) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_end = destination_start + .checked_add(row_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let source_row = source + .get(source_start..source_end) + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + let destination_length = destination.len(); + let destination_row = destination + .get_mut(destination_start..destination_end) + .ok_or(MacosCaptureError::CpuDestinationTooSmall { + required: destination_end, + actual: destination_length, + })?; + destination_row.copy_from_slice(source_row); + } + Ok(()) +} diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index d47e7e68b..6bdcaf452 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -56,6 +56,14 @@ impl MacosFrameDropReason { | MacosCaptureError::ArithmeticOverflow | MacosCaptureError::AllocationTooSmall { .. } | MacosCaptureError::GeometryOutsideStorage(_) + | MacosCaptureError::CpuMappingUnavailable + | MacosCaptureError::CpuPlaneLayoutMismatch + | MacosCaptureError::PixelBufferLockFailed(_) + | MacosCaptureError::PixelBufferUnlockFailed(_) + | MacosCaptureError::MissingCpuPlaneAddress(_) + | MacosCaptureError::UnsupportedCpuPixelFormat(_) + | MacosCaptureError::InvalidCpuDestinationStride { .. } + | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted | MacosCaptureError::Geometry(_) => Self::Validation, } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 5f9a31644..906c6c5de 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -4,7 +4,11 @@ use std::sync::Arc; #[cfg(target_os = "macos")] use objc2_core_foundation::CFRetained; #[cfg(target_os = "macos")] -use objc2_core_video::{CVPixelBuffer, CVPixelBufferGetIOSurface}; +use objc2_core_video::{ + CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBaseAddressOfPlane, + CVPixelBufferGetIOSurface, CVPixelBufferGetPlaneCount, CVPixelBufferLockBaseAddress, + CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVReturnSuccess, +}; use thiserror::Error; use crate::geometry::{ @@ -229,7 +233,36 @@ impl MacosCaptureSurface { Ok(Self { iosurface_id, allocation_bytes, - owner: Arc::new(MacosRetainedPixelBuffer::Fixture { fixture_id }), + owner: Arc::new(MacosRetainedPixelBuffer::Fixture { + fixture_id, + planes: None, + }), + }) + } + + #[cfg(feature = "capture-fixtures")] + pub fn new_cpu_fixture( + iosurface_id: u32, + allocation_bytes: u64, + fixture_id: u64, + planes: Vec>, + ) -> Result { + if iosurface_id == 0 || allocation_bytes == 0 || planes.is_empty() { + return Err(MacosCaptureError::InvalidSurface); + } + let used_bytes = planes.iter().try_fold(0_u64, |total, plane| { + total.checked_add(u64::try_from(plane.len()).ok()?) + }); + if used_bytes.is_none_or(|used_bytes| used_bytes > allocation_bytes) { + return Err(MacosCaptureError::InvalidSurface); + } + Ok(Self { + iosurface_id, + allocation_bytes, + owner: Arc::new(MacosRetainedPixelBuffer::Fixture { + fixture_id, + planes: Some(planes.into()), + }), }) } @@ -248,9 +281,7 @@ impl MacosCaptureSurface { Ok(Self { iosurface_id, allocation_bytes, - owner: Arc::new(MacosRetainedPixelBuffer::Native { - _pixel_buffer: pixel_buffer, - }), + owner: Arc::new(MacosRetainedPixelBuffer::Native { pixel_buffer }), }) } @@ -261,11 +292,40 @@ impl MacosCaptureSurface { #[cfg(feature = "capture-fixtures")] pub fn fixture_id(&self) -> Option { match &*self.owner { - MacosRetainedPixelBuffer::Fixture { fixture_id } => Some(*fixture_id), + MacosRetainedPixelBuffer::Fixture { fixture_id, .. } => Some(*fixture_id), #[cfg(target_os = "macos")] MacosRetainedPixelBuffer::Native { .. } => None, } } + + pub(crate) fn with_plane_bytes( + &self, + lengths: &[u64], + operation: impl FnOnce(&[&[u8]]) -> R, + ) -> Result { + match &*self.owner { + #[cfg(target_os = "macos")] + MacosRetainedPixelBuffer::Native { pixel_buffer } => { + with_native_plane_bytes(pixel_buffer, lengths, operation) + } + #[cfg(feature = "capture-fixtures")] + MacosRetainedPixelBuffer::Fixture { planes, .. } => { + let planes = planes + .as_ref() + .ok_or(MacosCaptureError::CpuMappingUnavailable)?; + if planes.len() != lengths.len() + || planes + .iter() + .zip(lengths) + .any(|(plane, length)| u64::try_from(plane.len()).ok() != Some(*length)) + { + return Err(MacosCaptureError::CpuPlaneLayoutMismatch); + } + let borrowed = planes.iter().map(AsRef::as_ref).collect::>(); + Ok(operation(&borrowed)) + } + } + } } impl fmt::Debug for MacosCaptureSurface { @@ -281,10 +341,13 @@ impl fmt::Debug for MacosCaptureSurface { enum MacosRetainedPixelBuffer { #[cfg(target_os = "macos")] Native { - _pixel_buffer: CFRetained, + pixel_buffer: CFRetained, }, #[cfg(feature = "capture-fixtures")] - Fixture { fixture_id: u64 }, + Fixture { + fixture_id: u64, + planes: Option]>>, + }, } impl fmt::Debug for MacosRetainedPixelBuffer { @@ -293,7 +356,7 @@ impl fmt::Debug for MacosRetainedPixelBuffer { #[cfg(target_os = "macos")] Self::Native { .. } => formatter.write_str("MacosRetainedPixelBuffer::Native"), #[cfg(feature = "capture-fixtures")] - Self::Fixture { fixture_id } => formatter + Self::Fixture { fixture_id, .. } => formatter .debug_struct("MacosRetainedPixelBuffer::Fixture") .field("fixture_id", fixture_id) .finish(), @@ -311,6 +374,88 @@ unsafe impl Send for MacosRetainedPixelBuffer {} // access is serialized by Core Video's lock contract. unsafe impl Sync for MacosRetainedPixelBuffer {} +#[cfg(target_os = "macos")] +struct PixelBufferReadLock<'a> { + pixel_buffer: &'a CVPixelBuffer, + locked: bool, +} + +#[cfg(target_os = "macos")] +impl<'a> PixelBufferReadLock<'a> { + fn acquire(pixel_buffer: &'a CVPixelBuffer) -> Result { + // SAFETY: The retained pixel buffer remains live through this guard, + // and read-only is used symmetrically for lock and unlock. + let code = + unsafe { CVPixelBufferLockBaseAddress(pixel_buffer, CVPixelBufferLockFlags::ReadOnly) }; + if code != kCVReturnSuccess { + return Err(MacosCaptureError::PixelBufferLockFailed(code)); + } + Ok(Self { + pixel_buffer, + locked: true, + }) + } + + fn unlock(mut self) -> Result<(), MacosCaptureError> { + // SAFETY: This guard owns the successful matching read-only lock and + // marks it released before Drop can run. + let code = unsafe { + CVPixelBufferUnlockBaseAddress(self.pixel_buffer, CVPixelBufferLockFlags::ReadOnly) + }; + self.locked = false; + if code == kCVReturnSuccess { + Ok(()) + } else { + Err(MacosCaptureError::PixelBufferUnlockFailed(code)) + } + } +} + +#[cfg(target_os = "macos")] +impl Drop for PixelBufferReadLock<'_> { + fn drop(&mut self) { + if self.locked { + // SAFETY: Drop runs only while the successful read-only lock is + // still owned, including unwinding from the mapping closure. + let _ = unsafe { + CVPixelBufferUnlockBaseAddress(self.pixel_buffer, CVPixelBufferLockFlags::ReadOnly) + }; + } + } +} + +#[cfg(target_os = "macos")] +fn with_native_plane_bytes( + pixel_buffer: &CVPixelBuffer, + lengths: &[u64], + operation: impl FnOnce(&[&[u8]]) -> R, +) -> Result { + let lock = PixelBufferReadLock::acquire(pixel_buffer)?; + let plane_count = CVPixelBufferGetPlaneCount(pixel_buffer); + let actual_count = if plane_count == 0 { 1 } else { plane_count }; + if actual_count != lengths.len() { + return Err(MacosCaptureError::CpuPlaneLayoutMismatch); + } + let mut planes = Vec::with_capacity(actual_count); + for (index, length) in lengths.iter().copied().enumerate() { + let address = if plane_count == 0 { + CVPixelBufferGetBaseAddress(pixel_buffer) + } else { + CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, index) + }; + let length = usize::try_from(length).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + if address.is_null() { + return Err(MacosCaptureError::MissingCpuPlaneAddress(index)); + } + // SAFETY: The read lock keeps every non-null plane address valid for + // its validated Core Video plane length until operation returns. + planes.push(unsafe { std::slice::from_raw_parts(address.cast::(), length) }); + } + let result = operation(&planes); + lock.unlock()?; + Ok(result) +} + #[derive(Debug, Clone)] pub struct MacosCaptureFrame { pub epoch: u64, @@ -653,6 +798,22 @@ pub enum MacosCaptureError { InvalidSurface, #[error("complete frame has no IOSurface-backed pixel buffer")] MissingIoSurface, + #[error("capture surface has no CPU-mappable fixture or pixel buffer")] + CpuMappingUnavailable, + #[error("mapped CPU planes do not match the validated frame layout")] + CpuPlaneLayoutMismatch, + #[error("Core Video pixel-buffer lock failed with code {0}")] + PixelBufferLockFailed(i32), + #[error("Core Video pixel-buffer unlock failed with code {0}")] + PixelBufferUnlockFailed(i32), + #[error("Core Video returned no base address for plane {0}")] + MissingCpuPlaneAddress(usize), + #[error("CPU publication requires BGRA8 input, got {0:?}")] + UnsupportedCpuPixelFormat(MacosCapturePixelFormat), + #[error("CPU destination stride {actual} is smaller than {minimum}")] + InvalidCpuDestinationStride { minimum: usize, actual: usize }, + #[error("CPU destination has {actual} bytes, but {required} are required")] + CpuDestinationTooSmall { required: usize, actual: usize }, #[error("complete-frame sequence exhausted")] SequenceExhausted, #[error(transparent)] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 1cb96f1f9..2b27d5377 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -3,6 +3,7 @@ //! Native framework ownership remains private to this crate. The public frame //! boundary contains only plain Rust metadata plus an opaque retained surface. +mod cpu; mod diagnostics; mod frame; mod geometry; diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 123f6d1d3..78288f605 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use hypercolor_macos_capture::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureError, @@ -330,6 +332,49 @@ fn decoded_frames_keep_the_pixel_buffer_owner_alive() { assert_eq!(surface.retained_owner_count(), 1); } +#[test] +fn bgra_cpu_copy_preserves_rows_and_ignores_padding() { + let mut sample = complete_sample(); + let source = (0_u8..192).collect::>(); + complete_frame_mut(&mut sample).surface = + MacosCaptureSurface::new_cpu_fixture(7, 192, 99, vec![Arc::<[u8]>::from(source.clone())]) + .expect("CPU fixture surface should be valid"); + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample); + let mut destination = vec![0xcc; 36 * 6]; + + frame + .copy_bgra8_to(&mut destination, 36) + .expect("BGRA rows should copy"); + + for row in 0..6 { + assert_eq!( + &destination[row * 36..row * 36 + 32], + &source[row * 32..row * 32 + 32] + ); + assert_eq!(&destination[row * 36 + 32..(row + 1) * 36], &[0xcc; 4]); + } + assert_eq!( + frame.copy_bgra8_to(&mut destination, 31), + Err(MacosCaptureError::InvalidCpuDestinationStride { + minimum: 32, + actual: 31, + }) + ); +} + +#[test] +fn cpu_copy_rejects_non_bgra_input_without_mapping_it() { + let mut sample = complete_sample(); + complete_frame_mut(&mut sample).pixel_format_fourcc = ARGB2101010; + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample); + assert_eq!( + frame.copy_bgra8_to(&mut [0; 192], 32), + Err(MacosCaptureError::UnsupportedCpuPixelFormat( + MacosCapturePixelFormat::Argb2101010 + )) + ); +} + #[test] fn mailbox_replaces_stale_deliveries_without_growing() { let mailbox = MacosFrameMailbox::new(); From 7c450fc1a369014b46dec643d81ccd014f2f47e9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:05:04 -0700 Subject: [PATCH 023/144] refactor(macos): bind capture UI to main thread 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) --- crates/hypercolor-macos-capture/src/native.rs | 22 +++++++++++-------- .../tests/capture_contract_tests.rs | 7 ++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index d1d3289a1..bac4506ae 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use block2::RcBlock; -use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained}; +use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained, MainThreadBound}; use objc2::rc::Retained; use objc2::runtime::{AnyObject, ProtocolObject}; use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; @@ -582,9 +582,13 @@ impl PickerObserver { } } -pub struct MacosScreenCaptureSession { +struct MainThreadSession { picker: Retained, observer: Retained, +} + +pub struct MacosScreenCaptureSession { + main: MainThreadBound, shared: Arc, } @@ -622,8 +626,7 @@ impl MacosScreenCaptureSession { picker }; Ok(Self { - picker, - observer, + main: MainThreadBound::new(MainThreadSession { picker, observer }, mtm), shared, }) } @@ -648,7 +651,8 @@ impl MacosScreenCaptureSession { .set_status(MacosProtectedSourceState::NeedsUserAction); return Err(MacosCaptureError::ScreenCapturePermissionRequired); } - self.observer.present(&self.picker); + self.main + .get_on_main(|main| main.observer.present(&main.picker)); Ok(()) } @@ -665,7 +669,7 @@ impl MacosScreenCaptureSession { } pub fn stop(&self) { - self.observer.stop(); + self.main.get_on_main(|main| main.observer.stop()); self.shared.set_status(MacosProtectedSourceState::ReadyIdle); } } @@ -679,11 +683,11 @@ impl fmt::Debug for MacosScreenCaptureSession { } } -impl Drop for MacosScreenCaptureSession { +impl Drop for MainThreadSession { fn drop(&mut self) { self.observer.stop(); - // SAFETY: MainThreadOnly ownership prevents this session from moving - // to another thread while registered with the picker. + // SAFETY: MainThreadBound runs this destructor on the main thread, and + // the observer remains retained through its removal. unsafe { let protocol: &ProtocolObject = ProtocolObject::from_ref(&*self.observer); diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 78288f605..75b87f0e1 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -406,6 +406,13 @@ fn callback_diagnostics_start_with_every_drop_reason_at_zero() { } } +#[cfg(target_os = "macos")] +#[test] +fn screen_capture_session_handle_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + fn sample_with_status(status: i64) -> MacosRawCaptureSample { let mut sample = complete_sample(); sample.attachments.status = MacosAttachment::Value(status); From 4ac4e57c573db66f9cdd19c6e721cd1d2cc89401 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:08:11 -0700 Subject: [PATCH 024/144] feat(macos): separate capture demand from selection 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) --- .../src/diagnostics.rs | 1 + crates/hypercolor-macos-capture/src/frame.rs | 2 + crates/hypercolor-macos-capture/src/native.rs | 93 ++++++++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 6bdcaf452..e231cb960 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -48,6 +48,7 @@ impl MacosFrameDropReason { | MacosCaptureError::NotMainThread | MacosCaptureError::ScreenCapturePermissionRequired | MacosCaptureError::NativeOperation { .. } + | MacosCaptureError::RetainNativeFilterFailed | MacosCaptureError::PlaneCount { .. } | MacosCaptureError::InvalidPlaneIndex { .. } | MacosCaptureError::InvalidPlaneExtent { .. } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 906c6c5de..20aaaba24 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -798,6 +798,8 @@ pub enum MacosCaptureError { InvalidSurface, #[error("complete frame has no IOSurface-backed pixel buffer")] MissingIoSurface, + #[error("ScreenCaptureKit filter retention failed")] + RetainNativeFilterFailed, #[error("capture surface has no CPU-mappable fixture or pixel buffer")] CpuMappingUnavailable, #[error("mapped CPU planes do not match the validated frame layout")] diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index bac4506ae..28b2c7aea 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::fmt; use std::ptr; use std::sync::atomic::{AtomicU64, Ordering}; @@ -236,8 +236,16 @@ impl CaptureOutput { } } +#[derive(Clone)] +struct NativeFilter(Retained); + +// SAFETY: SCContentFilter is immutable after picker delivery and remains in +// the process that owns every consuming SCStream. Rust never mutates it. +unsafe impl Send for NativeFilter {} + struct NativeStream { stream: Retained, + filter: NativeFilter, _output: Retained, _queue: DispatchRetained, } @@ -256,6 +264,12 @@ impl NativeStream { streams: Weak, ) -> Result { let (configuration, display_filter) = stream_configuration(filter, request)?; + // SAFETY: The picker callback supplies a live filter. Retaining it + // preserves the immutable selection through stream retirement. + let retained_filter = unsafe { + Retained::retain(ptr::from_ref(filter).cast_mut()) + .ok_or(MacosCaptureError::RetainNativeFilterFailed)? + }; let output = CaptureOutput::new( epoch, shared, @@ -292,6 +306,7 @@ impl NativeStream { } Ok(Self { stream, + filter: NativeFilter(retained_filter), _output: output, _queue: queue, }) @@ -315,6 +330,7 @@ enum StreamRole { struct StreamState { current: Option, candidate: Option, + selected_filter: Option, } struct StreamSlot { @@ -368,6 +384,7 @@ impl StreamSlot { return false; }; let previous = state.current.replace(candidate); + state.selected_filter = state.current.as_ref().map(|current| current.filter.clone()); self.shared.activate_epoch(epoch); previous }; @@ -403,6 +420,25 @@ impl StreamSlot { lock(&self.state).current.is_some() } + fn has_selection(&self) -> bool { + lock(&self.state).selected_filter.is_some() + } + + fn store_selection(&self, filter: &SCContentFilter) -> Result<(), MacosCaptureError> { + // SAFETY: The picker callback supplies a live immutable filter. The + // retained owner remains process-local and is never serialized. + let filter = unsafe { + Retained::retain(ptr::from_ref(filter).cast_mut()) + .ok_or(MacosCaptureError::RetainNativeFilterFailed)? + }; + lock(&self.state).selected_filter = Some(NativeFilter(filter)); + Ok(()) + } + + fn selected_filter(&self) -> Option { + lock(&self.state).selected_filter.clone() + } + fn current_stream(&self) -> Option> { lock(&self.state) .current @@ -413,6 +449,12 @@ impl StreamSlot { fn stop(&self) { let (current, candidate) = { let mut state = lock(&self.state); + if state.current.is_none() + && state.selected_filter.is_none() + && let Some(candidate) = state.candidate.as_ref() + { + state.selected_filter = Some(candidate.filter.clone()); + } (state.current.take(), state.candidate.take()) }; self.shared.activate_epoch(0); @@ -473,6 +515,7 @@ struct PickerObserverIvars { streams: Arc, request: MacosStreamRequest, next_epoch: RefCell, + active: Cell, } define_class!( @@ -492,7 +535,7 @@ define_class!( _picker: &SCContentSharingPicker, _stream: Option<&SCStream>, ) { - if !self.ivars().streams.has_current() { + if !self.ivars().streams.has_selection() { self.ivars() .shared .set_status(MacosProtectedSourceState::NeedsSelection); @@ -507,7 +550,18 @@ define_class!( filter: &SCContentFilter, _stream: Option<&SCStream>, ) { - self.install_filter(filter); + if self.ivars().active.get() { + self.install_filter(filter); + } else if let Err(error) = self.ivars().streams.store_selection(filter) { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::Failed); + self.ivars().shared.publish_error(error); + } else { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + } } #[allow(non_snake_case)] @@ -537,6 +591,7 @@ impl PickerObserver { streams, request, next_epoch: RefCell::new(1), + active: Cell::new(false), }); // SAFETY: NSObject has no additional initialization requirements for // this main-thread observer subclass. @@ -577,7 +632,31 @@ impl PickerObserver { } } + fn set_active(&self, active: bool) { + if self.ivars().active.replace(active) == active { + return; + } + if !active { + self.ivars().streams.stop(); + let status = if self.ivars().streams.has_selection() { + MacosProtectedSourceState::ReadyIdle + } else { + MacosProtectedSourceState::NeedsSelection + }; + self.ivars().shared.set_status(status); + return; + } + let Some(filter) = self.ivars().streams.selected_filter() else { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::NeedsSelection); + return; + }; + self.install_filter(&filter.0); + } + fn stop(&self) { + self.ivars().active.set(false); self.ivars().streams.stop(); } } @@ -669,8 +748,12 @@ impl MacosScreenCaptureSession { } pub fn stop(&self) { - self.main.get_on_main(|main| main.observer.stop()); - self.shared.set_status(MacosProtectedSourceState::ReadyIdle); + self.set_capture_active(false); + } + + pub fn set_capture_active(&self, active: bool) { + self.main + .get_on_main(|main| main.observer.set_active(active)); } } From 3f306b9ec2fcdf6bfbbedc92ced9d32ee2bd8414 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:10:21 -0700 Subject: [PATCH 025/144] feat(macos): add SDR capture color oracle 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) --- crates/hypercolor-macos-capture/src/cpu.rs | 190 +++++++++++++++++- .../src/diagnostics.rs | 1 + crates/hypercolor-macos-capture/src/frame.rs | 2 + .../tests/capture_contract_tests.rs | 54 +++++ 4 files changed, 246 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-macos-capture/src/cpu.rs b/crates/hypercolor-macos-capture/src/cpu.rs index 954075c58..99178db5b 100644 --- a/crates/hypercolor-macos-capture/src/cpu.rs +++ b/crates/hypercolor-macos-capture/src/cpu.rs @@ -1,4 +1,7 @@ -use crate::{MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat}; +use crate::{ + MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, MacosColorPrimaries, + MacosTransferFunction, +}; impl MacosCaptureFrame { pub fn copy_bgra8_to( @@ -48,6 +51,74 @@ impl MacosCaptureFrame { ) })? } + + pub fn convert_bgra8_sdr_to_rgba8( + &self, + destination: &mut [u8], + destination_stride: usize, + ) -> Result<(), MacosCaptureError> { + if self.pixel_format != MacosCapturePixelFormat::Bgra8 { + return Err(MacosCaptureError::UnsupportedCpuPixelFormat( + self.pixel_format, + )); + } + if matches!( + self.color.transfer, + MacosTransferFunction::Pq | MacosTransferFunction::Hlg + ) { + return Err(MacosCaptureError::UnsupportedCpuTransferFunction( + self.color.transfer, + )); + } + let row_bytes = usize::try_from(self.storage_extent.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let height = validate_destination(destination, destination_stride, row_bytes, self)?; + let source = self + .planes + .first() + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + let lengths = [source.length_bytes]; + self.surface.with_plane_bytes(&lengths, |planes| { + convert_bgra_rows( + planes[0], + source.bytes_per_row, + destination, + destination_stride, + row_bytes, + height, + self.color.primaries, + self.color.transfer, + ) + })? + } +} + +fn validate_destination( + destination: &[u8], + destination_stride: usize, + row_bytes: usize, + frame: &MacosCaptureFrame, +) -> Result { + if destination_stride < row_bytes { + return Err(MacosCaptureError::InvalidCpuDestinationStride { + minimum: row_bytes, + actual: destination_stride, + }); + } + let height = usize::try_from(frame.storage_extent.height) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let required = destination_stride + .checked_mul(height) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if destination.len() < required { + return Err(MacosCaptureError::CpuDestinationTooSmall { + required, + actual: destination.len(), + }); + } + Ok(height) } fn copy_rows( @@ -85,3 +156,120 @@ fn copy_rows( } Ok(()) } + +#[allow(clippy::too_many_arguments)] +fn convert_bgra_rows( + source: &[u8], + source_stride: usize, + destination: &mut [u8], + destination_stride: usize, + row_bytes: usize, + height: usize, + primaries: MacosColorPrimaries, + transfer: MacosTransferFunction, +) -> Result<(), MacosCaptureError> { + for row in 0..height { + let source_start = row + .checked_mul(source_stride) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let source_end = source_start + .checked_add(row_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_start = row + .checked_mul(destination_stride) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_end = destination_start + .checked_add(row_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let source_row = source + .get(source_start..source_end) + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + let destination_row = destination + .get_mut(destination_start..destination_end) + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + for (source_pixel, destination_pixel) in source_row + .chunks_exact(4) + .zip(destination_row.chunks_exact_mut(4)) + { + let linear = [ + decode(source_pixel[2], transfer), + decode(source_pixel[1], transfer), + decode(source_pixel[0], transfer), + ]; + let linear = compress_gamut(convert_primaries(linear, primaries)); + destination_pixel[0] = encode_srgb(linear[0]); + destination_pixel[1] = encode_srgb(linear[1]); + destination_pixel[2] = encode_srgb(linear[2]); + destination_pixel[3] = source_pixel[3]; + } + } + Ok(()) +} + +fn decode(value: u8, transfer: MacosTransferFunction) -> f32 { + let value = f32::from(value) / 255.0; + match transfer { + MacosTransferFunction::Srgb => { + if value <= 0.040_45 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + } + MacosTransferFunction::Rec709 => decode_bt(value, 1.099, 0.018), + MacosTransferFunction::Rec2020 => decode_bt(value, 1.099_296_8, 0.018_053_97), + MacosTransferFunction::Linear => value, + MacosTransferFunction::Pq | MacosTransferFunction::Hlg => unreachable!(), + } +} + +fn decode_bt(value: f32, alpha: f32, beta: f32) -> f32 { + let encoded_cut = 4.5 * beta; + if value < encoded_cut { + value / 4.5 + } else { + ((value + alpha - 1.0) / alpha).powf(1.0 / 0.45) + } +} + +fn convert_primaries(rgb: [f32; 3], primaries: MacosColorPrimaries) -> [f32; 3] { + let matrix: [[f32; 3]; 3] = match primaries { + MacosColorPrimaries::Srgb => return rgb, + MacosColorPrimaries::DisplayP3 => [ + [1.224_745, -0.224_904, 0.0], + [-0.042_058, 1.042_081, 0.0], + [-0.019_642, -0.078_655, 1.098_537], + ], + MacosColorPrimaries::Rec2020 => [ + [1.660_491, -0.587_641, -0.072_85], + [-0.124_55, 1.132_9, -0.008_349], + [-0.018_151, -0.100_579, 1.118_73], + ], + }; + matrix.map(|row| row[0].mul_add(rgb[0], row[1].mul_add(rgb[1], row[2] * rgb[2]))) +} + +fn compress_gamut(mut rgb: [f32; 3]) -> [f32; 3] { + let minimum = rgb.into_iter().reduce(f32::min).unwrap_or(0.0); + if minimum < 0.0 { + for channel in &mut rgb { + *channel -= minimum; + } + } + let maximum = rgb.into_iter().reduce(f32::max).unwrap_or(1.0); + if maximum > 1.0 { + for channel in &mut rgb { + *channel /= maximum; + } + } + rgb +} + +fn encode_srgb(value: f32) -> u8 { + let encoded = if value <= 0.003_130_8 { + 12.92 * value + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (encoded.clamp(0.0, 1.0) * 255.0).round() as u8 +} diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index e231cb960..b1c59fdc3 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -63,6 +63,7 @@ impl MacosFrameDropReason { | MacosCaptureError::PixelBufferUnlockFailed(_) | MacosCaptureError::MissingCpuPlaneAddress(_) | MacosCaptureError::UnsupportedCpuPixelFormat(_) + | MacosCaptureError::UnsupportedCpuTransferFunction(_) | MacosCaptureError::InvalidCpuDestinationStride { .. } | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 20aaaba24..364f8b5da 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -812,6 +812,8 @@ pub enum MacosCaptureError { MissingCpuPlaneAddress(usize), #[error("CPU publication requires BGRA8 input, got {0:?}")] UnsupportedCpuPixelFormat(MacosCapturePixelFormat), + #[error("CPU SDR publication does not support {0:?} transfer")] + UnsupportedCpuTransferFunction(MacosTransferFunction), #[error("CPU destination stride {actual} is smaller than {minimum}")] InvalidCpuDestinationStride { minimum: usize, actual: usize }, #[error("CPU destination has {actual} bytes, but {required} are required")] diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 75b87f0e1..2fddd6923 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -375,6 +375,46 @@ fn cpu_copy_rejects_non_bgra_input_without_mapping_it() { ); } +#[test] +fn bgra_sdr_conversion_swizzles_channels_and_preserves_alpha() { + let frame = bgra_cpu_frame([30, 20, 10, 127], rgb_color()); + let mut destination = vec![0; 192]; + frame + .convert_bgra8_sdr_to_rgba8(&mut destination, 32) + .expect("sRGB BGRA should convert"); + for pixel in destination.chunks_exact(4) { + assert_eq!(pixel, &[10, 20, 30, 127]); + } +} + +#[test] +fn bgra_sdr_conversion_compresses_wide_gamut_primaries() { + let mut color = rgb_color(); + color.primaries = MacosColorPrimaries::DisplayP3; + let frame = bgra_cpu_frame([0, 0, 255, 255], color); + let mut destination = vec![0; 192]; + frame + .convert_bgra8_sdr_to_rgba8(&mut destination, 32) + .expect("Display P3 red should convert"); + assert_eq!(destination[0], 255); + assert_eq!(destination[1], 0); + assert!(destination[2] < 64); + assert_eq!(destination[3], 255); +} + +#[test] +fn bgra_sdr_conversion_rejects_hdr_transfer_functions() { + let mut color = rgb_color(); + color.transfer = MacosTransferFunction::Pq; + let frame = bgra_cpu_frame([0, 0, 255, 255], color); + assert_eq!( + frame.convert_bgra8_sdr_to_rgba8(&mut [0; 192], 32), + Err(MacosCaptureError::UnsupportedCpuTransferFunction( + MacosTransferFunction::Pq + )) + ); +} + #[test] fn mailbox_replaces_stale_deliveries_without_growing() { let mailbox = MacosFrameMailbox::new(); @@ -443,6 +483,20 @@ fn complete_sample() -> MacosRawCaptureSample { } } +fn bgra_cpu_frame( + pixel: [u8; 4], + color: MacosCaptureColorimetry, +) -> hypercolor_macos_capture::MacosCaptureFrame { + let mut sample = complete_sample(); + let source = pixel.repeat(48); + let frame = complete_frame_mut(&mut sample); + frame.color = color; + frame.surface = + MacosCaptureSurface::new_cpu_fixture(7, 192, 99, vec![Arc::<[u8]>::from(source)]) + .expect("CPU fixture surface should be valid"); + decode_frame(&mut MacosFrameDecoder::new(1), sample) +} + fn complete_frame_mut(sample: &mut MacosRawCaptureSample) -> &mut MacosRawCompleteFrame { sample.frame.as_mut().expect("fixture frame should exist") } From 6079bfab580e42505960d656951ea48ce54fbb82 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:11:04 -0700 Subject: [PATCH 026/144] feat(macos): wake capture workers on frames 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) --- .../hypercolor-macos-capture/src/mailbox.rs | 30 +++++++++++++++++-- .../tests/capture_contract_tests.rs | 15 ++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/mailbox.rs b/crates/hypercolor-macos-capture/src/mailbox.rs index 9923af8af..0224c2f7a 100644 --- a/crates/hypercolor-macos-capture/src/mailbox.rs +++ b/crates/hypercolor-macos-capture/src/mailbox.rs @@ -1,10 +1,17 @@ -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::Duration; use crate::{MacosCaptureError, MacosFrameEvent}; #[derive(Debug, Clone, Default)] pub struct MacosFrameMailbox { - state: Arc>, + inner: Arc, +} + +#[derive(Debug, Default)] +struct MailboxInner { + state: Mutex, + ready: Condvar, } #[derive(Debug, Default)] @@ -23,6 +30,8 @@ impl MacosFrameMailbox { if state.latest.replace(delivery).is_some() { state.superseded = state.superseded.saturating_add(1); } + drop(state); + self.inner.ready.notify_one(); } pub fn take_latest(&self) -> Option> { @@ -37,8 +46,23 @@ impl MacosFrameMailbox { self.lock().superseded } + pub fn wait_latest( + &self, + timeout: Duration, + ) -> Option> { + let state = self.lock(); + let mut state = self + .inner + .ready + .wait_timeout_while(state, timeout, |state| state.latest.is_none()) + .unwrap_or_else(std::sync::PoisonError::into_inner) + .0; + state.latest.take() + } + fn lock(&self) -> MutexGuard<'_, MailboxState> { - self.state + self.inner + .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 2fddd6923..d894ac343 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -433,6 +433,21 @@ fn mailbox_replaces_stale_deliveries_without_growing() { assert!(!mailbox.has_pending()); } +#[test] +fn mailbox_wait_returns_a_ready_delivery_without_polling() { + let mailbox = MacosFrameMailbox::new(); + mailbox.publish(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))); + assert!(matches!( + mailbox.wait_latest(std::time::Duration::from_secs(1)), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))) + )); + assert!( + mailbox + .wait_latest(std::time::Duration::from_millis(0)) + .is_none() + ); +} + #[test] fn callback_diagnostics_start_with_every_drop_reason_at_zero() { let diagnostics = MacosCaptureCallbackDiagnostics::default(); From 1f5489aae9613cbabb0b3843d974ec95b6f28e6e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:21:51 -0700 Subject: [PATCH 027/144] fix(core): gate native media helpers by platform 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) --- crates/hypercolor-core/src/input/media.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/hypercolor-core/src/input/media.rs b/crates/hypercolor-core/src/input/media.rs index eaa7c36f9..4ab9db02b 100644 --- a/crates/hypercolor-core/src/input/media.rs +++ b/crates/hypercolor-core/src/input/media.rs @@ -840,6 +840,7 @@ impl MediaProviderSession { }) } + #[cfg(any(target_os = "linux", target_os = "windows"))] fn disconnect(&mut self) { self.provider.disconnect(); self.connected = false; @@ -1406,6 +1407,7 @@ struct CompletedMediaPoll { enum MediaPublicationKind { BackendSuccess, StateUpdate, + #[cfg(any(target_os = "linux", target_os = "windows"))] BackendFailure, } @@ -1469,6 +1471,7 @@ impl MediaPollPublisher { true } + #[cfg(any(target_os = "linux", target_os = "windows"))] fn publish_unavailable(&self, completed_at: Instant) -> bool { let mut publication = self .publication From df021468c1aa9e1707629b11778c711a5f96630c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:25:28 -0700 Subject: [PATCH 028/144] feat(macos): add event-driven CPU capture worker 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) --- Cargo.lock | 1 + Cargo.toml | 1 + crates/hypercolor-core/Cargo.toml | 7 + .../hypercolor-core/src/input/screen/macos.rs | 778 ++++++++++++++++++ .../hypercolor-core/src/input/screen/mod.rs | 6 + .../tests/macos_screen_capture_tests.rs | 164 ++++ 6 files changed, 957 insertions(+) create mode 100644 crates/hypercolor-core/src/input/screen/macos.rs create mode 100644 crates/hypercolor-core/tests/macos_screen_capture_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 812e5d51e..f4788c5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5001,6 +5001,7 @@ dependencies = [ "hypercolor-driver-api", "hypercolor-hal", "hypercolor-linux-gpu-interop", + "hypercolor-macos-capture", "hypercolor-macos-gpu-interop", "hypercolor-platform-fs", "hypercolor-types", diff --git a/Cargo.toml b/Cargo.toml index cc74ae954..3342a8681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,6 +143,7 @@ hypercolor-core = { path = "crates/hypercolor-core" } hypercolor-platform-fs = { path = "crates/hypercolor-platform-fs" } hypercolor-linux-gpu-interop = { path = "crates/hypercolor-linux-gpu-interop" } hypercolor-macos-gpu-interop = { path = "crates/hypercolor-macos-gpu-interop" } +hypercolor-macos-capture = { path = "crates/hypercolor-macos-capture" } hypercolor-macos-input = { path = "crates/hypercolor-macos-input" } hypercolor-windows-capture = { path = "crates/hypercolor-windows-capture" } hypercolor-windows-gpu-interop = { path = "crates/hypercolor-windows-gpu-interop" } diff --git a/crates/hypercolor-core/Cargo.toml b/crates/hypercolor-core/Cargo.toml index 6f38c7825..9ca4421f4 100644 --- a/crates/hypercolor-core/Cargo.toml +++ b/crates/hypercolor-core/Cargo.toml @@ -17,6 +17,7 @@ allocation-contract-tests = [] spatial-workspace-test-hooks = [] windows-capture-fixtures = [] macos-native-fixtures = [] +macos-capture-fixtures = ["hypercolor-macos-capture/capture-fixtures"] media-lottie = ["dep:rlottie"] media-video = ["dep:gstreamer", "dep:gstreamer-app", "dep:gstreamer-video"] servo = [ @@ -48,6 +49,7 @@ hypercolor-windows-input = { path = "../hypercolor-windows-input" } # the capture crate supplies stubs when DXGI is unavailable. hypercolor-windows-capture = { path = "../hypercolor-windows-capture" } hypercolor-macos-input = { workspace = true } +hypercolor-macos-capture = { workspace = true } hypercolor-driver-api = { workspace = true } hypercolor-hal = { workspace = true } hypercolor-platform-fs = { workspace = true } @@ -148,6 +150,11 @@ name = "spatial_area_reuse_tests" path = "tests/spatial_area_reuse_tests.rs" required-features = ["allocation-contract-tests"] +[[test]] +name = "macos_screen_capture_tests" +path = "tests/macos_screen_capture_tests.rs" +required-features = ["macos-capture-fixtures"] + [[bench]] name = "core_pipeline" harness = false diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs new file mode 100644 index 000000000..70ee2bed9 --- /dev/null +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -0,0 +1,778 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::anyhow; +use hypercolor_macos_capture::{ + MacosCaptureFrame, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosProtectedSourceState, +}; + +#[cfg(target_os = "macos")] +use hypercolor_macos_capture::{ + MacosCaptureCadence, MacosScreenCaptureSession, MacosStreamRequest, +}; + +use super::{ + CaptureConfig, CaptureCursor, CaptureCursorContent, CaptureDamage, CaptureFrame, + CaptureFrameMetadata, CapturePixelFormat, CapturePlanePool, CaptureRotation, CaptureSourceId, + CaptureStorage, CpuCaptureStorage, PixelExtent, PixelRect, RawCaptureSurface, + ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, + ScreenByteAdmissionCoordinator, ScreenCaptureDemand, ScreenCaptureInput, SourceScale, + analyze_screen_frame, +}; +use crate::input::status::SourceSessionSlot; +use crate::input::traits::{InputData, InputSource}; +use crate::input::{SourceKind, SourceStatusHandle, SourceStatusReporter}; + +const WORKER_WAIT: Duration = Duration::from_millis(100); + +trait MacosCaptureControl: Send + Sync { + fn mailbox(&self) -> MacosFrameMailbox; + fn set_active(&self, active: bool); + fn present_picker(&self) -> anyhow::Result<()>; + fn request_authorization(&self) -> MacosProtectedSourceState; + fn status(&self) -> MacosProtectedSourceState; +} + +#[cfg(target_os = "macos")] +struct NativeCaptureControl { + session: MacosScreenCaptureSession, +} + +#[cfg(target_os = "macos")] +impl MacosCaptureControl for NativeCaptureControl { + fn mailbox(&self) -> MacosFrameMailbox { + self.session.mailbox() + } + + fn set_active(&self, active: bool) { + self.session.set_capture_active(active); + } + + fn present_picker(&self) -> anyhow::Result<()> { + self.session.present_picker().map_err(anyhow::Error::from) + } + + fn request_authorization(&self) -> MacosProtectedSourceState { + self.session.request_authorization() + } + + fn status(&self) -> MacosProtectedSourceState { + self.session.status() + } +} + +#[derive(Default)] +struct MacosPublication { + worker_generation: u64, + latest: Option>, +} + +struct PreparedWorker { + analyzer: ScreenCaptureInput, + plane_pool: CapturePlanePool, + target_fps: u32, +} + +struct CaptureWorker { + stop: Arc, + exit_rx: mpsc::Receiver>, + join: Option>, +} + +pub struct MacosScreenCaptureInput { + config: CaptureConfig, + control: Arc, + admission: ScreenByteAdmissionCoordinator, + publication: Arc>, + worker: Option, + worker_generation: u64, + demand: ScreenCaptureDemand, + running: bool, + status: SourceStatusReporter, + status_session: SourceSessionSlot, +} + +impl MacosScreenCaptureInput { + #[cfg(target_os = "macos")] + pub fn new( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + ) -> anyhow::Result { + let request = MacosStreamRequest::new( + MacosCaptureCadence::FramesPerSecond(config.target_fps), + true, + )?; + let session = MacosScreenCaptureSession::new(request)?; + Ok(Self::with_control( + config, + admission, + Arc::new(NativeCaptureControl { session }), + )) + } + + fn with_control( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + control: Arc, + ) -> Self { + let consented = !matches!( + control.status(), + MacosProtectedSourceState::NeedsUserAction + | MacosProtectedSourceState::PermissionDenied + | MacosProtectedSourceState::Revoked + ); + Self { + config, + control, + admission, + publication: Arc::new(Mutex::new(MacosPublication::default())), + worker: None, + worker_generation: 0, + demand: ScreenCaptureDemand::Inactive, + running: false, + status: SourceStatusReporter::new( + "macos:session", + SourceKind::Screen, + "screen_capture_kit_cpu", + true, + consented, + false, + ), + status_session: SourceSessionSlot::new(), + } + } + + pub fn authorize(&mut self) -> anyhow::Result { + let state = self.control.request_authorization(); + self.refresh_policy()?; + Ok(state) + } + + pub fn present_picker(&self) -> anyhow::Result<()> { + self.control.present_picker() + } + + pub fn protected_state(&self) -> MacosProtectedSourceState { + self.control.status() + } + + fn refresh_policy(&mut self) -> anyhow::Result<()> { + self.refresh_policy_for(self.demand) + } + + fn refresh_policy_for(&mut self, demand: ScreenCaptureDemand) -> anyhow::Result<()> { + let consented = !matches!( + self.control.status(), + MacosProtectedSourceState::NeedsUserAction + | MacosProtectedSourceState::PermissionDenied + | MacosProtectedSourceState::Revoked + ); + self.status + .set_policy(true, consented, demand.is_active())?; + Ok(()) + } + + fn prepare_worker(&self, extent: PixelExtent) -> anyhow::Result { + let mut analyzer = ScreenCaptureInput::with_requested_extent_and_admission( + self.config.clone(), + extent, + self.admission.clone(), + )?; + analyzer.start()?; + Ok(PreparedWorker { + analyzer, + plane_pool: CapturePlanePool::with_admission_coordinator(self.admission.clone()), + target_fps: self.config.target_fps, + }) + } + + fn install_worker(&mut self, prepared: PreparedWorker) -> anyhow::Result<()> { + let worker_generation = self + .worker_generation + .checked_add(1) + .ok_or_else(|| anyhow!("macOS capture worker generation exhausted"))?; + let mailbox = self.control.mailbox(); + let publication = Arc::clone(&self.publication); + let status_session = self.status_session.clone(); + let target_fps = prepared.target_fps; + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + let start = Arc::new(AtomicBool::new(false)); + let worker_start = Arc::clone(&start); + let (exit_tx, exit_rx) = mpsc::channel(); + let join = thread::Builder::new() + .name("hypercolor-macos-screen-capture".to_owned()) + .spawn(move || { + while !worker_start.load(Ordering::Acquire) { + thread::park(); + } + let result = if worker_stop.load(Ordering::Acquire) { + Ok(()) + } else { + run_worker( + prepared, + mailbox, + publication, + worker_generation, + target_fps, + status_session, + worker_stop, + ) + }; + let _ = exit_tx.send(result); + })?; + self.stop_worker(); + self.worker_generation = worker_generation; + { + let mut publication = lock(&self.publication); + publication.worker_generation = worker_generation; + publication.latest = None; + } + self.worker = Some(CaptureWorker { + stop, + exit_rx, + join: Some(join), + }); + start.store(true, Ordering::Release); + self.worker + .as_ref() + .and_then(|worker| worker.join.as_ref()) + .expect("installed worker retains its thread handle") + .thread() + .unpark(); + Ok(()) + } + + fn stop_worker(&mut self) { + let Some(mut worker) = self.worker.take() else { + return; + }; + worker.stop.store(true, Ordering::Release); + if let Some(join) = worker.join.take() { + let _ = join.join(); + } + lock(&self.publication).latest = None; + } + + fn observe_worker_exit(&mut self) -> anyhow::Result<()> { + let Some(worker) = self.worker.as_ref() else { + return Ok(()); + }; + match worker.exit_rx.try_recv() { + Ok(Ok(())) => { + self.stop_worker(); + if self.running && self.demand.is_active() { + return Err(anyhow!("macOS capture worker exited while active")); + } + } + Ok(Err(error)) => { + self.stop_worker(); + return Err(error); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.stop_worker(); + return Err(anyhow!("macOS capture worker disconnected")); + } + Err(mpsc::TryRecvError::Empty) => {} + } + Ok(()) + } +} + +impl InputSource for MacosScreenCaptureInput { + fn name(&self) -> &'static str { + "macos_screen_capture" + } + + fn start(&mut self) -> anyhow::Result<()> { + if self.running { + return Ok(()); + } + self.refresh_policy()?; + if let Some(extent) = self.demand.requested_extent() { + let prepared = self.prepare_worker(extent)?; + let session = self.status.begin_session()?; + if let Err(error) = self.install_worker(prepared) { + self.status.stop(); + return Err(error); + } + if let Some(session) = session { + self.status_session.store(session); + } + self.control.set_active(true); + } + self.running = true; + Ok(()) + } + + fn stop(&mut self) { + self.control.set_active(false); + self.status_session.clear(); + self.stop_worker(); + self.status.stop(); + self.demand = ScreenCaptureDemand::Inactive; + self.running = false; + } + + fn sample(&mut self) -> anyhow::Result { + self.observe_worker_exit()?; + if !self.running || !self.demand.is_active() { + return Ok(InputData::None); + } + let publication = lock(&self.publication); + if publication.worker_generation != self.worker_generation { + return Ok(InputData::None); + } + Ok(publication + .latest + .as_deref() + .cloned() + .unwrap_or(InputData::None)) + } + + fn sample_shared_and_drain_into( + &mut self, + _delta_secs: f32, + _events: &mut Vec, + ) -> anyhow::Result>> { + self.observe_worker_exit()?; + if !self.running || !self.demand.is_active() { + return Ok(None); + } + let publication = lock(&self.publication); + Ok((publication.worker_generation == self.worker_generation) + .then(|| publication.latest.clone()) + .flatten()) + } + + fn is_running(&self) -> bool { + self.running + } + + fn source_status_handle(&self) -> Option { + Some(self.status.handle()) + } + + fn source_status_reporter(&mut self) -> Option<&mut SourceStatusReporter> { + Some(&mut self.status) + } + + fn is_screen_source(&self) -> bool { + true + } + + fn screen_capture_demand(&self) -> ScreenCaptureDemand { + self.demand + } + + fn screen_analysis_resource_plan( + &self, + demand: ScreenCaptureDemand, + ) -> anyhow::Result> { + let Some(extent) = demand.requested_extent() else { + return Ok(None); + }; + Ok(Some(ScreenAnalysisResourcePlan::try_new_for_extent( + self.config.grid_cols, + self.config.grid_rows, + self.config.target_fps, + extent, + u64::MAX, + )?)) + } + + fn screen_analysis_work_plan( + &self, + demand: ScreenCaptureDemand, + ) -> anyhow::Result> { + let Some(extent) = demand.requested_extent() else { + return Ok(None); + }; + Ok(Some(ScreenAnalysisWorkPlan::try_new( + extent, + extent, + &self.config, + )?)) + } + + fn screen_analysis_compute_capacity(&self) -> Option { + None + } + + fn set_screen_capture_demand(&mut self, demand: ScreenCaptureDemand) -> anyhow::Result<()> { + let prepared = demand + .requested_extent() + .map(|extent| self.prepare_worker(extent)) + .transpose()?; + let was_active = self.demand.is_active(); + if !self.running { + self.refresh_policy_for(demand)?; + self.demand = demand; + return Ok(()); + } + if let Some(prepared) = prepared { + let session = if was_active { + None + } else { + self.refresh_policy_for(demand)?; + self.status.begin_session()? + }; + if let Err(error) = self.install_worker(prepared) { + if !was_active { + self.refresh_policy_for(self.demand)?; + } + return Err(error); + } + if let Some(session) = session { + self.status_session.store(session); + } + self.control.set_active(true); + } else { + self.control.set_active(false); + self.status_session.clear(); + self.stop_worker(); + self.refresh_policy_for(demand)?; + } + self.demand = demand; + Ok(()) + } + + fn reconfigure_screen_capture(&mut self, config: &CaptureConfig) -> anyhow::Result<()> { + let prepared = self + .demand + .requested_extent() + .map(|extent| { + let mut analyzer = ScreenCaptureInput::with_requested_extent_and_admission( + config.clone(), + extent, + self.admission.clone(), + )?; + analyzer.start()?; + Ok::<_, anyhow::Error>(PreparedWorker { + analyzer, + plane_pool: CapturePlanePool::with_admission_coordinator( + self.admission.clone(), + ), + target_fps: config.target_fps, + }) + }) + .transpose()?; + if self.running + && let Some(prepared) = prepared + { + self.install_worker(prepared)?; + } + self.config.clone_from(config); + Ok(()) + } + + fn reselect_screen_source(&mut self) -> anyhow::Result<()> { + self.present_picker() + } +} + +impl Drop for MacosScreenCaptureInput { + fn drop(&mut self) { + self.control.set_active(false); + self.stop_worker(); + } +} + +fn run_worker( + mut prepared: PreparedWorker, + mailbox: MacosFrameMailbox, + publication: Arc>, + worker_generation: u64, + target_fps: u32, + status_session: SourceSessionSlot, + stop: Arc, +) -> anyhow::Result<()> { + let source_id = CaptureSourceId::new(Arc::::from("macos:session"))?; + let mut topology = TopologyState::default(); + while !stop.load(Ordering::Acquire) { + let Some(delivery) = mailbox.wait_latest(WORKER_WAIT) else { + continue; + }; + match delivery { + Ok(MacosFrameEvent::Frame(frame)) => { + publish_frame( + &mut prepared, + *frame, + &source_id, + &mut topology, + &publication, + worker_generation, + target_fps, + &status_session, + )?; + } + Ok(MacosFrameEvent::Lifecycle( + MacosFrameStatus::Suspended | MacosFrameStatus::Stopped, + )) + | Err(_) => lock(&publication).latest = None, + Ok(MacosFrameEvent::Lifecycle(_)) => {} + } + } + prepared.analyzer.stop(); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn publish_frame( + prepared: &mut PreparedWorker, + frame: MacosCaptureFrame, + source_id: &CaptureSourceId, + topology: &mut TopologyState, + publication: &Mutex, + worker_generation: u64, + target_fps: u32, + status_session: &SourceSessionSlot, +) -> anyhow::Result<()> { + let extent = PixelExtent::new(frame.storage_extent.width, frame.storage_extent.height)?; + let row_stride = usize::try_from(extent.width()) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| anyhow!("macOS capture row stride overflow"))?; + let byte_len = row_stride + .checked_mul(usize::try_from(extent.height())?) + .ok_or_else(|| anyhow!("macOS capture plane length overflow"))?; + let mut plane = prepared.plane_pool.try_acquire(byte_len)?; + plane.resize(byte_len, 0); + frame.convert_bgra8_sdr_to_rgba8(&mut plane, row_stride)?; + let captured_at = Instant::now(); + let fresh_until = captured_at + .checked_add(Duration::from_nanos( + 2_000_000_000_u64.div_ceil(u64::from(target_fps)), + )) + .ok_or_else(|| anyhow!("macOS capture freshness deadline overflow"))?; + let topology_generation = topology.observe(&frame)?; + let geometry = super::CaptureGeometry::new( + capture_origin(&frame)?, + extent, + extent, + CaptureRotation::Identity, + None, + SourceScale::ONE, + )?; + let cursor = CaptureCursor { + visible: frame.cursor_composed, + position: None, + hotspot: None, + shape_extent: None, + shape_generation: None, + content: if frame.cursor_composed { + CaptureCursorContent::Composed + } else { + CaptureCursorContent::Hidden + }, + }; + let damage = CaptureDamage::new( + frame + .damage + .iter() + .map(|rect| { + Ok(PixelRect::new( + u32::try_from(rect.x)?, + u32::try_from(rect.y)?, + rect.width, + rect.height, + )?) + }) + .collect::>>()?, + Vec::new(), + ); + let sequence = frame + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; + let capture = CaptureFrame::::new( + CaptureFrameMetadata { + source_id: source_id.clone(), + topology_generation, + session_generation: frame.epoch, + sequence, + captured_at, + fresh_until, + geometry, + colorimetry: super::CaptureColorimetry::SRGB, + cursor, + }, + CaptureStorage::Cpu(CpuCaptureStorage::from_owner( + plane.freeze(), + CapturePixelFormat::Rgba8, + i64::try_from(row_stride)?, + 0, + )), + damage, + )?; + let snapshot = analyze_screen_frame(&mut prepared.analyzer, capture)?; + if snapshot.geometry_frame().metadata().topology_generation != topology_generation { + return Err(anyhow!("macOS analysis changed topology generation")); + } + let data = Arc::new(InputData::Screen(snapshot.data().clone())); + { + let mut publication = lock(publication); + if publication.worker_generation != worker_generation { + return Ok(()); + } + publication.latest = Some(data); + } + if let Some(status) = status_session.load() { + status.record_sample(captured_at, fresh_until, 1)?; + } + Ok(()) +} + +#[derive(Default)] +struct TopologyState { + descriptor: Option, + generation: u64, +} + +impl TopologyState { + fn observe(&mut self, frame: &MacosCaptureFrame) -> anyhow::Result { + let descriptor = TopologyDescriptor::from_frame(frame); + if self.descriptor.as_ref() != Some(&descriptor) { + self.generation = self + .generation + .checked_add(1) + .ok_or_else(|| anyhow!("macOS topology generation exhausted"))?; + self.descriptor = Some(descriptor); + } + Ok(self.generation) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TopologyDescriptor { + width: u32, + height: u32, + content: (i64, i64, u32, u32), + scale_bits: u64, + screen: Option<(u64, u64, u64, u64)>, +} + +impl TopologyDescriptor { + fn from_frame(frame: &MacosCaptureFrame) -> Self { + let content = frame.geometry.content_rect_pixels; + Self { + width: frame.storage_extent.width, + height: frame.storage_extent.height, + content: (content.x, content.y, content.width, content.height), + scale_bits: frame.geometry.display_scale_factor.get().to_bits(), + screen: frame.geometry.screen_rect_points.map(|rect| { + ( + rect.x.to_bits(), + rect.y.to_bits(), + rect.width.to_bits(), + rect.height.to_bits(), + ) + }), + } + } +} + +fn capture_origin(frame: &MacosCaptureFrame) -> anyhow::Result { + let rect = frame + .geometry + .screen_rect_points + .unwrap_or(frame.geometry.content_rect_points); + let scale = frame.geometry.display_scale_factor.get(); + Ok(super::PhysicalOrigin { + x: scaled_coordinate(rect.x, scale)?, + y: scaled_coordinate(rect.y, scale)?, + }) +} + +fn scaled_coordinate(value: f64, scale: f64) -> anyhow::Result { + let value = (value * scale).floor(); + if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) { + return Err(anyhow!("macOS capture origin exceeds i32")); + } + Ok(value as i32) +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(feature = "macos-capture-fixtures")] +struct FixtureControl { + mailbox: MacosFrameMailbox, + active: AtomicBool, + status: Mutex, +} + +#[cfg(feature = "macos-capture-fixtures")] +impl Default for FixtureControl { + fn default() -> Self { + Self { + mailbox: MacosFrameMailbox::default(), + active: AtomicBool::new(false), + status: Mutex::new(MacosProtectedSourceState::ReadyIdle), + } + } +} + +#[cfg(feature = "macos-capture-fixtures")] +impl MacosCaptureControl for FixtureControl { + fn mailbox(&self) -> MacosFrameMailbox { + self.mailbox.clone() + } + + fn set_active(&self, active: bool) { + self.active.store(active, Ordering::Release); + *lock(&self.status) = if active { + MacosProtectedSourceState::Starting + } else { + MacosProtectedSourceState::ReadyIdle + }; + } + + fn present_picker(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn request_authorization(&self) -> MacosProtectedSourceState { + MacosProtectedSourceState::NeedsSelection + } + + fn status(&self) -> MacosProtectedSourceState { + *lock(&self.status) + } +} + +#[cfg(feature = "macos-capture-fixtures")] +pub struct MacosScreenCaptureFixture { + control: Arc, +} + +#[cfg(feature = "macos-capture-fixtures")] +impl MacosScreenCaptureFixture { + pub fn source( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + ) -> (MacosScreenCaptureInput, Self) { + let control = Arc::new(FixtureControl { + status: Mutex::new(MacosProtectedSourceState::ReadyIdle), + ..FixtureControl::default() + }); + let source = MacosScreenCaptureInput::with_control(config, admission, control.clone()); + (source, Self { control }) + } + + pub fn publish(&self, frame: MacosCaptureFrame) { + self.control + .mailbox + .publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); + } + + pub fn is_active(&self) -> bool { + self.control.active.load(Ordering::Acquire) + } +} diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 0fcc1ea07..9ba96a0e6 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -24,11 +24,13 @@ mod fanout; mod frame; mod hub; mod ledger; +mod macos; mod materialize; mod plan; mod process; mod publication; mod reducer; +#[cfg(any(target_os = "linux", target_os = "windows"))] mod retained; mod sampling; pub mod sector; @@ -89,6 +91,9 @@ pub use hub::{ pub use ledger::{ ScreenWorkerExactLedger, ScreenWorkerExactLedgerBuilder, ScreenWorkerLedgerBuildError, }; +#[cfg(feature = "macos-capture-fixtures")] +pub use macos::MacosScreenCaptureFixture; +pub use macos::MacosScreenCaptureInput; pub use materialize::{ CpuSurfaceMaterializationError, CpuZoneMaterializationError, PreparedCpuSurfaceMaterializer, PreparedCpuZoneMaterializer, StagedCpuZonePublication, @@ -132,6 +137,7 @@ pub use reducer::{ CpuReductionExecutor, CpuReductionLayout, CpuReductionRequest, CpuSurfaceReductionJob, PreparedCpuMaterializationWorkspace, PreparedCpuReductionBatch, }; +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) use retained::{ExactBoxList, ExactBoxNode}; pub use sampling::{ CpuMappedSamplingPoint, CpuSamplingError, CpuSamplingPoint, CpuSamplingView, diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs new file mode 100644 index 000000000..ecada4eeb --- /dev/null +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -0,0 +1,164 @@ +//! ScreenCaptureKit core worker fixture contracts. + +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use hypercolor_core::input::screen::{ + CaptureConfig, MacosScreenCaptureFixture, PixelExtent, ScreenAdmissionCapacity, + ScreenByteAdmissionCoordinator, ScreenCaptureDemand, +}; +use hypercolor_core::input::{InputData, InputSource}; +use hypercolor_macos_capture::{ + MacosAttachment, MacosCaptureColorimetry, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, + MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, + MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosTransferFunction, +}; + +const BGRA8: u32 = 0x4247_5241; + +fn fixture_frame(epoch: u64, pixel: [u8; 4]) -> MacosCaptureFrame { + let extent = MacosPixelExtent::new(4, 2).expect("fixture extent is valid"); + let stride = 16; + let bytes = Arc::<[u8]>::from(pixel.repeat(8)); + let surface = MacosCaptureSurface::new_cpu_fixture(7, 32, epoch, vec![bytes]) + .expect("fixture surface is valid"); + let sample = MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: extent, + planes: vec![MacosRawCapturePlane { + index: 0, + extent, + bytes_per_row: stride, + length_bytes: 32, + }], + pixel_format_fourcc: BGRA8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + cursor_composed: true, + surface, + }), + attachments: MacosRawFrameAttachments { + status: MacosAttachment::Value(0), + display_time: MacosAttachment::Value(epoch * 1_000), + display_scale_factor: MacosAttachment::Value(1.0), + content_scale: MacosAttachment::Value(1.0), + content_rect: MacosAttachment::Value( + MacosPointRect::new(0.0, 0.0, 4.0, 2.0).expect("content rect is valid"), + ), + dirty_rects: MacosAttachment::Missing, + screen_rect: MacosAttachment::Missing, + bounding_rect: MacosAttachment::Missing, + }, + }; + let mut decoder = MacosFrameDecoder::new(epoch); + let MacosFrameEvent::Frame(frame) = decoder.decode(sample).expect("fixture frame decodes") + else { + panic!("complete sample must decode as a frame"); + }; + assert_eq!(frame.pixel_format, MacosCapturePixelFormat::Bgra8); + *frame +} + +fn fixture_source( + config: CaptureConfig, +) -> ( + hypercolor_core::input::screen::MacosScreenCaptureInput, + MacosScreenCaptureFixture, +) { + let admission = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + MacosScreenCaptureFixture::source(config, admission) +} + +fn wait_for_screen(source: &mut impl InputSource) -> hypercolor_core::input::ScreenData { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match source.sample().expect("fixture sample succeeds") { + InputData::Screen(data) => return data, + InputData::None if Instant::now() < deadline => thread::yield_now(), + InputData::None => panic!("fixture worker did not publish before the deadline"), + _ => panic!("macOS fixture published the wrong input kind"), + } + } +} + +#[test] +fn fixture_capture_activates_only_for_live_demand() { + let config = CaptureConfig { + target_fps: 60, + grid_cols: 2, + grid_rows: 1, + smoothing_alpha: 1.0, + ..CaptureConfig::default() + }; + let (mut source, fixture) = fixture_source(config); + + assert_eq!(source.name(), "macos_screen_capture"); + assert_eq!( + source.protected_state(), + MacosProtectedSourceState::ReadyIdle + ); + assert!(!fixture.is_active()); + source.start().expect("fixture source starts idle"); + assert!(matches!(source.sample(), Ok(InputData::None))); + + source + .set_screen_capture_demand(ScreenCaptureDemand::try_active(4, 2).expect("valid demand")) + .expect("fixture demand activates"); + assert!(fixture.is_active()); + fixture.publish(fixture_frame(1, [0, 0, 255, 255])); + let data = wait_for_screen(&mut source); + assert_eq!(data.grid_width, 2); + assert_eq!(data.grid_height, 1); + assert_eq!(data.source_width, 4); + assert_eq!(data.source_height, 2); + assert_eq!(data.zone_colors.len(), 2); + + source + .set_screen_capture_demand(ScreenCaptureDemand::Inactive) + .expect("fixture demand deactivates"); + assert!(!fixture.is_active()); + assert!(matches!(source.sample(), Ok(InputData::None))); +} + +#[test] +fn reconfiguration_fences_the_previous_worker_generation() { + let config = CaptureConfig { + target_fps: 60, + grid_cols: 2, + grid_rows: 1, + smoothing_alpha: 1.0, + ..CaptureConfig::default() + }; + let (mut source, fixture) = fixture_source(config.clone()); + source.start().expect("fixture source starts idle"); + source + .set_screen_capture_demand(ScreenCaptureDemand::active( + PixelExtent::new(4, 2).expect("fixture demand is valid"), + )) + .expect("fixture demand activates"); + fixture.publish(fixture_frame(1, [255, 0, 0, 255])); + assert_eq!(wait_for_screen(&mut source).zone_colors.len(), 2); + + source + .reconfigure_screen_capture(&CaptureConfig { + grid_cols: 1, + grid_rows: 1, + ..config + }) + .expect("fixture worker reconfigures"); + assert!(matches!(source.sample(), Ok(InputData::None))); + + fixture.publish(fixture_frame(2, [0, 255, 0, 255])); + let data = wait_for_screen(&mut source); + assert_eq!(data.grid_width, 1); + assert_eq!(data.grid_height, 1); + assert_eq!(data.zone_colors.len(), 1); +} From 54220a72acca16304c79d8e4d8afb671dd12db83 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:35:04 -0700 Subject: [PATCH 029/144] feat(macos): wire screen capture into daemon startup Install ScreenCaptureKit as a first-class daemon screen source with the same host-memory capacity plan and config transaction used by Linux and Windows. Keep selection persistence platform-scoped until macOS exposes a stable choice. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-daemon/Cargo.toml | 3 + crates/hypercolor-daemon/src/api/config.rs | 20 ++--- .../hypercolor-daemon/src/startup/services.rs | 90 ++++++++++++------- 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/crates/hypercolor-daemon/Cargo.toml b/crates/hypercolor-daemon/Cargo.toml index d9f8afd7d..ec31db642 100644 --- a/crates/hypercolor-daemon/Cargo.toml +++ b/crates/hypercolor-daemon/Cargo.toml @@ -83,6 +83,9 @@ pollster = { workspace = true, optional = true } sd-notify = "0.4" sysinfo = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +sysinfo = { workspace = true } + [target.'cfg(target_os = "windows")'.dependencies] hypercolor-windows-capture = { workspace = true, optional = true } hypercolor-windows-gpu-interop = { workspace = true, features = ["screen-capture"], optional = true } diff --git a/crates/hypercolor-daemon/src/api/config.rs b/crates/hypercolor-daemon/src/api/config.rs index 7a97218f9..34865e7fe 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -688,7 +688,7 @@ async fn apply_capture_config_transaction( "config manager unavailable" ))); }; - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let (plan, capacity_plan, capacity_preparation, admission_coordinator) = { let input_manager = state.input_manager.lock().await; let plan = input_manager.plan_screen_runtime_config(capture.enabled); @@ -725,13 +725,13 @@ async fn apply_capture_config_transaction( input_manager.screen_admission_coordinator(), ) }; - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let plan = { let input_manager = state.input_manager.lock().await; input_manager.plan_screen_runtime_config(capture.enabled) }; let (mut replacement, persistence) = if plan.enabled() { - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let (mut source, persistence) = crate::startup::services::prepare_platform_screen_capture_source( &capture, @@ -741,7 +741,7 @@ async fn apply_capture_config_transaction( capacity_plan.total_capacity(), ) .map_err(CaptureConfigTransactionError::Prepare)?; - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let (mut source, persistence) = crate::startup::services::prepare_platform_screen_capture_source( &capture, @@ -792,7 +792,7 @@ async fn apply_capture_config_transaction( stop_prepared_capture_source(replacement).await; return Err(CaptureConfigTransactionError::Commit(error)); } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] if let Some(capacity_preparation) = &capacity_preparation && let Err(error) = input_manager.validate_screen_capacity(capacity_preparation) { @@ -837,7 +837,7 @@ async fn apply_capture_config_transaction( } } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let retirement = if let Some(capacity_preparation) = capacity_preparation { input_manager.commit_screen_capacity_and_runtime_config( capacity_preparation, @@ -848,7 +848,7 @@ async fn apply_capture_config_transaction( input_manager.commit_screen_runtime_config(&plan, &mut replacement) } .expect("screen capacity and runtime were validated under the same input-manager lock"); - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let retirement = input_manager .commit_screen_runtime_config(&plan, &mut replacement) .expect("screen runtime plan was validated under the same input-manager lock"); @@ -1215,7 +1215,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use hypercolor_core::config::ConfigManager; - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use hypercolor_core::input::screen::ScreenAdmissionCapacity; use hypercolor_core::input::screen::{PixelExtent, ScreenCaptureDemand}; use hypercolor_core::input::{ @@ -1513,7 +1513,7 @@ mod tests { assert!(stopped.load(Ordering::Acquire)); } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[tokio::test] async fn capture_transaction_applies_publication_capacity_with_config() { let tempdir = tempfile::tempdir().expect("temporary config directory should build"); @@ -1558,7 +1558,7 @@ mod tests { ); } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[tokio::test] async fn capture_transaction_conflict_preserves_publication_capacity() { let tempdir = tempfile::tempdir().expect("temporary config directory should build"); diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index 2ae2357d2..c20379059 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -9,7 +9,7 @@ use std::time::Instant; use anyhow::{Context, Result}; use arc_swap::ArcSwap; -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use sysinfo::{MemoryRefreshKind, RefreshKind, System}; use tokio::sync::{Mutex, RwLock, watch}; use tracing::{info, warn}; @@ -32,15 +32,17 @@ use hypercolor_core::input::MacosHostInput; #[cfg(target_os = "windows")] use hypercolor_core::input::WindowsHostInput; use hypercolor_core::input::audio::AudioInput; -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use hypercolor_core::input::screen::CaptureConfig as ScreenCaptureConfig; +#[cfg(target_os = "macos")] +use hypercolor_core::input::screen::MacosScreenCaptureInput; #[cfg(target_os = "linux")] use hypercolor_core::input::screen::WaylandScreenCaptureInput; #[cfg(target_os = "windows")] use hypercolor_core::input::screen::{ CaptureSourceSink, ResolvedCaptureSource, WindowsScreenCaptureInput, }; -#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] use hypercolor_core::input::screen::{ScreenAdmissionCapacity, ScreenAnalysisResourcePlan}; use hypercolor_core::input::{InputManager, SensorPoller, SourceStatusHandle}; use hypercolor_core::scene::SceneManager; @@ -648,9 +650,9 @@ pub(crate) fn build_input_manager( config_manager: &Arc, ) -> Result<(InputManager, hypercolor_core::input::BrowserInputHandle)> { let mut input_manager = InputManager::new(); - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let capacity_plan = screen_capacity_plan(&config.capture)?; - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] input_manager.set_screen_capacity_plan( capacity_plan.resource_capacity(), capacity_plan.total_capacity(), @@ -688,7 +690,7 @@ pub(crate) fn build_input_manager( } if config.capture.enabled { - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] { let admission_coordinator = input_manager.screen_admission_coordinator(); input_manager.add_source(build_platform_screen_capture_source( @@ -698,7 +700,7 @@ pub(crate) fn build_input_manager( capacity_plan.total_capacity(), )?); } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] input_manager.add_source(build_platform_screen_capture_source( &config.capture, Arc::clone(config_manager), @@ -709,14 +711,14 @@ pub(crate) fn build_input_manager( Ok((input_manager, browser_input)) } -#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ScreenCapacityPlan { resource: ScreenAdmissionCapacity, total: ScreenAdmissionCapacity, } -#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] impl ScreenCapacityPlan { pub(crate) const fn resource_capacity(self) -> ScreenAdmissionCapacity { self.resource @@ -727,7 +729,7 @@ impl ScreenCapacityPlan { } } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub(crate) fn screen_capacity_plan( capture: &hypercolor_types::config::CaptureConfig, ) -> Result { @@ -735,7 +737,7 @@ pub(crate) fn screen_capacity_plan( screen_capacity_plan_for_backend(capture, backend_capacity) } -#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] pub(crate) fn screen_capacity_plan_for_backend( capture: &hypercolor_types::config::CaptureConfig, backend_capacity: u64, @@ -749,7 +751,7 @@ pub(crate) fn screen_capacity_plan_for_backend( Ok(ScreenCapacityPlan { resource, total }) } -#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] pub(crate) fn screen_analysis_plan_for_demand( capture: &hypercolor_types::config::CaptureConfig, demand: hypercolor_core::input::screen::ScreenCaptureDemand, @@ -769,7 +771,7 @@ pub(crate) fn screen_analysis_plan_for_demand( .context("screen analysis demand exceeds configured steady capacity") } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn available_host_memory_bytes() -> Result { let mut system = System::new_with_specifics( RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), @@ -782,7 +784,7 @@ fn available_host_memory_bytes() -> Result { Ok(available) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub(crate) fn build_platform_screen_capture_source( capture: &hypercolor_types::config::CaptureConfig, config_manager: Arc, @@ -799,7 +801,7 @@ pub(crate) fn build_platform_screen_capture_source( ) } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub(crate) fn build_platform_screen_capture_source( capture: &hypercolor_types::config::CaptureConfig, config_manager: Arc, @@ -809,7 +811,7 @@ pub(crate) fn build_platform_screen_capture_source( build_platform_screen_capture_source_with_persistence(capture, persistence) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub(crate) fn prepare_platform_screen_capture_source( capture: &hypercolor_types::config::CaptureConfig, config_manager: Arc, @@ -830,7 +832,7 @@ pub(crate) fn prepare_platform_screen_capture_source( Ok((source, persistence)) } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub(crate) fn prepare_platform_screen_capture_source( capture: &hypercolor_types::config::CaptureConfig, config_manager: Arc, @@ -845,7 +847,7 @@ pub(crate) fn prepare_platform_screen_capture_source( Ok((source, persistence)) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn build_platform_screen_capture_source_with_persistence( capture: &hypercolor_types::config::CaptureConfig, persistence: CaptureConfigPersistenceGate, @@ -866,6 +868,8 @@ fn build_platform_screen_capture_source_with_persistence( admission_coordinator, capacity, )?; + #[cfg(target_os = "macos")] + let source = build_macos_screen_capture_source(capture, admission_coordinator, capacity)?; let status = source .source_status_handle() .context("screen capture source must expose lifecycle status")?; @@ -873,7 +877,7 @@ fn build_platform_screen_capture_source_with_persistence( Ok(source) } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn build_platform_screen_capture_source_with_persistence( capture: &hypercolor_types::config::CaptureConfig, persistence: CaptureConfigPersistenceGate, @@ -919,8 +923,6 @@ enum CaptureConfigPersistenceUpdate { configured: Option, resolved: Option, }, - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - Unsupported, } impl CaptureConfigPersistenceGate { @@ -976,6 +978,7 @@ impl CaptureConfigPersistenceGate { source_identity(&state) } + #[cfg(any(target_os = "linux", target_os = "windows"))] fn publish(&self, update: CaptureConfigPersistenceUpdate) { let persistence = { let mut state = self @@ -1066,6 +1069,7 @@ impl CaptureConfigPersistenceGate { self.inner.config_manager.revoke_capture_persistence(epoch); } + #[cfg(any(target_os = "linux", target_os = "windows"))] fn persist( &self, epoch: CapturePersistenceEpoch, @@ -1094,8 +1098,6 @@ impl CaptureConfigPersistenceGate { snapshot.capture.restore_token != *resolved } } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - CaptureConfigPersistenceUpdate::Unsupported => false, }; if !should_persist { return; @@ -1110,8 +1112,6 @@ impl CaptureConfigPersistenceGate { CaptureConfigPersistenceUpdate::RestoreToken { resolved, .. } => { capture.restore_token = resolved; } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - CaptureConfigPersistenceUpdate::Unsupported => {} }; let result = match source { Some(source) => config_manager.modify_capture_if_authorized(epoch, source, mutate), @@ -1131,6 +1131,17 @@ impl CaptureConfigPersistenceGate { } } } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + fn persist( + &self, + _epoch: CapturePersistenceEpoch, + _source: Option, + update: CaptureConfigPersistenceUpdate, + _deferred: bool, + ) { + match update {} + } } fn source_identity(state: &CaptureConfigPersistenceState) -> Option { @@ -1152,17 +1163,21 @@ fn source_identity(state: &CaptureConfigPersistenceState) -> Option bool { match update { #[cfg(target_os = "windows")] CaptureConfigPersistenceUpdate::WindowsSource(_) => true, #[cfg(target_os = "linux")] CaptureConfigPersistenceUpdate::RestoreToken { .. } => false, - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - CaptureConfigPersistenceUpdate::Unsupported => false, } } +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +const fn requires_source_identity(_update: &CaptureConfigPersistenceUpdate) -> bool { + false +} + #[cfg(target_os = "windows")] fn windows_capture_source_sink(persistence: CaptureConfigPersistenceGate) -> CaptureSourceSink { Arc::new(move |resolved: ResolvedCaptureSource| { @@ -1186,6 +1201,18 @@ pub(crate) fn build_windows_screen_capture_source( )) } +#[cfg(target_os = "macos")] +pub(crate) fn build_macos_screen_capture_source( + capture: &hypercolor_types::config::CaptureConfig, + admission_coordinator: hypercolor_core::input::screen::ScreenByteAdmissionCoordinator, + capacity: ScreenAdmissionCapacity, +) -> Result> { + Ok(Box::new(MacosScreenCaptureInput::new( + screen_capture_config_with_capacity_from(capture, capacity)?, + admission_coordinator, + )?)) +} + /// Build the platform host-input capture source, when config allows one. /// /// Every supported platform uses an event-driven native backend that reports @@ -1265,7 +1292,7 @@ pub(crate) fn build_screen_capture_source( )) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub(crate) fn screen_capture_config_from( capture: &hypercolor_types::config::CaptureConfig, ) -> Result { @@ -1301,7 +1328,7 @@ fn windows_screen_capture_config_from( screen_capture_config_with_capacity_from(capture, capacity) } -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn screen_capture_config_with_capacity_from( capture: &hypercolor_types::config::CaptureConfig, capacity: ScreenAdmissionCapacity, @@ -1337,7 +1364,10 @@ fn noise_gate_to_db(noise_gate: f32) -> f32 { 20.0 * linear.log10() } -#[cfg(all(test, any(target_os = "linux", target_os = "windows")))] +#[cfg(all( + test, + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] mod tests; #[cfg(test)] From 4d2fd595fba660b896302e26ff85228051f3ec96 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:39:16 -0700 Subject: [PATCH 030/144] feat(input): add macOS platform status contract Attach explicit macOS authorization, ownership, selection, lifecycle, and Tahoe capability state to the generic source snapshot. Platform updates keep generic health intact and deduplicate identical publications. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-core/src/input/mod.rs | 9 +- crates/hypercolor-core/src/input/status.rs | 199 ++++++++++++++++++++ crates/hypercolor-core/tests/input_tests.rs | 46 ++++- 3 files changed, 247 insertions(+), 7 deletions(-) diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 3c178ae26..7e6d9cd44 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -43,10 +43,13 @@ pub use screen::{ScreenCaptureDemand, ScreenPublicationDemandSnapshot}; pub use scroll::{LegacyWheelProjector, Q16_16_SCALE, q16_16_to_f64}; pub use sensor::SensorPoller; pub use status::{ + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosSelectionState, MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, ScreenCaptureDiagnostics, ScreenCaptureReductionPath, SourceDiagnostics, SourceFreshness, - SourceIssue, SourceKind, SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, - SourceState, SourceStatus, SourceStatusAvailability, SourceStatusError, SourceStatusHandle, - SourceStatusRegistry, SourceStatusRegistrySnapshot, SourceStatusReporter, + SourceIssue, SourceKind, SourcePlatformStatus, SourceResourceScanHealth, SourceSessionSlot, + SourceSessionWriter, SourceState, SourceStatus, SourceStatusAvailability, SourceStatusError, + SourceStatusHandle, SourceStatusRegistry, SourceStatusRegistrySnapshot, SourceStatusReporter, SourceStatusSubscription, SourceStatusWriter, SourceTimestampField, TerminalFailureLatch, classify_source_resource_scan, }; diff --git a/crates/hypercolor-core/src/input/status.rs b/crates/hypercolor-core/src/input/status.rs index 6f25f89dd..64dd968f5 100644 --- a/crates/hypercolor-core/src/input/status.rs +++ b/crates/hypercolor-core/src/input/status.rs @@ -101,6 +101,170 @@ impl SourceIssue { } } +/// Precise lifecycle state for one protected macOS capability. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MacosProtectedSourceState { + /// Configuration disables the capability. + Disabled, + /// The next transition requires an explicit local authorization action. + NeedsUserAction, + /// The user denied the requested authorization. + PermissionDenied, + /// Authorization is present but the owning process must restart. + NeedsProcessRestart, + /// Screen capture requires a source choice from Apple's system picker. + NeedsSelection, + /// The capability is authorized and selected but has no active demand. + ReadyIdle, + /// Native resources are being established. + Starting, + /// The capability is active and producing data. + Live, + /// Native delivery stopped transiently and recovery is pending. + Interrupted, + /// A previously usable authorization was revoked. + Revoked, + /// The current configuration failed terminally. + Failed, +} + +/// TCC authorization evidence for one macOS protected resource. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MacosAuthorizationState { + /// The adapter has not queried authorization yet. + Unknown, + /// No positive grant or explicit denial has been observed. + NotDetermined, + /// The user explicitly denied the request. + Denied, + /// The current capability owner has positive grant evidence. + Authorized, +} + +/// Process topology that owns a protected macOS capability. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MacosCapabilityOwner { + /// Daemon process embedded as an app sidecar. + AppSidecar, + /// Main application process. + App, + /// Direct user launchd service. + LaunchdService, + /// Homebrew-managed user service. + HomebrewService, + /// Authenticated app broker. + Broker, + /// Terminal-launched daemon. + Standalone, +} + +/// Bounded record of two macOS daemon topologies contending for ownership. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MacosDaemonOwnerConflict { + /// Topology currently holding the process guard. + pub active: MacosCapabilityOwner, + /// Topology that attempted to start. + pub contender: MacosCapabilityOwner, + /// Unix timestamp of the observed conflict. + pub observed_at_ms: u64, +} + +/// Native architecture of the active macOS host. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MacosArchitecture { + /// Apple Silicon host. + AppleSilicon, + /// Intel host. + Intel, +} + +/// Runtime Tahoe feature probes stable for one process and Metal device. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MacosTahoeCapabilities { + /// Native host architecture, independent of the running executable slice. + pub host_architecture: MacosArchitecture, + /// Whether this process runs under Rosetta translation. + pub translated_process: bool, + /// Whether the Tahoe Core Graphics tone-mapping API is callable. + pub content_tone_mapping_info: bool, + /// Whether the active Metal device exposes every required Metal 4 facility. + pub metal4: bool, +} + +/// Tahoe capabilities resolved for one selected capture incarnation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MacosTahoeSelectionCapabilities { + /// Stable selected source identity. + pub source_id: Arc, + /// Capture session generation that proved these capabilities. + pub capture_session_generation: u64, + /// Whether the selected stream delivered canonical HDR. + pub hdr_capture: bool, + /// Whether paired SDR and HDR diagnostic screenshots are available. + pub dual_range_screenshots: bool, +} + +/// Persistability and content style of the current macOS screen selection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MacosSelectionState { + /// No source is currently selected. + None, + /// A stable display source is selected. + Display { + /// Canonical display UUID source identity. + source_id: Arc, + }, + /// A window, application, or multi-window choice valid for this process. + SessionScoped { + /// Redacted content style suitable for diagnostics. + content_style: Arc, + }, +} + +/// Platform detail for the macOS host-input adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MacosInputPlatformStatus { + /// Keyboard capture lifecycle. + pub keyboard: MacosProtectedSourceState, + /// Pointer capture lifecycle. + pub pointer: MacosProtectedSourceState, + /// Input Monitoring authorization evidence. + pub keyboard_tcc: MacosAuthorizationState, + /// Process topology owning keyboard capture. + pub keyboard_owner: MacosCapabilityOwner, + /// Process topology owning pointer capture. + pub pointer_owner: MacosCapabilityOwner, + /// Latest daemon-owner conflict, when one exists. + pub owner_conflict: Option>, +} + +/// Platform detail for the macOS screen-capture adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MacosScreenPlatformStatus { + /// Screen capture lifecycle. + pub state: MacosProtectedSourceState, + /// Screen Recording authorization evidence. + pub tcc: MacosAuthorizationState, + /// Process topology owning ScreenCaptureKit. + pub owner: MacosCapabilityOwner, + /// Current system-picker selection. + pub selection: MacosSelectionState, + /// Tahoe capabilities for the active selected stream. + pub tahoe_selection: Option, + /// Latest daemon-owner conflict, when one exists. + pub owner_conflict: Option>, +} + +/// Platform-specific detail attached to a generic input-source status. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SourcePlatformStatus { + /// macOS host-input state. + MacosInput(MacosInputPlatformStatus), + /// macOS screen-capture state. + MacosScreen(MacosScreenPlatformStatus), +} + /// Screen-capture reduction implementation reported by source diagnostics. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ScreenCaptureReductionPath { @@ -315,6 +479,8 @@ pub struct SourceStatus { pub issue: Option, /// Structured freshness problem details. pub freshness_issue: Option, + /// Platform-specific state clients must not infer from generic health. + pub platform: Option>, /// Whether the source was permanently removed from its owning graph. pub retired: bool, } @@ -345,6 +511,7 @@ impl SourceStatus { denied_resource_count: 0, issue: None, freshness_issue: None, + platform: None, retired: false, } } @@ -919,6 +1086,30 @@ impl SourceStatusWriter { Ok(()) } + /// Publish platform-specific state without disturbing generic lifecycle. + /// + /// # Errors + /// + /// Returns [`SourceStatusError::Retired`] after source removal. + pub fn set_platform( + &self, + platform: Option, + ) -> Result<(), SourceStatusError> { + let platform = platform.map(Arc::new); + let _control = lock_control(&self.shared); + let current = self.shared.latest.load_full(); + if current.retired { + return Err(SourceStatusError::Retired); + } + if current.platform == platform { + return Ok(()); + } + let mut status = (*current).clone(); + status.platform = platform; + publish_structural(&self.shared, status); + Ok(()) + } + /// Begin a source session with a strictly newer graph generation. /// /// # Errors @@ -1202,6 +1393,14 @@ impl SourceStatusReporter { self.writer.set_backend(backend) } + /// Publish platform-specific state without disturbing generic lifecycle. + pub fn set_platform( + &mut self, + platform: Option, + ) -> Result<(), SourceStatusError> { + self.writer.set_platform(platform) + } + /// Stop and fence the current source session. pub fn stop(&mut self) { self.session = None; diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index 4677b37c9..4b5ae408c 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -16,10 +16,12 @@ use hypercolor_core::input::screen::{ }; use hypercolor_core::input::{ AudioReconfigurationConflict, BrowserInputSource, INPUT_EVENT_RING_CAPACITY, InputData, - InputManager, InputSource, MediaSource, NetSource, ScreenData, ScreenReconfigurationConflict, - SourceFreshness, SourceIssue, SourceKind, SourceResourceScanHealth, SourceSessionSlot, - SourceSessionWriter, SourceState, SourceStatusError, SourceStatusHandle, SourceStatusReporter, - SourceStatusWriter, SourceTimestampField, TerminalFailureLatch, classify_source_resource_scan, + InputManager, InputSource, MacosAuthorizationState, MacosCapabilityOwner, + MacosProtectedSourceState, MacosScreenPlatformStatus, MacosSelectionState, MediaSource, + NetSource, ScreenData, ScreenReconfigurationConflict, SourceFreshness, SourceIssue, SourceKind, + SourcePlatformStatus, SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, + SourceState, SourceStatusError, SourceStatusHandle, SourceStatusReporter, SourceStatusWriter, + SourceTimestampField, TerminalFailureLatch, classify_source_resource_scan, }; use hypercolor_core::types::audio::{AudioData, AudioPipelineConfig, AudioSourceType}; use hypercolor_core::types::event::{InputButtonState, InputEvent, TimedInputEvent, ZoneColors}; @@ -3224,6 +3226,42 @@ fn source_backend_updates_preserve_lifecycle_and_deduplicate() { assert!(Arc::ptr_eq(&updated, &handle.snapshot())); } +#[test] +fn source_platform_updates_preserve_lifecycle_and_deduplicate() { + let (writer, handle) = test_status_writer(); + let before = handle.snapshot(); + let platform = SourcePlatformStatus::MacosScreen(MacosScreenPlatformStatus { + state: MacosProtectedSourceState::NeedsSelection, + tcc: MacosAuthorizationState::Authorized, + owner: MacosCapabilityOwner::AppSidecar, + selection: MacosSelectionState::None, + tahoe_selection: None, + owner_conflict: None, + }); + + writer + .set_platform(Some(platform.clone())) + .expect("platform update should succeed"); + let updated = handle.snapshot(); + assert_eq!(updated.platform.as_deref(), Some(&platform)); + assert_eq!(updated.state, before.state); + assert_eq!( + updated.source_graph_generation, + before.source_graph_generation + ); + assert_eq!(updated.session_generation, before.session_generation); + + writer + .set_platform(Some(platform)) + .expect("same platform status should be a no-op"); + assert!(Arc::ptr_eq(&updated, &handle.snapshot())); + + writer + .set_platform(None) + .expect("platform status should clear"); + assert!(handle.snapshot().platform.is_none()); +} + #[test] fn terminal_failure_latch_probes_once_per_worker_session() { let mut latch = TerminalFailureLatch::default(); From cedf2191724c226939e8fab5bea9ace9358d6ff0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 18:50:13 -0700 Subject: [PATCH 031/144] feat(macos): publish native capture platform status Record system-picker selections as canonical display UUIDs or redacted session-scoped styles. Publish exact TCC, owner, selection, and native lifecycle state through source status without main-thread dispatch. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 172 ++++++++++++++---- .../tests/macos_screen_capture_tests.rs | 46 ++++- crates/hypercolor-macos-capture/Cargo.toml | 1 + .../src/diagnostics.rs | 1 + crates/hypercolor-macos-capture/src/frame.rs | 2 + crates/hypercolor-macos-capture/src/lib.rs | 4 +- crates/hypercolor-macos-capture/src/native.rs | 91 ++++++++- .../hypercolor-macos-capture/src/session.rs | 23 +++ 8 files changed, 293 insertions(+), 47 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 70ee2bed9..a29d3c048 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -5,8 +5,8 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureFrame, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, - MacosProtectedSourceState, + MacosCaptureContentStyle, MacosCaptureFrame, MacosCaptureSelection, MacosFrameEvent, + MacosFrameMailbox, MacosFrameStatus, MacosProtectedSourceState as NativeProtectedSourceState, }; #[cfg(target_os = "macos")] @@ -24,7 +24,11 @@ use super::{ }; use crate::input::status::SourceSessionSlot; use crate::input::traits::{InputData, InputSource}; -use crate::input::{SourceKind, SourceStatusHandle, SourceStatusReporter}; +use crate::input::{ + MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, + MacosScreenPlatformStatus, MacosSelectionState, SourceKind, SourcePlatformStatus, + SourceStatusHandle, SourceStatusReporter, +}; const WORKER_WAIT: Duration = Duration::from_millis(100); @@ -32,8 +36,10 @@ trait MacosCaptureControl: Send + Sync { fn mailbox(&self) -> MacosFrameMailbox; fn set_active(&self, active: bool); fn present_picker(&self) -> anyhow::Result<()>; - fn request_authorization(&self) -> MacosProtectedSourceState; - fn status(&self) -> MacosProtectedSourceState; + fn request_authorization(&self) -> NativeProtectedSourceState; + fn status(&self) -> NativeProtectedSourceState; + fn selection(&self) -> MacosCaptureSelection; + fn authorization(&self) -> MacosAuthorizationState; } #[cfg(target_os = "macos")] @@ -55,13 +61,27 @@ impl MacosCaptureControl for NativeCaptureControl { self.session.present_picker().map_err(anyhow::Error::from) } - fn request_authorization(&self) -> MacosProtectedSourceState { + fn request_authorization(&self) -> NativeProtectedSourceState { self.session.request_authorization() } - fn status(&self) -> MacosProtectedSourceState { + fn status(&self) -> NativeProtectedSourceState { self.session.status() } + + fn selection(&self) -> MacosCaptureSelection { + self.session.selection() + } + + fn authorization(&self) -> MacosAuthorizationState { + if MacosScreenCaptureSession::screen_authorized() { + MacosAuthorizationState::Authorized + } else if self.session.status() == NativeProtectedSourceState::PermissionDenied { + MacosAuthorizationState::Denied + } else { + MacosAuthorizationState::NotDetermined + } + } } #[derive(Default)] @@ -93,6 +113,7 @@ pub struct MacosScreenCaptureInput { running: bool, status: SourceStatusReporter, status_session: SourceSessionSlot, + owner: MacosCapabilityOwner, } impl MacosScreenCaptureInput { @@ -118,13 +139,8 @@ impl MacosScreenCaptureInput { admission: ScreenByteAdmissionCoordinator, control: Arc, ) -> Self { - let consented = !matches!( - control.status(), - MacosProtectedSourceState::NeedsUserAction - | MacosProtectedSourceState::PermissionDenied - | MacosProtectedSourceState::Revoked - ); - Self { + let consented = control.authorization() == MacosAuthorizationState::Authorized; + let mut source = Self { config, control, admission, @@ -142,34 +158,58 @@ impl MacosScreenCaptureInput { false, ), status_session: SourceSessionSlot::new(), - } + owner: MacosCapabilityOwner::Standalone, + }; + source + .refresh_platform_status() + .expect("new macOS screen status is not retired"); + source } - pub fn authorize(&mut self) -> anyhow::Result { + pub fn authorize(&mut self) -> anyhow::Result { let state = self.control.request_authorization(); self.refresh_policy()?; + self.refresh_platform_status()?; Ok(state) } - pub fn present_picker(&self) -> anyhow::Result<()> { - self.control.present_picker() + pub fn present_picker(&mut self) -> anyhow::Result<()> { + let result = self.control.present_picker(); + self.refresh_platform_status()?; + result } - pub fn protected_state(&self) -> MacosProtectedSourceState { + pub fn protected_state(&self) -> NativeProtectedSourceState { self.control.status() } + pub fn set_capability_owner(&mut self, owner: MacosCapabilityOwner) -> anyhow::Result<()> { + self.owner = owner; + self.refresh_platform_status() + } + + fn refresh_platform_status(&mut self) -> anyhow::Result<()> { + let state = self.control.status(); + self.status + .set_platform(Some(SourcePlatformStatus::MacosScreen( + MacosScreenPlatformStatus { + state: map_protected_state(state), + tcc: self.control.authorization(), + owner: self.owner, + selection: map_selection(self.control.selection()), + tahoe_selection: None, + owner_conflict: None, + }, + )))?; + Ok(()) + } + fn refresh_policy(&mut self) -> anyhow::Result<()> { self.refresh_policy_for(self.demand) } fn refresh_policy_for(&mut self, demand: ScreenCaptureDemand) -> anyhow::Result<()> { - let consented = !matches!( - self.control.status(), - MacosProtectedSourceState::NeedsUserAction - | MacosProtectedSourceState::PermissionDenied - | MacosProtectedSourceState::Revoked - ); + let consented = self.control.authorization() == MacosAuthorizationState::Authorized; self.status .set_policy(true, consented, demand.is_active())?; Ok(()) @@ -304,12 +344,15 @@ impl InputSource for MacosScreenCaptureInput { } self.control.set_active(true); } + self.refresh_platform_status()?; self.running = true; Ok(()) } fn stop(&mut self) { self.control.set_active(false); + self.refresh_platform_status() + .expect("live macOS screen status is not retired"); self.status_session.clear(); self.stop_worker(); self.status.stop(); @@ -318,6 +361,7 @@ impl InputSource for MacosScreenCaptureInput { } fn sample(&mut self) -> anyhow::Result { + self.refresh_platform_status()?; self.observe_worker_exit()?; if !self.running || !self.demand.is_active() { return Ok(InputData::None); @@ -338,6 +382,7 @@ impl InputSource for MacosScreenCaptureInput { _delta_secs: f32, _events: &mut Vec, ) -> anyhow::Result>> { + self.refresh_platform_status()?; self.observe_worker_exit()?; if !self.running || !self.demand.is_active() { return Ok(None); @@ -437,6 +482,7 @@ impl InputSource for MacosScreenCaptureInput { self.refresh_policy_for(demand)?; } self.demand = demand; + self.refresh_platform_status()?; Ok(()) } @@ -695,6 +741,43 @@ fn scaled_coordinate(value: f64, scale: f64) -> anyhow::Result { Ok(value as i32) } +const fn map_protected_state(state: NativeProtectedSourceState) -> MacosProtectedSourceState { + match state { + NativeProtectedSourceState::Disabled => MacosProtectedSourceState::Disabled, + NativeProtectedSourceState::NeedsUserAction => MacosProtectedSourceState::NeedsUserAction, + NativeProtectedSourceState::PermissionDenied => MacosProtectedSourceState::PermissionDenied, + NativeProtectedSourceState::NeedsProcessRestart => { + MacosProtectedSourceState::NeedsProcessRestart + } + NativeProtectedSourceState::NeedsSelection => MacosProtectedSourceState::NeedsSelection, + NativeProtectedSourceState::ReadyIdle => MacosProtectedSourceState::ReadyIdle, + NativeProtectedSourceState::Starting => MacosProtectedSourceState::Starting, + NativeProtectedSourceState::Live => MacosProtectedSourceState::Live, + NativeProtectedSourceState::Interrupted => MacosProtectedSourceState::Interrupted, + NativeProtectedSourceState::Revoked => MacosProtectedSourceState::Revoked, + NativeProtectedSourceState::Failed => MacosProtectedSourceState::Failed, + } +} + +fn map_selection(selection: MacosCaptureSelection) -> MacosSelectionState { + match selection { + MacosCaptureSelection::None => MacosSelectionState::None, + MacosCaptureSelection::Display { source_id } => MacosSelectionState::Display { source_id }, + MacosCaptureSelection::SessionScoped { content_style } => { + let content_style = match content_style { + MacosCaptureContentStyle::Window => "window", + MacosCaptureContentStyle::MultipleWindows => "multiple_windows", + MacosCaptureContentStyle::Application => "application", + MacosCaptureContentStyle::MultipleApplications => "multiple_applications", + MacosCaptureContentStyle::Mixed => "mixed", + }; + MacosSelectionState::SessionScoped { + content_style: Arc::from(content_style), + } + } + } +} + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() @@ -705,7 +788,8 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { struct FixtureControl { mailbox: MacosFrameMailbox, active: AtomicBool, - status: Mutex, + status: Mutex, + selection: Mutex, } #[cfg(feature = "macos-capture-fixtures")] @@ -714,7 +798,8 @@ impl Default for FixtureControl { Self { mailbox: MacosFrameMailbox::default(), active: AtomicBool::new(false), - status: Mutex::new(MacosProtectedSourceState::ReadyIdle), + status: Mutex::new(NativeProtectedSourceState::ReadyIdle), + selection: Mutex::new(MacosCaptureSelection::None), } } } @@ -728,9 +813,9 @@ impl MacosCaptureControl for FixtureControl { fn set_active(&self, active: bool) { self.active.store(active, Ordering::Release); *lock(&self.status) = if active { - MacosProtectedSourceState::Starting + NativeProtectedSourceState::Starting } else { - MacosProtectedSourceState::ReadyIdle + NativeProtectedSourceState::ReadyIdle }; } @@ -738,13 +823,29 @@ impl MacosCaptureControl for FixtureControl { Ok(()) } - fn request_authorization(&self) -> MacosProtectedSourceState { - MacosProtectedSourceState::NeedsSelection + fn request_authorization(&self) -> NativeProtectedSourceState { + *lock(&self.status) = NativeProtectedSourceState::NeedsSelection; + NativeProtectedSourceState::NeedsSelection } - fn status(&self) -> MacosProtectedSourceState { + fn status(&self) -> NativeProtectedSourceState { *lock(&self.status) } + + fn selection(&self) -> MacosCaptureSelection { + lock(&self.selection).clone() + } + + fn authorization(&self) -> MacosAuthorizationState { + match self.status() { + NativeProtectedSourceState::PermissionDenied | NativeProtectedSourceState::Revoked => { + MacosAuthorizationState::Denied + } + NativeProtectedSourceState::NeedsUserAction => MacosAuthorizationState::NotDetermined, + NativeProtectedSourceState::Disabled => MacosAuthorizationState::Unknown, + _ => MacosAuthorizationState::Authorized, + } + } } #[cfg(feature = "macos-capture-fixtures")] @@ -759,7 +860,7 @@ impl MacosScreenCaptureFixture { admission: ScreenByteAdmissionCoordinator, ) -> (MacosScreenCaptureInput, Self) { let control = Arc::new(FixtureControl { - status: Mutex::new(MacosProtectedSourceState::ReadyIdle), + status: Mutex::new(NativeProtectedSourceState::ReadyIdle), ..FixtureControl::default() }); let source = MacosScreenCaptureInput::with_control(config, admission, control.clone()); @@ -767,6 +868,7 @@ impl MacosScreenCaptureFixture { } pub fn publish(&self, frame: MacosCaptureFrame) { + *lock(&self.control.status) = NativeProtectedSourceState::Live; self.control .mailbox .publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); @@ -775,4 +877,8 @@ impl MacosScreenCaptureFixture { pub fn is_active(&self) -> bool { self.control.active.load(Ordering::Acquire) } + + pub fn set_selection(&self, selection: MacosCaptureSelection) { + *lock(&self.control.selection) = selection; + } } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index ecada4eeb..e20dd23aa 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -8,12 +8,17 @@ use hypercolor_core::input::screen::{ CaptureConfig, MacosScreenCaptureFixture, PixelExtent, ScreenAdmissionCapacity, ScreenByteAdmissionCoordinator, ScreenCaptureDemand, }; -use hypercolor_core::input::{InputData, InputSource}; +use hypercolor_core::input::{ + InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, + MacosProtectedSourceState as CoreProtectedSourceState, MacosSelectionState, + SourcePlatformStatus, +}; use hypercolor_macos_capture::{ MacosAttachment, MacosCaptureColorimetry, MacosCaptureFrame, MacosCapturePixelFormat, - MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, - MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, - MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosTransferFunction, + MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, + MacosFrameDecoder, MacosFrameEvent, MacosPixelExtent, MacosPointRect, + MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, + MacosRawFrameAttachments, MacosTransferFunction, }; const BGRA8: u32 = 0x4247_5241; @@ -105,6 +110,20 @@ fn fixture_capture_activates_only_for_live_demand() { source.protected_state(), MacosProtectedSourceState::ReadyIdle ); + source + .set_capability_owner(MacosCapabilityOwner::AppSidecar) + .expect("fixture owner status updates"); + let status = source + .source_status_handle() + .expect("macOS fixture exposes status"); + let initial = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = initial.platform.as_deref() else { + panic!("expected macOS screen platform status"); + }; + assert_eq!(platform.state, CoreProtectedSourceState::ReadyIdle); + assert_eq!(platform.tcc, MacosAuthorizationState::Authorized); + assert_eq!(platform.owner, MacosCapabilityOwner::AppSidecar); + assert_eq!(platform.selection, MacosSelectionState::None); assert!(!fixture.is_active()); source.start().expect("fixture source starts idle"); assert!(matches!(source.sample(), Ok(InputData::None))); @@ -113,6 +132,9 @@ fn fixture_capture_activates_only_for_live_demand() { .set_screen_capture_demand(ScreenCaptureDemand::try_active(4, 2).expect("valid demand")) .expect("fixture demand activates"); assert!(fixture.is_active()); + fixture.set_selection(MacosCaptureSelection::Display { + source_id: Arc::from("display:00000000-0000-0000-0000-000000000001"), + }); fixture.publish(fixture_frame(1, [0, 0, 255, 255])); let data = wait_for_screen(&mut source); assert_eq!(data.grid_width, 2); @@ -120,12 +142,28 @@ fn fixture_capture_activates_only_for_live_demand() { assert_eq!(data.source_width, 4); assert_eq!(data.source_height, 2); assert_eq!(data.zone_colors.len(), 2); + let live = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = live.platform.as_deref() else { + panic!("expected live macOS screen platform status"); + }; + assert_eq!(platform.state, CoreProtectedSourceState::Live); + assert_eq!( + platform.selection, + MacosSelectionState::Display { + source_id: Arc::from("display:00000000-0000-0000-0000-000000000001"), + } + ); source .set_screen_capture_demand(ScreenCaptureDemand::Inactive) .expect("fixture demand deactivates"); assert!(!fixture.is_active()); assert!(matches!(source.sample(), Ok(InputData::None))); + let inactive = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = inactive.platform.as_deref() else { + panic!("expected inactive macOS screen platform status"); + }; + assert_eq!(platform.state, CoreProtectedSourceState::ReadyIdle); } #[test] diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml index e6137c5e8..28a6954fe 100644 --- a/crates/hypercolor-macos-capture/Cargo.toml +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -33,6 +33,7 @@ objc2-core-foundation = { workspace = true, features = [ "CFDictionary", "CFNumber", "CFString", + "CFUUID", "objc2", ] } objc2-core-graphics = { workspace = true, features = ["std", "CGGeometry", "CGWindow"] } diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index b1c59fdc3..723e76a7f 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -49,6 +49,7 @@ impl MacosFrameDropReason { | MacosCaptureError::ScreenCapturePermissionRequired | MacosCaptureError::NativeOperation { .. } | MacosCaptureError::RetainNativeFilterFailed + | MacosCaptureError::DisplayUuidUnavailable(_) | MacosCaptureError::PlaneCount { .. } | MacosCaptureError::InvalidPlaneIndex { .. } | MacosCaptureError::InvalidPlaneExtent { .. } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 364f8b5da..cd52fa142 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -800,6 +800,8 @@ pub enum MacosCaptureError { MissingIoSurface, #[error("ScreenCaptureKit filter retention failed")] RetainNativeFilterFailed, + #[error("display {0} has no canonical Core Graphics UUID")] + DisplayUuidUnavailable(u32), #[error("capture surface has no CPU-mappable fixture or pixel buffer")] CpuMappingUnavailable, #[error("mapped CPU planes do not match the validated frame layout")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 2b27d5377..3f8a66774 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -28,4 +28,6 @@ pub use geometry::{ MacosScale, }; pub use mailbox::MacosFrameMailbox; -pub use session::{MacosCaptureCadence, MacosStreamRequest}; +pub use session::{ + MacosCaptureCadence, MacosCaptureContentStyle, MacosCaptureSelection, MacosStreamRequest, +}; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 28b2c7aea..2aaeed8c0 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -1,6 +1,6 @@ use std::cell::{Cell, RefCell}; use std::fmt; -use std::ptr; +use std::ptr::{self, NonNull}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, Weak}; @@ -10,10 +10,10 @@ use objc2::rc::Retained; use objc2::runtime::{AnyObject, ProtocolObject}; use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; use objc2_core_foundation::{ - CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType, CGPoint, CGRect, CGSize, + CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType, CFUUID, CGPoint, CGRect, CGSize, }; use objc2_core_graphics::{ - CGPreflightScreenCaptureAccess, CGRectMakeWithDictionaryRepresentation, + CGDirectDisplayID, CGPreflightScreenCaptureAccess, CGRectMakeWithDictionaryRepresentation, CGRequestScreenCaptureAccess, }; use objc2_core_media::{CMSampleBuffer, CMTime}; @@ -46,18 +46,19 @@ use objc2_screen_capture_kit::{ use crate::diagnostics::CallbackCounters; use crate::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, - MacosCaptureColorimetry, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSurface, - MacosChromaLocation, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, - MacosFrameMailbox, MacosFrameStatus, MacosPixelExtent, MacosPixelRect, MacosPointRect, - MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, - MacosRawFrameAttachments, MacosScale, MacosStreamRequest, MacosTransferFunction, - MacosYuvMatrix, + MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureError, MacosCapturePixelFormat, + MacosCaptureSelection, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, + MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, + MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, }; #[derive(Debug)] struct SessionShared { mailbox: MacosFrameMailbox, status: Mutex, + selection: Mutex, counters: CallbackCounters, current_epoch: AtomicU64, } @@ -67,6 +68,7 @@ impl SessionShared { Self { mailbox: MacosFrameMailbox::new(), status: Mutex::new(status), + selection: Mutex::new(MacosCaptureSelection::None), counters: CallbackCounters::default(), current_epoch: AtomicU64::new(0), } @@ -80,6 +82,14 @@ impl SessionShared { *lock(&self.status) = status; } + fn selection(&self) -> MacosCaptureSelection { + lock(&self.selection).clone() + } + + fn set_selection(&self, selection: MacosCaptureSelection) { + *lock(&self.selection) = selection; + } + fn current_epoch(&self) -> u64 { self.current_epoch.load(Ordering::Acquire) } @@ -246,6 +256,7 @@ unsafe impl Send for NativeFilter {} struct NativeStream { stream: Retained, filter: NativeFilter, + selection: MacosCaptureSelection, _output: Retained, _queue: DispatchRetained, } @@ -264,6 +275,7 @@ impl NativeStream { streams: Weak, ) -> Result { let (configuration, display_filter) = stream_configuration(filter, request)?; + let selection = selection_from_filter(filter)?; // SAFETY: The picker callback supplies a live filter. Retaining it // preserves the immutable selection through stream retirement. let retained_filter = unsafe { @@ -307,6 +319,7 @@ impl NativeStream { Ok(Self { stream, filter: NativeFilter(retained_filter), + selection, _output: output, _queue: queue, }) @@ -385,6 +398,9 @@ impl StreamSlot { }; let previous = state.current.replace(candidate); state.selected_filter = state.current.as_ref().map(|current| current.filter.clone()); + if let Some(current) = &state.current { + self.shared.set_selection(current.selection.clone()); + } self.shared.activate_epoch(epoch); previous }; @@ -425,6 +441,7 @@ impl StreamSlot { } fn store_selection(&self, filter: &SCContentFilter) -> Result<(), MacosCaptureError> { + let selection = selection_from_filter(filter)?; // SAFETY: The picker callback supplies a live immutable filter. The // retained owner remains process-local and is never serialized. let filter = unsafe { @@ -432,6 +449,7 @@ impl StreamSlot { .ok_or(MacosCaptureError::RetainNativeFilterFailed)? }; lock(&self.state).selected_filter = Some(NativeFilter(filter)); + self.shared.set_selection(selection); Ok(()) } @@ -739,6 +757,10 @@ impl MacosScreenCaptureSession { self.shared.status() } + pub fn selection(&self) -> MacosCaptureSelection { + self.shared.selection() + } + pub fn mailbox(&self) -> MacosFrameMailbox { self.shared.mailbox.clone() } @@ -757,6 +779,57 @@ impl MacosScreenCaptureSession { } } +fn selection_from_filter( + filter: &SCContentFilter, +) -> Result { + // SAFETY: Picker-delivered filters are immutable and retain every array + // member for the duration of this metadata query. + unsafe { + let displays = filter.includedDisplays(); + let windows = filter.includedWindows(); + let applications = filter.includedApplications(); + if displays.is_empty() && windows.is_empty() && applications.is_empty() { + return Ok(MacosCaptureSelection::None); + } + if windows.is_empty() && applications.is_empty() && displays.len() == 1 { + let display = displays + .firstObject() + .ok_or(MacosCaptureError::DisplayUuidUnavailable(0))?; + let display_id = display.displayID(); + let uuid = display_uuid(display_id) + .ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))?; + let source_id = CFUUID::new_string(None, Some(&uuid)) + .ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))? + .to_string() + .to_ascii_lowercase(); + return Ok(MacosCaptureSelection::Display { + source_id: Arc::from(format!("display:{source_id}")), + }); + } + let content_style = if !windows.is_empty() && !applications.is_empty() { + MacosCaptureContentStyle::Mixed + } else if windows.len() > 1 { + MacosCaptureContentStyle::MultipleWindows + } else if !windows.is_empty() { + MacosCaptureContentStyle::Window + } else if applications.len() > 1 { + MacosCaptureContentStyle::MultipleApplications + } else { + MacosCaptureContentStyle::Application + }; + Ok(MacosCaptureSelection::SessionScoped { content_style }) + } +} + +fn display_uuid(display_id: CGDirectDisplayID) -> Option> { + unsafe extern "C-unwind" { + fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> Option>; + } + // SAFETY: Core Graphics returns a nullable create-rule CFUUID reference. + // CFRetained assumes the owning +1 reference and balances it on drop. + unsafe { CGDisplayCreateUUIDFromDisplayID(display_id).map(|uuid| CFRetained::from_raw(uuid)) } +} + impl fmt::Debug for MacosScreenCaptureSession { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs index 441735d30..d94424f73 100644 --- a/crates/hypercolor-macos-capture/src/session.rs +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -1,5 +1,28 @@ +use std::sync::Arc; + use crate::MacosCaptureError; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosCaptureContentStyle { + Window, + MultipleWindows, + Application, + MultipleApplications, + Mixed, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub enum MacosCaptureSelection { + #[default] + None, + Display { + source_id: Arc, + }, + SessionScoped { + content_style: MacosCaptureContentStyle, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MacosCaptureCadence { NativeRefresh, From 340539add5ef00215e5981b6e60e64df164d79a7 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:00:12 -0700 Subject: [PATCH 032/144] feat(macos): calibrate capture display time Convert ScreenCaptureKit mach display timestamps into Rust monotonic time with the native timebase. Freshness now reflects WindowServer display age instead of capture-worker queue latency. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 43 +++++-- .../tests/macos_screen_capture_tests.rs | 8 +- crates/hypercolor-macos-capture/src/clock.rs | 107 ++++++++++++++++++ crates/hypercolor-macos-capture/src/lib.rs | 2 + .../tests/capture_contract_tests.rs | 50 +++++++- 5 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 crates/hypercolor-macos-capture/src/clock.rs diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index a29d3c048..6483338c1 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -5,8 +5,9 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureContentStyle, MacosCaptureFrame, MacosCaptureSelection, MacosFrameEvent, - MacosFrameMailbox, MacosFrameStatus, MacosProtectedSourceState as NativeProtectedSourceState, + MacosCaptureContentStyle, MacosCaptureFrame, MacosCaptureSelection, MacosDisplayClock, + MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosProtectedSourceState as NativeProtectedSourceState, }; #[cfg(target_os = "macos")] @@ -40,11 +41,13 @@ trait MacosCaptureControl: Send + Sync { fn status(&self) -> NativeProtectedSourceState; fn selection(&self) -> MacosCaptureSelection; fn authorization(&self) -> MacosAuthorizationState; + fn captured_at(&self, display_time: u64) -> anyhow::Result; } #[cfg(target_os = "macos")] struct NativeCaptureControl { session: MacosScreenCaptureSession, + clock: MacosDisplayClock, } #[cfg(target_os = "macos")] @@ -82,6 +85,12 @@ impl MacosCaptureControl for NativeCaptureControl { MacosAuthorizationState::NotDetermined } } + + fn captured_at(&self, display_time: u64) -> anyhow::Result { + self.clock + .timestamp(display_time) + .map_err(anyhow::Error::from) + } } #[derive(Default)] @@ -127,10 +136,11 @@ impl MacosScreenCaptureInput { true, )?; let session = MacosScreenCaptureSession::new(request)?; + let clock = MacosDisplayClock::system()?; Ok(Self::with_control( config, admission, - Arc::new(NativeCaptureControl { session }), + Arc::new(NativeCaptureControl { session, clock }), )) } @@ -235,6 +245,7 @@ impl MacosScreenCaptureInput { .checked_add(1) .ok_or_else(|| anyhow!("macOS capture worker generation exhausted"))?; let mailbox = self.control.mailbox(); + let control = Arc::clone(&self.control); let publication = Arc::clone(&self.publication); let status_session = self.status_session.clone(); let target_fps = prepared.target_fps; @@ -260,6 +271,7 @@ impl MacosScreenCaptureInput { target_fps, status_session, worker_stop, + control, ) }; let _ = exit_tx.send(result); @@ -535,6 +547,7 @@ fn run_worker( target_fps: u32, status_session: SourceSessionSlot, stop: Arc, + control: Arc, ) -> anyhow::Result<()> { let source_id = CaptureSourceId::new(Arc::::from("macos:session"))?; let mut topology = TopologyState::default(); @@ -553,6 +566,7 @@ fn run_worker( worker_generation, target_fps, &status_session, + &control, )?; } Ok(MacosFrameEvent::Lifecycle( @@ -576,6 +590,7 @@ fn publish_frame( worker_generation: u64, target_fps: u32, status_session: &SourceSessionSlot, + control: &Arc, ) -> anyhow::Result<()> { let extent = PixelExtent::new(frame.storage_extent.width, frame.storage_extent.height)?; let row_stride = usize::try_from(extent.width()) @@ -588,7 +603,7 @@ fn publish_frame( let mut plane = prepared.plane_pool.try_acquire(byte_len)?; plane.resize(byte_len, 0); frame.convert_bgra8_sdr_to_rgba8(&mut plane, row_stride)?; - let captured_at = Instant::now(); + let captured_at = control.captured_at(frame.display_time)?; let fresh_until = captured_at .checked_add(Duration::from_nanos( 2_000_000_000_u64.div_ceil(u64::from(target_fps)), @@ -659,6 +674,12 @@ fn publish_frame( return Err(anyhow!("macOS analysis changed topology generation")); } let data = Arc::new(InputData::Screen(snapshot.data().clone())); + if lock(publication).worker_generation != worker_generation { + return Ok(()); + } + if let Some(status) = status_session.load() { + status.record_sample(captured_at, fresh_until, 1)?; + } { let mut publication = lock(publication); if publication.worker_generation != worker_generation { @@ -666,9 +687,6 @@ fn publish_frame( } publication.latest = Some(data); } - if let Some(status) = status_session.load() { - status.record_sample(captured_at, fresh_until, 1)?; - } Ok(()) } @@ -790,6 +808,7 @@ struct FixtureControl { active: AtomicBool, status: Mutex, selection: Mutex, + captured_at: Mutex>, } #[cfg(feature = "macos-capture-fixtures")] @@ -800,6 +819,7 @@ impl Default for FixtureControl { active: AtomicBool::new(false), status: Mutex::new(NativeProtectedSourceState::ReadyIdle), selection: Mutex::new(MacosCaptureSelection::None), + captured_at: Mutex::new(None), } } } @@ -846,6 +866,10 @@ impl MacosCaptureControl for FixtureControl { _ => MacosAuthorizationState::Authorized, } } + + fn captured_at(&self, _display_time: u64) -> anyhow::Result { + Ok(lock(&self.captured_at).take().unwrap_or_else(Instant::now)) + } } #[cfg(feature = "macos-capture-fixtures")] @@ -874,6 +898,11 @@ impl MacosScreenCaptureFixture { .publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); } + pub fn publish_at(&self, frame: MacosCaptureFrame, captured_at: Instant) { + *lock(&self.control.captured_at) = Some(captured_at); + self.publish(frame); + } + pub fn is_active(&self) -> bool { self.control.active.load(Ordering::Acquire) } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index e20dd23aa..e7b1887a9 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -113,6 +113,10 @@ fn fixture_capture_activates_only_for_live_demand() { source .set_capability_owner(MacosCapabilityOwner::AppSidecar) .expect("fixture owner status updates"); + source + .source_status_reporter() + .expect("macOS fixture exposes status reporting") + .set_source_graph_generation(1); let status = source .source_status_handle() .expect("macOS fixture exposes status"); @@ -135,7 +139,8 @@ fn fixture_capture_activates_only_for_live_demand() { fixture.set_selection(MacosCaptureSelection::Display { source_id: Arc::from("display:00000000-0000-0000-0000-000000000001"), }); - fixture.publish(fixture_frame(1, [0, 0, 255, 255])); + let captured_at = Instant::now(); + fixture.publish_at(fixture_frame(1, [0, 0, 255, 255]), captured_at); let data = wait_for_screen(&mut source); assert_eq!(data.grid_width, 2); assert_eq!(data.grid_height, 1); @@ -143,6 +148,7 @@ fn fixture_capture_activates_only_for_live_demand() { assert_eq!(data.source_height, 2); assert_eq!(data.zone_colors.len(), 2); let live = status.snapshot(); + assert_eq!(live.last_sample_at, Some(captured_at)); let Some(SourcePlatformStatus::MacosScreen(platform)) = live.platform.as_deref() else { panic!("expected live macOS screen platform status"); }; diff --git a/crates/hypercolor-macos-capture/src/clock.rs b/crates/hypercolor-macos-capture/src/clock.rs new file mode 100644 index 000000000..9a212804e --- /dev/null +++ b/crates/hypercolor-macos-capture/src/clock.rs @@ -0,0 +1,107 @@ +use std::num::NonZeroU32; +use std::time::{Duration, Instant}; + +#[derive(Clone, Debug)] +pub struct MacosDisplayClock { + anchor_ticks: u64, + anchor_instant: Instant, + timebase_numerator: NonZeroU32, + timebase_denominator: NonZeroU32, +} + +impl MacosDisplayClock { + pub fn new( + anchor_ticks: u64, + anchor_instant: Instant, + timebase_numerator: u32, + timebase_denominator: u32, + ) -> Result { + let timebase_numerator = + NonZeroU32::new(timebase_numerator).ok_or(MacosDisplayClockError::InvalidTimebase { + numerator: timebase_numerator, + denominator: timebase_denominator, + })?; + let timebase_denominator = NonZeroU32::new(timebase_denominator).ok_or( + MacosDisplayClockError::InvalidTimebase { + numerator: timebase_numerator.get(), + denominator: timebase_denominator, + }, + )?; + Ok(Self { + anchor_ticks, + anchor_instant, + timebase_numerator, + timebase_denominator, + }) + } + + #[cfg(target_os = "macos")] + pub fn system() -> Result { + #[repr(C)] + struct MachTimebaseInfo { + numerator: u32, + denominator: u32, + } + unsafe extern "C" { + fn mach_absolute_time() -> u64; + fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32; + } + let mut timebase = MachTimebaseInfo { + numerator: 0, + denominator: 0, + }; + // SAFETY: mach_timebase_info initializes the provided plain-data + // structure and retains no pointer after returning. + let result = unsafe { mach_timebase_info(&raw mut timebase) }; + if result != 0 { + return Err(MacosDisplayClockError::TimebaseQueryFailed(result)); + } + let anchor_instant = Instant::now(); + // SAFETY: mach_absolute_time has no preconditions or retained state. + let anchor_ticks = unsafe { mach_absolute_time() }; + Self::new( + anchor_ticks, + anchor_instant, + timebase.numerator, + timebase.denominator, + ) + } + + pub fn timestamp(&self, display_time: u64) -> Result { + if display_time >= self.anchor_ticks { + let elapsed = self.duration(display_time - self.anchor_ticks)?; + self.anchor_instant + .checked_add(elapsed) + .ok_or(MacosDisplayClockError::InstantOutOfRange) + } else { + let elapsed = self.duration(self.anchor_ticks - display_time)?; + self.anchor_instant + .checked_sub(elapsed) + .ok_or(MacosDisplayClockError::InstantOutOfRange) + } + } + + fn duration(&self, ticks: u64) -> Result { + let nanoseconds = u128::from(ticks) + .checked_mul(u128::from(self.timebase_numerator.get())) + .ok_or(MacosDisplayClockError::DurationOutOfRange)? + / u128::from(self.timebase_denominator.get()); + let seconds = u64::try_from(nanoseconds / 1_000_000_000) + .map_err(|_| MacosDisplayClockError::DurationOutOfRange)?; + let subsecond_nanos = u32::try_from(nanoseconds % 1_000_000_000) + .map_err(|_| MacosDisplayClockError::DurationOutOfRange)?; + Ok(Duration::new(seconds, subsecond_nanos)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum MacosDisplayClockError { + #[error("mach timebase query failed with status {0}")] + TimebaseQueryFailed(i32), + #[error("invalid mach timebase {numerator}/{denominator}")] + InvalidTimebase { numerator: u32, denominator: u32 }, + #[error("mach display-time duration exceeds the monotonic clock range")] + DurationOutOfRange, + #[error("mach display time falls outside the monotonic clock range")] + InstantOutOfRange, +} diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 3f8a66774..ff6ea486a 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -3,6 +3,7 @@ //! Native framework ownership remains private to this crate. The public frame //! boundary contains only plain Rust metadata plus an opaque retained surface. +mod clock; mod cpu; mod diagnostics; mod frame; @@ -15,6 +16,7 @@ mod session; #[cfg(target_os = "macos")] pub use native::MacosScreenCaptureSession; +pub use clock::{MacosDisplayClock, MacosDisplayClockError}; pub use diagnostics::{MacosCaptureCallbackDiagnostics, MacosFrameDropReason}; pub use frame::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index d894ac343..ef5951756 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -4,11 +4,13 @@ use hypercolor_macos_capture::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, - MacosColorRange, MacosFrameDecoder, MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, - MacosFrameStatus, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, - MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, - MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, + MacosColorRange, MacosDisplayClock, MacosDisplayClockError, MacosFrameDecoder, + MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosGeometryError, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, + MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, MacosStreamRequest, + MacosTransferFunction, MacosYuvMatrix, }; +use std::time::{Duration, Instant}; const BGRA8: u32 = 0x4247_5241; const ARGB2101010: u32 = 0x5231_306b; @@ -18,6 +20,46 @@ const YUV420_FULL_RANGE: u32 = 0x3432_3066; const YUV44410_VIDEO_RANGE: u32 = 0x7834_3434; const YUV44410_FULL_RANGE: u32 = 0x7866_3434; +#[test] +fn display_clock_maps_mach_ticks_around_its_monotonic_anchor() { + let anchor = Instant::now(); + let clock = MacosDisplayClock::new(100, anchor, 125, 3).expect("valid timebase"); + + assert_eq!(clock.timestamp(100), Ok(anchor)); + assert_eq!(clock.timestamp(124), Ok(anchor + Duration::from_micros(1))); + assert_eq!(clock.timestamp(76), Ok(anchor - Duration::from_micros(1))); +} + +#[test] +fn display_clock_rejects_invalid_or_unrepresentable_timebases() { + let anchor = Instant::now(); + assert_eq!( + MacosDisplayClock::new(0, anchor, 0, 1).expect_err("zero numerator must fail"), + MacosDisplayClockError::InvalidTimebase { + numerator: 0, + denominator: 1, + } + ); + assert_eq!( + MacosDisplayClock::new(0, anchor, 1, 0).expect_err("zero denominator must fail"), + MacosDisplayClockError::InvalidTimebase { + numerator: 1, + denominator: 0, + } + ); + let clock = MacosDisplayClock::new(0, anchor, u32::MAX, 1).expect("valid timebase"); + assert_eq!( + clock.timestamp(u64::MAX), + Err(MacosDisplayClockError::DurationOutOfRange) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn system_display_clock_reads_the_native_timebase() { + MacosDisplayClock::system().expect("macOS exposes its monotonic timebase"); +} + #[test] fn queue_depth_is_the_full_framework_limit() { assert_eq!(MACOS_STREAM_QUEUE_DEPTH, 8); From 356fa0376b685c5e9db68fc1285b0474b9135a9a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:10:18 -0700 Subject: [PATCH 033/144] fix(macos): preserve capture through repick failures Keep the last good screen publication visible while a replacement worker warms up. Candidate and picker failures now carry a recoverable event when the current stream or selection remains usable. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 10 +++- .../tests/macos_screen_capture_tests.rs | 41 +++++++++++-- crates/hypercolor-macos-capture/src/frame.rs | 1 + crates/hypercolor-macos-capture/src/native.rs | 58 +++++++++++++++---- .../tests/capture_contract_tests.rs | 3 + 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 6483338c1..8ac8e79c4 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -276,12 +276,13 @@ impl MacosScreenCaptureInput { }; let _ = exit_tx.send(result); })?; + let previous_latest = lock(&self.publication).latest.clone(); self.stop_worker(); self.worker_generation = worker_generation; { let mut publication = lock(&self.publication); publication.worker_generation = worker_generation; - publication.latest = None; + publication.latest = previous_latest; } self.worker = Some(CaptureWorker { stop, @@ -574,6 +575,7 @@ fn run_worker( )) | Err(_) => lock(&publication).latest = None, Ok(MacosFrameEvent::Lifecycle(_)) => {} + Ok(MacosFrameEvent::RecoverableError(_)) => {} } } prepared.analyzer.stop(); @@ -903,6 +905,12 @@ impl MacosScreenCaptureFixture { self.publish(frame); } + pub fn publish_recoverable_error(&self, error: hypercolor_macos_capture::MacosCaptureError) { + self.control + .mailbox + .publish(Ok(MacosFrameEvent::RecoverableError(Box::new(error)))); + } + pub fn is_active(&self) -> bool { self.control.active.load(Ordering::Acquire) } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index e7b1887a9..8a0f05739 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -14,9 +14,9 @@ use hypercolor_core::input::{ SourcePlatformStatus, }; use hypercolor_macos_capture::{ - MacosAttachment, MacosCaptureColorimetry, MacosCaptureFrame, MacosCapturePixelFormat, - MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, - MacosFrameDecoder, MacosFrameEvent, MacosPixelExtent, MacosPointRect, + MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, + MacosCapturePixelFormat, MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, + MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosTransferFunction, }; @@ -94,6 +94,25 @@ fn wait_for_screen(source: &mut impl InputSource) -> hypercolor_core::input::Scr } } +fn wait_for_grid_width( + source: &mut impl InputSource, + grid_width: u32, +) -> hypercolor_core::input::ScreenData { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match source.sample().expect("fixture sample succeeds") { + InputData::Screen(data) if data.grid_width == grid_width => return data, + InputData::Screen(_) | InputData::None if Instant::now() < deadline => { + thread::yield_now(); + } + InputData::Screen(_) | InputData::None => { + panic!("fixture worker did not publish the expected grid before the deadline"); + } + _ => panic!("macOS fixture published the wrong input kind"), + } + } +} + #[test] fn fixture_capture_activates_only_for_live_demand() { let config = CaptureConfig { @@ -198,10 +217,22 @@ fn reconfiguration_fences_the_previous_worker_generation() { ..config }) .expect("fixture worker reconfigures"); - assert!(matches!(source.sample(), Ok(InputData::None))); + let retained = source.sample().expect("last-good frame remains readable"); + let InputData::Screen(retained) = retained else { + panic!("expected retained screen data during reconfiguration"); + }; + assert_eq!(retained.grid_width, 2); + fixture.publish_recoverable_error(MacosCaptureError::DisplayUuidUnavailable(7)); + let retained = source + .sample() + .expect("recoverable repick error preserves last-good data"); + let InputData::Screen(retained) = retained else { + panic!("expected retained screen data after recoverable repick error"); + }; + assert_eq!(retained.grid_width, 2); fixture.publish(fixture_frame(2, [0, 255, 0, 255])); - let data = wait_for_screen(&mut source); + let data = wait_for_grid_width(&mut source, 1); assert_eq!(data.grid_width, 1); assert_eq!(data.grid_height, 1); assert_eq!(data.zone_colors.len(), 1); diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index cd52fa142..baf58d987 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -510,6 +510,7 @@ pub struct MacosRawCaptureSample { pub enum MacosFrameEvent { Frame(Box), Lifecycle(MacosFrameStatus), + RecoverableError(Box), } #[derive(Debug, Clone)] diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 2aaeed8c0..802c49bd3 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -117,6 +117,7 @@ impl SessionShared { self.counters.record_lifecycle(); MacosProtectedSourceState::Live } + MacosFrameEvent::RecoverableError(_) => self.status(), }; self.set_status(status); self.mailbox.publish(Ok(event)); @@ -129,6 +130,11 @@ impl SessionShared { fn publish_error(&self, error: MacosCaptureError) { self.mailbox.publish(Err(error)); } + + fn publish_recoverable_error(&self, error: MacosCaptureError) { + self.mailbox + .publish(Ok(MacosFrameEvent::RecoverableError(Box::new(error)))); + } } #[derive(Debug)] @@ -512,20 +518,27 @@ fn handle_stream_error( let role = streams .upgrade() .map_or(StreamRole::Stale, |streams| streams.remove(epoch)); - match role { + let preserve_current = match role { StreamRole::Candidate if streams .upgrade() .is_some_and(|streams| streams.has_current()) => { shared.set_status(MacosProtectedSourceState::Live); + true } StreamRole::Candidate | StreamRole::Current => { shared.set_status(classify_stream_error(error)); + false } StreamRole::Stale => return, + }; + let error = native_error("ScreenCaptureKit stream", error); + if preserve_current { + shared.publish_recoverable_error(error); + } else { + shared.publish_error(error); } - shared.publish_error(native_error("ScreenCaptureKit stream", error)); } struct PickerObserverIvars { @@ -571,10 +584,17 @@ define_class!( if self.ivars().active.get() { self.install_filter(filter); } else if let Err(error) = self.ivars().streams.store_selection(filter) { - self.ivars() - .shared - .set_status(MacosProtectedSourceState::Failed); - self.ivars().shared.publish_error(error); + if self.ivars().streams.has_selection() { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + self.ivars().shared.publish_recoverable_error(error); + } else { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::Failed); + self.ivars().shared.publish_error(error); + } } else { self.ivars() .shared @@ -585,14 +605,23 @@ define_class!( #[allow(non_snake_case)] #[unsafe(method(contentSharingPickerStartDidFailWithError:))] fn contentSharingPickerStartDidFailWithError(&self, error: &NSError) { - if !self.ivars().streams.has_current() { + let preserve_current = self.ivars().streams.has_current(); + let preserve_selection = self.ivars().streams.has_selection(); + if !preserve_current && !preserve_selection { self.ivars() .shared .set_status(MacosProtectedSourceState::Failed); + } else if !preserve_current { + self.ivars() + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + } + let error = native_error("ScreenCaptureKit picker", error); + if preserve_current || preserve_selection { + self.ivars().shared.publish_recoverable_error(error); + } else { + self.ivars().shared.publish_error(error); } - self.ivars() - .shared - .publish_error(native_error("ScreenCaptureKit picker", error)); } } ); @@ -628,13 +657,18 @@ impl PickerObserver { .stage_candidate(filter, self.ivars().request, epoch) }); if let Err(error) = result { - let status = if self.ivars().streams.has_current() { + let preserve_current = self.ivars().streams.has_current(); + let status = if preserve_current { MacosProtectedSourceState::Live } else { MacosProtectedSourceState::Failed }; self.ivars().shared.set_status(status); - self.ivars().shared.publish_error(error); + if preserve_current { + self.ivars().shared.publish_recoverable_error(error); + } else { + self.ivars().shared.publish_error(error); + } } } diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index ef5951756..ab026650e 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -565,6 +565,9 @@ fn decode_frame( match decoder.decode(sample).expect("sample should decode") { MacosFrameEvent::Frame(frame) => *frame, MacosFrameEvent::Lifecycle(status) => panic!("expected frame, got {status:?}"), + MacosFrameEvent::RecoverableError(error) => { + panic!("expected frame, got recoverable error: {error}") + } } } From 17c049822e3ac65d5408a1a1b74557dc19d572ff Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:10:31 -0700 Subject: [PATCH 034/144] fix(macos): correct screen capture privacy metadata Declare the Screen Recording purpose used by screen-reactive effects and remove the unrelated Apple Events request. Pin the exact two privacy strings so packaging cannot regress to a broader permission. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-app/Info.plist | 4 +-- crates/hypercolor-app/tests/config_tests.rs | 40 +++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/hypercolor-app/Info.plist b/crates/hypercolor-app/Info.plist index cd9262c0c..e8f9d942a 100644 --- a/crates/hypercolor-app/Info.plist +++ b/crates/hypercolor-app/Info.plist @@ -4,7 +4,7 @@ NSMicrophoneUsageDescription Hypercolor uses your microphone for audio-reactive lighting effects. - NSAppleEventsUsageDescription - Hypercolor uses input events for keyboard-reactive lighting effects. + NSScreenCaptureUsageDescription + Hypercolor captures your screen to create screen-reactive lighting effects. diff --git a/crates/hypercolor-app/tests/config_tests.rs b/crates/hypercolor-app/tests/config_tests.rs index 2f7e7eaaa..0cc54f86a 100644 --- a/crates/hypercolor-app/tests/config_tests.rs +++ b/crates/hypercolor-app/tests/config_tests.rs @@ -4,6 +4,7 @@ //! and carries the metadata the Tauri runtime expects at startup. They do //! not spawn a Tauri app; they only read the file from the manifest dir. +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -32,6 +33,20 @@ fn manifest_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +fn plist_string_entries(plist: &str) -> BTreeMap<&str, &str> { + let lines: Vec<_> = plist.lines().map(str::trim).collect(); + lines + .windows(2) + .filter_map(|pair| { + let key = pair[0].strip_prefix("")?.strip_suffix("")?; + let value = pair[1] + .strip_prefix("")? + .strip_suffix("")?; + Some((key, value)) + }) + .collect() +} + #[test] fn default_capability_grants_window_and_autostart_permissions() { let capability = default_capability(); @@ -207,12 +222,25 @@ fn macos_bundle_plists_declare_required_permissions() { ); } - for key in [ - "NSMicrophoneUsageDescription", - "NSAppleEventsUsageDescription", - ] { - assert!(info_plist.contains(key), "Info.plist should declare {key}"); - } + let expected_privacy_entries = BTreeMap::from([ + ( + "NSMicrophoneUsageDescription", + "Hypercolor uses your microphone for audio-reactive lighting effects.", + ), + ( + "NSScreenCaptureUsageDescription", + "Hypercolor captures your screen to create screen-reactive lighting effects.", + ), + ]); + assert_eq!( + plist_string_entries(&info_plist), + expected_privacy_entries, + "Info.plist should declare only the required privacy purpose strings" + ); + assert!( + !info_plist.contains("NSAppleEventsUsageDescription"), + "Info.plist should not request unrelated Apple Events permission" + ); } #[test] From af0e277aae1f91971a271d21616b964f3a49ebb6 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:14:59 -0700 Subject: [PATCH 035/144] feat(api): expose macOS protected-source status Serialize macOS input and screen lifecycle details through system status, including TCC evidence, capability ownership, picker selection, conflicts, and Tahoe stream capabilities. Keep unsupported future variants additive. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-daemon/src/api/system.rs | 410 ++++++++++++++++++++- 1 file changed, 407 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index e4101effd..18343aa17 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -13,7 +13,12 @@ use hypercolor_core::engine::RenderLoopState; use hypercolor_core::input::screen::{ PixelExtent, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, }; -use hypercolor_core::input::{SourceFreshness, SourceIssue, SourceKind, SourceState, SourceStatus}; +use hypercolor_core::input::{ + MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosSelectionState, MacosTahoeSelectionCapabilities, SourceFreshness, SourceIssue, SourceKind, + SourcePlatformStatus, SourceState, SourceStatus, +}; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::sensor::SystemSnapshot; use serde::Serialize; @@ -172,6 +177,89 @@ pub struct InputSourceIssueStatus { pub retryable: bool, } +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MacosProtectedSourceStateApi { + Disabled, + NeedsUserAction, + PermissionDenied, + NeedsProcessRestart, + NeedsSelection, + ReadyIdle, + Starting, + Live, + Interrupted, + Revoked, + Failed, +} + +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MacosAuthorizationStateApi { + Unknown, + NotDetermined, + Denied, + Authorized, +} + +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MacosCapabilityOwnerApi { + AppSidecar, + App, + LaunchdService, + HomebrewService, + Broker, + Standalone, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosDaemonOwnerConflictApiStatus { + pub active: MacosCapabilityOwnerApi, + pub contender: MacosCapabilityOwnerApi, + pub observed_at_ms: u64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MacosSelectionStateApi { + None, + Display { source_id: String }, + SessionScoped { content_style: String }, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosTahoeSelectionCapabilitiesApiStatus { + pub source_id: String, + pub capture_session_generation: u64, + pub hdr_capture: bool, + pub dual_range_screenshots: bool, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputSourcePlatformStatus { + MacosInput { + keyboard: MacosProtectedSourceStateApi, + pointer: MacosProtectedSourceStateApi, + keyboard_tcc: MacosAuthorizationStateApi, + keyboard_owner: MacosCapabilityOwnerApi, + pointer_owner: MacosCapabilityOwnerApi, + #[serde(default, skip_serializing_if = "Option::is_none")] + owner_conflict: Option, + }, + MacosScreen { + state: MacosProtectedSourceStateApi, + tcc: MacosAuthorizationStateApi, + owner: MacosCapabilityOwnerApi, + selection: MacosSelectionStateApi, + #[serde(default, skip_serializing_if = "Option::is_none")] + tahoe_selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + owner_conflict: Option, + }, +} + /// Lock-free lifecycle and freshness status for one input source. #[derive(Debug, Clone, Serialize, ToSchema)] #[allow( @@ -202,6 +290,8 @@ pub struct InputSourceStatus { pub lifecycle_issue: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub freshness_issue: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, pub retired: bool, } @@ -595,10 +685,132 @@ fn input_source_status(source: &SourceStatus, now: Instant) -> InputSourceStatus issue, lifecycle_issue, freshness_issue, + platform: source + .platform + .as_deref() + .and_then(input_source_platform_status), retired: source.retired, } } +fn input_source_platform_status( + platform: &SourcePlatformStatus, +) -> Option { + match platform { + SourcePlatformStatus::MacosInput(status) => Some(macos_input_platform_status(status)), + SourcePlatformStatus::MacosScreen(status) => Some(macos_screen_platform_status(status)), + _ => None, + } +} + +fn macos_input_platform_status(status: &MacosInputPlatformStatus) -> InputSourcePlatformStatus { + InputSourcePlatformStatus::MacosInput { + keyboard: macos_protected_source_state(status.keyboard), + pointer: macos_protected_source_state(status.pointer), + keyboard_tcc: macos_authorization_state(status.keyboard_tcc), + keyboard_owner: macos_capability_owner(status.keyboard_owner), + pointer_owner: macos_capability_owner(status.pointer_owner), + owner_conflict: status + .owner_conflict + .as_deref() + .map(macos_daemon_owner_conflict), + } +} + +fn macos_screen_platform_status(status: &MacosScreenPlatformStatus) -> InputSourcePlatformStatus { + InputSourcePlatformStatus::MacosScreen { + state: macos_protected_source_state(status.state), + tcc: macos_authorization_state(status.tcc), + owner: macos_capability_owner(status.owner), + selection: macos_selection_state(&status.selection), + tahoe_selection: status + .tahoe_selection + .as_ref() + .map(macos_tahoe_selection_capabilities), + owner_conflict: status + .owner_conflict + .as_deref() + .map(macos_daemon_owner_conflict), + } +} + +const fn macos_protected_source_state( + state: MacosProtectedSourceState, +) -> MacosProtectedSourceStateApi { + match state { + MacosProtectedSourceState::Disabled => MacosProtectedSourceStateApi::Disabled, + MacosProtectedSourceState::NeedsUserAction => MacosProtectedSourceStateApi::NeedsUserAction, + MacosProtectedSourceState::PermissionDenied => { + MacosProtectedSourceStateApi::PermissionDenied + } + MacosProtectedSourceState::NeedsProcessRestart => { + MacosProtectedSourceStateApi::NeedsProcessRestart + } + MacosProtectedSourceState::NeedsSelection => MacosProtectedSourceStateApi::NeedsSelection, + MacosProtectedSourceState::ReadyIdle => MacosProtectedSourceStateApi::ReadyIdle, + MacosProtectedSourceState::Starting => MacosProtectedSourceStateApi::Starting, + MacosProtectedSourceState::Live => MacosProtectedSourceStateApi::Live, + MacosProtectedSourceState::Interrupted => MacosProtectedSourceStateApi::Interrupted, + MacosProtectedSourceState::Revoked => MacosProtectedSourceStateApi::Revoked, + MacosProtectedSourceState::Failed => MacosProtectedSourceStateApi::Failed, + } +} + +const fn macos_authorization_state(state: MacosAuthorizationState) -> MacosAuthorizationStateApi { + match state { + MacosAuthorizationState::Unknown => MacosAuthorizationStateApi::Unknown, + MacosAuthorizationState::NotDetermined => MacosAuthorizationStateApi::NotDetermined, + MacosAuthorizationState::Denied => MacosAuthorizationStateApi::Denied, + MacosAuthorizationState::Authorized => MacosAuthorizationStateApi::Authorized, + } +} + +const fn macos_capability_owner(owner: MacosCapabilityOwner) -> MacosCapabilityOwnerApi { + match owner { + MacosCapabilityOwner::AppSidecar => MacosCapabilityOwnerApi::AppSidecar, + MacosCapabilityOwner::App => MacosCapabilityOwnerApi::App, + MacosCapabilityOwner::LaunchdService => MacosCapabilityOwnerApi::LaunchdService, + MacosCapabilityOwner::HomebrewService => MacosCapabilityOwnerApi::HomebrewService, + MacosCapabilityOwner::Broker => MacosCapabilityOwnerApi::Broker, + MacosCapabilityOwner::Standalone => MacosCapabilityOwnerApi::Standalone, + } +} + +fn macos_daemon_owner_conflict( + conflict: &MacosDaemonOwnerConflict, +) -> MacosDaemonOwnerConflictApiStatus { + MacosDaemonOwnerConflictApiStatus { + active: macos_capability_owner(conflict.active), + contender: macos_capability_owner(conflict.contender), + observed_at_ms: conflict.observed_at_ms, + } +} + +fn macos_selection_state(selection: &MacosSelectionState) -> MacosSelectionStateApi { + match selection { + MacosSelectionState::None => MacosSelectionStateApi::None, + MacosSelectionState::Display { source_id } => MacosSelectionStateApi::Display { + source_id: source_id.to_string(), + }, + MacosSelectionState::SessionScoped { content_style } => { + MacosSelectionStateApi::SessionScoped { + content_style: content_style.to_string(), + } + } + } +} + +fn macos_tahoe_selection_capabilities( + capabilities: &MacosTahoeSelectionCapabilities, +) -> MacosTahoeSelectionCapabilitiesApiStatus { + MacosTahoeSelectionCapabilitiesApiStatus { + source_id: capabilities.source_id.to_string(), + capture_session_generation: capabilities.capture_session_generation, + hdr_capture: capabilities.hdr_capture, + dual_range_screenshots: capabilities.dual_range_screenshots, + } +} + fn input_source_issue_status(issue: &SourceIssue) -> InputSourceIssueStatus { InputSourceIssueStatus { code: issue.code.to_string(), @@ -1569,7 +1781,10 @@ fn round_2(value: f64) -> f64 { #[cfg(test)] mod tests { - use super::{get_sensor, get_sensors, get_status, us_to_ms_f64}; + use super::{ + get_sensor, get_sensors, get_status, input_source_status, macos_selection_state, + us_to_ms_f64, + }; use crate::api::AppState; use crate::performance::{ CompositorBackendKind, FrameTimeline, FullFrameCopyMetrics, LatestFrameMetrics, @@ -1580,12 +1795,201 @@ mod tests { use axum::extract::{Path, State}; use hypercolor_core::bus::CanvasFrame; use hypercolor_core::input::screen::ScreenAdmissionCapacity; + use hypercolor_core::input::{ + MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosSelectionState, MacosTahoeSelectionCapabilities, SourceFreshness, SourceKind, + SourcePlatformStatus, SourceState, SourceStatus, + }; use hypercolor_types::canvas::Canvas; use hypercolor_types::sensor::{SensorReading, SensorUnit, SystemSnapshot}; - use serde_json::Value; + use serde::Deserialize; + use serde_json::{Value, json}; use std::sync::Arc; + use std::time::Instant; use tokio::sync::watch; + fn source_status_fixture(platform: Option) -> SourceStatus { + SourceStatus { + source_id: Arc::from("fixture:source"), + kind: SourceKind::Interaction, + backend: Arc::from("fixture"), + configured: true, + consented: true, + demanded: true, + state: SourceState::Live, + freshness: SourceFreshness::NotApplicable, + source_graph_generation: 7, + session_generation: 11, + last_sample_at: None, + freshness_deadline: None, + resource_count: 2, + denied_resource_count: 0, + issue: None, + freshness_issue: None, + platform: platform.map(Arc::new), + retired: false, + } + } + + #[test] + fn input_source_status_serializes_macos_input_platform() { + let platform = SourcePlatformStatus::MacosInput(MacosInputPlatformStatus { + keyboard: MacosProtectedSourceState::NeedsProcessRestart, + pointer: MacosProtectedSourceState::Live, + keyboard_tcc: MacosAuthorizationState::Authorized, + keyboard_owner: MacosCapabilityOwner::AppSidecar, + pointer_owner: MacosCapabilityOwner::Broker, + owner_conflict: Some(Arc::new(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::LaunchdService, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 1_725_000_000_123, + })), + }); + let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); + let value = serde_json::to_value(status).expect("input status should serialize"); + + assert_eq!( + value["platform"], + json!({ + "type": "macos_input", + "keyboard": "needs_process_restart", + "pointer": "live", + "keyboard_tcc": "authorized", + "keyboard_owner": "app_sidecar", + "pointer_owner": "broker", + "owner_conflict": { + "active": "launchd_service", + "contender": "homebrew_service", + "observed_at_ms": 1_725_000_000_123_u64 + } + }) + ); + } + + #[test] + fn input_source_status_serializes_macos_screen_platform() { + let platform = SourcePlatformStatus::MacosScreen(MacosScreenPlatformStatus { + state: MacosProtectedSourceState::Interrupted, + tcc: MacosAuthorizationState::Denied, + owner: MacosCapabilityOwner::Standalone, + selection: MacosSelectionState::SessionScoped { + content_style: Arc::from("multiple_windows"), + }, + tahoe_selection: Some(MacosTahoeSelectionCapabilities { + source_id: Arc::from("session:23"), + capture_session_generation: 29, + hdr_capture: true, + dual_range_screenshots: true, + }), + owner_conflict: Some(Arc::new(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::Standalone, + contender: MacosCapabilityOwner::App, + observed_at_ms: 1_725_000_000_456, + })), + }); + let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); + let value = serde_json::to_value(status).expect("screen status should serialize"); + + assert_eq!( + value["platform"], + json!({ + "type": "macos_screen", + "state": "interrupted", + "tcc": "denied", + "owner": "standalone", + "selection": { + "type": "session_scoped", + "content_style": "multiple_windows" + }, + "tahoe_selection": { + "source_id": "session:23", + "capture_session_generation": 29, + "hdr_capture": true, + "dual_range_screenshots": true + }, + "owner_conflict": { + "active": "standalone", + "contender": "app", + "observed_at_ms": 1_725_000_000_456_u64 + } + }) + ); + } + + #[test] + fn input_source_status_omits_absent_platform() { + let status = input_source_status(&source_status_fixture(None), Instant::now()); + let value = serde_json::to_value(status).expect("source status should serialize"); + + assert!(value.get("platform").is_none()); + } + + #[test] + fn macos_selection_status_preserves_public_shapes() { + let empty = serde_json::to_value(macos_selection_state(&MacosSelectionState::None)) + .expect("empty selection should serialize"); + let display = serde_json::to_value(macos_selection_state(&MacosSelectionState::Display { + source_id: Arc::from("display:7a3f"), + })) + .expect("display selection should serialize"); + + assert_eq!(empty, json!({ "type": "none" })); + assert_eq!( + display, + json!({ "type": "display", "source_id": "display:7a3f" }) + ); + } + + #[test] + fn macos_platform_json_tolerates_future_fields() { + #[derive(Debug, Deserialize)] + struct TolerantInputSourceStatus { + platform: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + enum TolerantPlatformStatus { + MacosScreen { state: String }, + } + + let value = json!({ + "platform": { + "type": "macos_screen", + "state": "live", + "future_probe": { "available": true } + }, + "future_source_field": 42 + }); + let status: TolerantInputSourceStatus = + serde_json::from_value(value).expect("unknown fields should remain additive"); + let Some(TolerantPlatformStatus::MacosScreen { state }) = status.platform else { + panic!("fixture should decode the macOS screen variant"); + }; + + assert_eq!(state, "live"); + } + + #[test] + fn macos_platform_status_is_present_in_openapi() { + use utoipa::OpenApi; + + let document = crate::api::openapi::ApiDoc::openapi(); + let value = serde_json::to_value(document).expect("OpenAPI should serialize"); + let schemas = value["components"]["schemas"] + .as_object() + .expect("OpenAPI should contain component schemas"); + + assert!(schemas.contains_key("InputSourcePlatformStatus")); + assert!(schemas.contains_key("MacosSelectionStateApi")); + assert!(schemas.contains_key("MacosTahoeSelectionCapabilitiesApiStatus")); + let platform_schema = &schemas["InputSourcePlatformStatus"]; + let encoded = serde_json::to_string(platform_schema).expect("schema should encode"); + assert!(encoded.contains("macos_input")); + assert!(encoded.contains("macos_screen")); + } + #[expect( clippy::too_many_lines, reason = "Status response assertions cover many nested metrics fields in one scenario" From 5db094e665e0dcada54985cfd6be46aea79ff329 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:15:37 -0700 Subject: [PATCH 036/144] ci(macos): run capture and status fixtures Exercise native frame contracts, the core capture worker, and macOS status serialization on both Apple Silicon and Intel macOS 26 runners. Clippy now covers the capture fixture paths with warnings denied. Co-Authored-By: Nova (OpenAI Codex) --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45aab84f2..f8665e980 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,11 +295,41 @@ jobs: cargo clippy --locked -p hypercolor-macos-gpu-interop --all-targets -- -D warnings + - name: Clippy macOS capture fixtures + run: | + ./scripts/cargo-cache-build.sh \ + cargo clippy --locked \ + -p hypercolor-macos-capture --features capture-fixtures --all-targets \ + -- -D warnings + ./scripts/cargo-cache-build.sh \ + cargo clippy --locked \ + -p hypercolor-core --features macos-capture-fixtures \ + --test macos_screen_capture_tests \ + -- -D warnings + - name: Run macOS interop fixtures run: >- ./scripts/cargo-cache-build.sh cargo nextest run --locked -p hypercolor-macos-gpu-interop + - name: Run macOS capture fixtures + run: | + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-macos-capture --features capture-fixtures \ + --test capture_contract_tests + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-core --features macos-capture-fixtures \ + --test macos_screen_capture_tests + + - name: Run macOS status API fixtures + run: >- + ./scripts/cargo-cache-build.sh + cargo nextest run --locked + -p hypercolor-daemon --no-default-features --features wgpu + -E 'test(/api::system::tests::(input_source_status|macos_)/)' + - name: Build deployment target fixture run: >- ./scripts/cargo-cache-build.sh From 6d65e2be77a86e5f30c70c0aa4f9ae9c18a9126f Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:20:52 -0700 Subject: [PATCH 037/144] fix(macos): wake capture worker during teardown Wake the frame mailbox after fencing a capture worker so teardown joins immediately instead of waiting for the polling timeout. The wait predicate observes the stop flag without injecting a synthetic frame. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 8 ++++- .../hypercolor-macos-capture/src/mailbox.rs | 16 +++++++++- .../tests/capture_contract_tests.rs | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 8ac8e79c4..1a7378fa8 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -107,6 +107,7 @@ struct PreparedWorker { struct CaptureWorker { stop: Arc, + mailbox: MacosFrameMailbox, exit_rx: mpsc::Receiver>, join: Option>, } @@ -245,6 +246,7 @@ impl MacosScreenCaptureInput { .checked_add(1) .ok_or_else(|| anyhow!("macOS capture worker generation exhausted"))?; let mailbox = self.control.mailbox(); + let worker_mailbox = mailbox.clone(); let control = Arc::clone(&self.control); let publication = Arc::clone(&self.publication); let status_session = self.status_session.clone(); @@ -286,6 +288,7 @@ impl MacosScreenCaptureInput { } self.worker = Some(CaptureWorker { stop, + mailbox: worker_mailbox, exit_rx, join: Some(join), }); @@ -304,6 +307,7 @@ impl MacosScreenCaptureInput { return; }; worker.stop.store(true, Ordering::Release); + worker.mailbox.wake(); if let Some(join) = worker.join.take() { let _ = join.join(); } @@ -553,7 +557,9 @@ fn run_worker( let source_id = CaptureSourceId::new(Arc::::from("macos:session"))?; let mut topology = TopologyState::default(); while !stop.load(Ordering::Acquire) { - let Some(delivery) = mailbox.wait_latest(WORKER_WAIT) else { + let Some(delivery) = + mailbox.wait_latest_while(WORKER_WAIT, || !stop.load(Ordering::Acquire)) + else { continue; }; match delivery { diff --git a/crates/hypercolor-macos-capture/src/mailbox.rs b/crates/hypercolor-macos-capture/src/mailbox.rs index 0224c2f7a..a801a40b6 100644 --- a/crates/hypercolor-macos-capture/src/mailbox.rs +++ b/crates/hypercolor-macos-capture/src/mailbox.rs @@ -49,17 +49,31 @@ impl MacosFrameMailbox { pub fn wait_latest( &self, timeout: Duration, + ) -> Option> { + self.wait_latest_while(timeout, || true) + } + + pub fn wait_latest_while( + &self, + timeout: Duration, + keep_waiting: impl Fn() -> bool, ) -> Option> { let state = self.lock(); let mut state = self .inner .ready - .wait_timeout_while(state, timeout, |state| state.latest.is_none()) + .wait_timeout_while(state, timeout, |state| { + state.latest.is_none() && keep_waiting() + }) .unwrap_or_else(std::sync::PoisonError::into_inner) .0; state.latest.take() } + pub fn wake(&self) { + self.inner.ready.notify_all(); + } + fn lock(&self) -> MutexGuard<'_, MailboxState> { self.inner .state diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index ab026650e..f0121e591 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -490,6 +490,38 @@ fn mailbox_wait_returns_a_ready_delivery_without_polling() { ); } +#[test] +fn mailbox_wake_releases_a_stopped_waiter_without_a_delivery() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc; + + let mailbox = MacosFrameMailbox::new(); + let waiting = Arc::new(AtomicBool::new(true)); + let worker_waiting = Arc::clone(&waiting); + let worker_mailbox = mailbox.clone(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + ready_tx.send(()).expect("waiter should announce readiness"); + let delivery = worker_mailbox.wait_latest_while(Duration::from_secs(5), || { + worker_waiting.load(Ordering::Acquire) + }); + done_tx + .send(delivery.is_none()) + .expect("waiter should exit"); + }); + + ready_rx.recv().expect("waiter should start"); + waiting.store(false, Ordering::Release); + mailbox.wake(); + assert!( + done_rx + .recv_timeout(Duration::from_millis(250)) + .expect("wake should release the waiter") + ); + worker.join().expect("waiter should join"); +} + #[test] fn callback_diagnostics_start_with_every_drop_reason_at_zero() { let diagnostics = MacosCaptureCallbackDiagnostics::default(); From f80847b53b86c7599f6f43acf31892e45db28101 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:22:26 -0700 Subject: [PATCH 038/144] test(macos): pin daemon signing contract Parse the signing manifest and daemon entitlement plist in packaging tests. Pin all seven actor mappings and the exact six enabled daemon entitlements so a broader or mismatched signing profile fails before release. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-app/tests/config_tests.rs | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/crates/hypercolor-app/tests/config_tests.rs b/crates/hypercolor-app/tests/config_tests.rs index 0cc54f86a..3540b5c93 100644 --- a/crates/hypercolor-app/tests/config_tests.rs +++ b/crates/hypercolor-app/tests/config_tests.rs @@ -33,6 +33,10 @@ fn manifest_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +fn repository_root() -> PathBuf { + manifest_dir().join("../..") +} + fn plist_string_entries(plist: &str) -> BTreeMap<&str, &str> { let lines: Vec<_> = plist.lines().map(str::trim).collect(); lines @@ -47,6 +51,67 @@ fn plist_string_entries(plist: &str) -> BTreeMap<&str, &str> { .collect() } +fn plist_boolean_entries(plist: &str) -> BTreeMap<&str, bool> { + let mut lines = plist.lines().map(str::trim); + let mut entries = BTreeMap::new(); + + while let Some(line) = lines.next() { + let Some(key) = line + .strip_prefix("") + .and_then(|key| key.strip_suffix("")) + else { + continue; + }; + let value_line = lines + .next() + .expect("plist keys should have a following value"); + let value = match value_line { + "" => true, + "" => false, + other => panic!("plist key {key} should have a Boolean value, got {other}"), + }; + assert!( + entries.insert(key, value).is_none(), + "plist key should be unique: {key}" + ); + } + + entries +} + +fn signing_manifest_entries(manifest: &str) -> BTreeMap<(&str, &str), (&str, &str)> { + let mut entries = BTreeMap::new(); + + for (line_index, line) in manifest.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut fields = line.split('\t'); + let scope = fields.next().expect("manifest rows should have a scope"); + let path = fields.next().expect("manifest rows should have a path"); + let identifier = fields + .next() + .expect("manifest rows should have an identifier"); + let entitlements = fields + .next() + .expect("manifest rows should have an entitlements profile"); + assert!( + fields.next().is_none(), + "manifest line {} should have exactly four fields", + line_index + 1 + ); + assert!( + entries + .insert((scope, path), (identifier, entitlements)) + .is_none(), + "manifest scope and path should be unique: {scope}/{path}" + ); + } + + entries +} + #[test] fn default_capability_grants_window_and_autostart_permissions() { let capability = default_capability(); @@ -243,6 +308,72 @@ fn macos_bundle_plists_declare_required_permissions() { ); } +#[test] +fn macos_daemon_signing_contract_is_exact() { + let root = repository_root(); + let manifest = fs::read_to_string(root.join("packaging/macos/signing-manifest.tsv")) + .expect("macOS signing manifest should be readable"); + let entitlements = fs::read_to_string(root.join("packaging/macos/daemon.entitlements.plist")) + .expect("macOS daemon entitlements should be readable"); + + let expected_manifest = BTreeMap::from([ + ( + ("app", "Contents/MacOS/Hypercolor"), + ( + "tech.hyperbliss.hypercolor", + "crates/hypercolor-app/entitlements.plist", + ), + ), + ( + ("app", "Contents/MacOS/hypercolor-daemon-{target}"), + ( + "tech.hyperbliss.hypercolor.sidecar", + "packaging/macos/daemon.entitlements.plist", + ), + ), + ( + ("app", "Contents/MacOS/hypercolor-{target}"), + ("tech.hyperbliss.hypercolor.cli", "none"), + ), + ( + ("standalone", "bin/hypercolor-daemon"), + ( + "tech.hyperbliss.hypercolor.daemon", + "packaging/macos/daemon.entitlements.plist", + ), + ), + ( + ("standalone", "bin/hypercolor"), + ("tech.hyperbliss.hypercolor.cli", "none"), + ), + ( + ("standalone", "bin/hypercolor-app"), + ( + "tech.hyperbliss.hypercolor.app-host", + "crates/hypercolor-app/entitlements.plist", + ), + ), + ( + ("standalone", "bin/hypercolor-tray"), + ("tech.hyperbliss.hypercolor.tray", "none"), + ), + ]); + assert_eq!(signing_manifest_entries(&manifest), expected_manifest); + + let expected_entitlements = BTreeMap::from([ + ("com.apple.security.cs.allow-jit", true), + ( + "com.apple.security.cs.allow-unsigned-executable-memory", + true, + ), + ("com.apple.security.device.audio-input", true), + ("com.apple.security.device.usb", true), + ("com.apple.security.network.client", true), + ("com.apple.security.network.server", true), + ]); + assert_eq!(plist_boolean_entries(&entitlements), expected_entitlements); +} + #[test] fn tauri_config_declares_sidecar_binaries() { let config = tauri_bundle_config(); From ede082fb1e1a023872b8f12c3c3405c4dd534c6f Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:22:57 -0700 Subject: [PATCH 039/144] feat(ui): decode macOS protected-source status Mirror the daemon macOS input and screen status in the web client. Keep platform details optional and tolerate future platform tags so older clients remain compatible as diagnostics grow. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-ui/src/api/system.rs | 240 +++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/crates/hypercolor-ui/src/api/system.rs b/crates/hypercolor-ui/src/api/system.rs index b6cd1bffd..89efd6cb1 100644 --- a/crates/hypercolor-ui/src/api/system.rs +++ b/crates/hypercolor-ui/src/api/system.rs @@ -69,6 +69,78 @@ pub struct InputSourceIssueStatus { pub retryable: bool, } +/// Process topologies competing to own a protected macOS capability. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct MacosDaemonOwnerConflictStatus { + pub active: Option, + pub contender: Option, + pub observed_at_ms: Option, +} + +/// Persistability and redacted content style of a macOS screen selection. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MacosSelectionStatus { + None, + Display { + #[serde(default)] + source_id: Option, + }, + SessionScoped { + #[serde(default)] + content_style: Option, + }, + #[serde(other)] + Unknown, +} + +/// Tahoe capabilities proven for one selected capture incarnation. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct MacosTahoeSelectionStatus { + pub source_id: Option, + pub capture_session_generation: Option, + pub hdr_capture: Option, + pub dual_range_screenshots: Option, +} + +/// Platform-specific source state carried by the daemon status endpoint. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputSourcePlatformStatus { + MacosInput { + #[serde(default)] + keyboard: Option, + #[serde(default)] + pointer: Option, + #[serde(default)] + keyboard_tcc: Option, + #[serde(default)] + keyboard_owner: Option, + #[serde(default)] + pointer_owner: Option, + #[serde(default)] + owner_conflict: Option, + }, + MacosScreen { + #[serde(default)] + state: Option, + #[serde(default)] + tcc: Option, + #[serde(default)] + owner: Option, + #[serde(default)] + selection: Option, + #[serde(default)] + tahoe_selection: Option, + #[serde(default)] + owner_conflict: Option, + }, + #[serde(other)] + Unknown, +} + /// Lock-free lifecycle and freshness status for one input source. #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(default)] @@ -90,6 +162,7 @@ pub struct InputSourceStatus { pub issue: Option, pub lifecycle_issue: Option, pub freshness_issue: Option, + pub platform: Option, pub retired: bool, } @@ -144,3 +217,170 @@ pub async fn fetch_system_sensors() -> Result { .await .map_err(Into::into) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + InputSourcePlatformStatus, InputSourceStatus, MacosDaemonOwnerConflictStatus, + MacosSelectionStatus, MacosTahoeSelectionStatus, + }; + + #[test] + fn input_source_status_decodes_macos_input_platform_tolerantly() { + let status: InputSourceStatus = serde_json::from_value(json!({ + "platform": { + "type": "macos_input", + "keyboard": "needs_process_restart", + "pointer": "live", + "keyboard_tcc": "authorized", + "keyboard_owner": "app_sidecar", + "pointer_owner": "broker", + "owner_conflict": { + "active": "launchd_service", + "contender": "homebrew_service", + "observed_at_ms": 1_725_000_000_123_u64, + "future_conflict_field": true + }, + "future_probe": { "available": true } + }, + "future_source_field": 42 + })) + .expect("macOS input status should decode"); + + let Some(InputSourcePlatformStatus::MacosInput { + keyboard, + pointer, + keyboard_tcc, + keyboard_owner, + pointer_owner, + owner_conflict, + }) = status.platform + else { + panic!("fixture should decode the macOS input variant"); + }; + + assert_eq!(keyboard.as_deref(), Some("needs_process_restart")); + assert_eq!(pointer.as_deref(), Some("live")); + assert_eq!(keyboard_tcc.as_deref(), Some("authorized")); + assert_eq!(keyboard_owner.as_deref(), Some("app_sidecar")); + assert_eq!(pointer_owner.as_deref(), Some("broker")); + assert_eq!( + owner_conflict, + Some(MacosDaemonOwnerConflictStatus { + active: Some("launchd_service".to_owned()), + contender: Some("homebrew_service".to_owned()), + observed_at_ms: Some(1_725_000_000_123), + }) + ); + + let partial: InputSourceStatus = serde_json::from_value(json!({ + "platform": { "type": "macos_input" } + })) + .expect("partial macOS input status should decode"); + assert!(matches!( + partial.platform, + Some(InputSourcePlatformStatus::MacosInput { + keyboard: None, + owner_conflict: None, + .. + }) + )); + } + + #[test] + fn input_source_status_decodes_macos_screen_platform_tolerantly() { + let status: InputSourceStatus = serde_json::from_value(json!({ + "platform": { + "type": "macos_screen", + "state": "interrupted", + "tcc": "denied", + "owner": "standalone", + "selection": { + "type": "session_scoped", + "content_style": "multiple_windows", + "future_selection_field": "ignored" + }, + "tahoe_selection": { + "source_id": "session:23", + "capture_session_generation": 29, + "hdr_capture": true, + "dual_range_screenshots": true, + "future_tahoe_field": 4 + }, + "owner_conflict": { + "active": "standalone", + "contender": "app", + "observed_at_ms": 1_725_000_000_456_u64 + }, + "future_probe": { "available": true } + } + })) + .expect("macOS screen status should decode"); + + let Some(InputSourcePlatformStatus::MacosScreen { + state, + tcc, + owner, + selection, + tahoe_selection, + owner_conflict, + }) = status.platform + else { + panic!("fixture should decode the macOS screen variant"); + }; + + assert_eq!(state.as_deref(), Some("interrupted")); + assert_eq!(tcc.as_deref(), Some("denied")); + assert_eq!(owner.as_deref(), Some("standalone")); + assert_eq!( + selection, + Some(MacosSelectionStatus::SessionScoped { + content_style: Some("multiple_windows".to_owned()), + }) + ); + assert_eq!( + tahoe_selection, + Some(MacosTahoeSelectionStatus { + source_id: Some("session:23".to_owned()), + capture_session_generation: Some(29), + hdr_capture: Some(true), + dual_range_screenshots: Some(true), + }) + ); + assert_eq!( + owner_conflict, + Some(MacosDaemonOwnerConflictStatus { + active: Some("standalone".to_owned()), + contender: Some("app".to_owned()), + observed_at_ms: Some(1_725_000_000_456), + }) + ); + } + + #[test] + fn input_source_status_decodes_absent_platform() { + let status: InputSourceStatus = serde_json::from_value(json!({ + "source_id": "linux:host-input", + "future_source_field": true + })) + .expect("status without platform should decode"); + + assert_eq!(status.source_id, "linux:host-input"); + assert_eq!(status.platform, None); + } + + #[test] + fn input_source_status_decodes_future_platform_variant() { + let status: InputSourceStatus = serde_json::from_value(json!({ + "platform": { + "type": "future_platform", + "future_state": "live" + } + })) + .expect("future platform status should decode"); + + assert_eq!(status.platform, Some(InputSourcePlatformStatus::Unknown)); + } +} From 154d0c0bf4d4e7a1b0b1a78fc864a883ab50a5bd Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:26:21 -0700 Subject: [PATCH 040/144] feat(macos): add typed capture source selector Parse the macOS source grammar at the native capture boundary. Normalize persisted display UUIDs to one lowercase identity. Invalid selectors fail before ScreenCaptureKit starts. Co-Authored-By: Nova (OpenAI Codex) --- Cargo.lock | 1 + crates/hypercolor-macos-capture/Cargo.toml | 1 + .../src/diagnostics.rs | 1 + crates/hypercolor-macos-capture/src/frame.rs | 2 + crates/hypercolor-macos-capture/src/lib.rs | 3 +- .../hypercolor-macos-capture/src/session.rs | 28 +++++++++++++ .../tests/capture_contract_tests.rs | 40 ++++++++++++++++--- 7 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4788c5d0..e4fccfdd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5331,6 +5331,7 @@ dependencies = [ "objc2-io-surface", "objc2-screen-capture-kit", "thiserror 2.0.18", + "uuid", ] [[package]] diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml index 28a6954fe..b25b38f85 100644 --- a/crates/hypercolor-macos-capture/Cargo.toml +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -21,6 +21,7 @@ capture-fixtures = [] [dependencies] thiserror = { workspace = true } +uuid = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 723e76a7f..294861606 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -47,6 +47,7 @@ impl MacosFrameDropReason { MacosCaptureError::InvalidCadence(_) | MacosCaptureError::NotMainThread | MacosCaptureError::ScreenCapturePermissionRequired + | MacosCaptureError::InvalidSourceSelector(_) | MacosCaptureError::NativeOperation { .. } | MacosCaptureError::RetainNativeFilterFailed | MacosCaptureError::DisplayUuidUnavailable(_) diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index baf58d987..d9fd696a0 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -741,6 +741,8 @@ pub enum MacosCaptureError { NotMainThread, #[error("screen-capture authorization requires explicit user action")] ScreenCapturePermissionRequired, + #[error("invalid macOS capture source selector: {0}")] + InvalidSourceSelector(String), #[error("{operation} failed with native error {code}: {message}")] NativeOperation { operation: &'static str, diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index ff6ea486a..f397bc0ee 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -31,5 +31,6 @@ pub use geometry::{ }; pub use mailbox::MacosFrameMailbox; pub use session::{ - MacosCaptureCadence, MacosCaptureContentStyle, MacosCaptureSelection, MacosStreamRequest, + MacosCaptureCadence, MacosCaptureContentStyle, MacosCaptureSelection, MacosCaptureSelector, + MacosStreamRequest, }; diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs index d94424f73..390c7ac49 100644 --- a/crates/hypercolor-macos-capture/src/session.rs +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -2,6 +2,34 @@ use std::sync::Arc; use crate::MacosCaptureError; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum MacosCaptureSelector { + Auto, + PrimaryDisplay, + Display { source_id: Arc }, + SessionScoped, +} + +impl MacosCaptureSelector { + pub fn parse(source: &str) -> Result { + match source.trim() { + "auto" => Ok(Self::Auto), + "primary_display" => Ok(Self::PrimaryDisplay), + "session_scoped" => Ok(Self::SessionScoped), + source => { + let Some(uuid) = source.strip_prefix("display:") else { + return Err(MacosCaptureError::InvalidSourceSelector(source.to_owned())); + }; + let uuid = uuid::Uuid::parse_str(uuid) + .map_err(|_| MacosCaptureError::InvalidSourceSelector(source.to_owned()))?; + Ok(Self::Display { + source_id: Arc::from(format!("display:{}", uuid.hyphenated())), + }) + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MacosCaptureContentStyle { Window, diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index f0121e591..09e293404 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -3,12 +3,12 @@ use std::sync::Arc; use hypercolor_macos_capture::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureError, - MacosCapturePixelFormat, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, - MacosColorRange, MacosDisplayClock, MacosDisplayClockError, MacosFrameDecoder, - MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosGeometryError, - MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, - MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, MacosStreamRequest, - MacosTransferFunction, MacosYuvMatrix, + MacosCapturePixelFormat, MacosCaptureSelector, MacosCaptureSurface, MacosChromaLocation, + MacosColorPrimaries, MacosColorRange, MacosDisplayClock, MacosDisplayClockError, + MacosFrameDecoder, MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, + MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, + MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, }; use std::time::{Duration, Instant}; @@ -83,6 +83,34 @@ fn stream_requests_preserve_native_refresh_and_reject_invalid_rates() { ); } +#[test] +fn capture_selectors_parse_and_normalize_display_identity() { + assert_eq!( + MacosCaptureSelector::parse("auto"), + Ok(MacosCaptureSelector::Auto) + ); + assert_eq!( + MacosCaptureSelector::parse("primary_display"), + Ok(MacosCaptureSelector::PrimaryDisplay) + ); + assert_eq!( + MacosCaptureSelector::parse("session_scoped"), + Ok(MacosCaptureSelector::SessionScoped) + ); + assert_eq!( + MacosCaptureSelector::parse("display:550E8400-E29B-41D4-A716-446655440000"), + Ok(MacosCaptureSelector::Display { + source_id: Arc::from("display:550e8400-e29b-41d4-a716-446655440000"), + }) + ); + assert_eq!( + MacosCaptureSelector::parse("display:not-a-uuid"), + Err(MacosCaptureError::InvalidSourceSelector( + "display:not-a-uuid".to_owned() + )) + ); +} + #[test] fn all_native_frame_statuses_decode_exactly() { let expected = [ From 1b8fbca74a4cca0cf5db648aff1077e9b52454f4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:33:17 -0700 Subject: [PATCH 041/144] feat(macos): resolve configured capture displays Resolve auto and primary selectors through the current main display. Resolve persisted display UUIDs against ScreenCaptureKit shareable content. Fence asynchronous enumeration so newer picker choices always win. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 5 +- .../src/diagnostics.rs | 2 + crates/hypercolor-macos-capture/src/frame.rs | 4 + crates/hypercolor-macos-capture/src/native.rs | 352 ++++++++++++++---- .../hypercolor-macos-capture/src/session.rs | 21 ++ .../tests/capture_contract_tests.rs | 11 + 6 files changed, 315 insertions(+), 80 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 1a7378fa8..e9aba47da 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -12,7 +12,7 @@ use hypercolor_macos_capture::{ #[cfg(target_os = "macos")] use hypercolor_macos_capture::{ - MacosCaptureCadence, MacosScreenCaptureSession, MacosStreamRequest, + MacosCaptureCadence, MacosCaptureSelector, MacosScreenCaptureSession, MacosStreamRequest, }; use super::{ @@ -136,7 +136,8 @@ impl MacosScreenCaptureInput { MacosCaptureCadence::FramesPerSecond(config.target_fps), true, )?; - let session = MacosScreenCaptureSession::new(request)?; + let selector = MacosCaptureSelector::parse(&config.source)?; + let session = MacosScreenCaptureSession::new(request, selector)?; let clock = MacosDisplayClock::system()?; Ok(Self::with_control( config, diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 294861606..379214b0e 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -51,6 +51,8 @@ impl MacosFrameDropReason { | MacosCaptureError::NativeOperation { .. } | MacosCaptureError::RetainNativeFilterFailed | MacosCaptureError::DisplayUuidUnavailable(_) + | MacosCaptureError::DisplaySourceUnavailable(_) + | MacosCaptureError::MissingShareableContent | MacosCaptureError::PlaneCount { .. } | MacosCaptureError::InvalidPlaneIndex { .. } | MacosCaptureError::InvalidPlaneExtent { .. } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index d9fd696a0..ac74ddaf4 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -805,6 +805,10 @@ pub enum MacosCaptureError { RetainNativeFilterFailed, #[error("display {0} has no canonical Core Graphics UUID")] DisplayUuidUnavailable(u32), + #[error("configured display source is unavailable: {0}")] + DisplaySourceUnavailable(String), + #[error("ScreenCaptureKit returned no shareable-content result")] + MissingShareableContent, #[error("capture surface has no CPU-mappable fixture or pixel buffer")] CpuMappingUnavailable, #[error("mapped CPU planes do not match the validated frame layout")] diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 802c49bd3..f5ed0cee7 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -1,7 +1,6 @@ -use std::cell::{Cell, RefCell}; use std::fmt; use std::ptr::{self, NonNull}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use block2::RcBlock; @@ -13,8 +12,8 @@ use objc2_core_foundation::{ CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType, CFUUID, CGPoint, CGRect, CGSize, }; use objc2_core_graphics::{ - CGDirectDisplayID, CGPreflightScreenCaptureAccess, CGRectMakeWithDictionaryRepresentation, - CGRequestScreenCaptureAccess, + CGDirectDisplayID, CGMainDisplayID, CGPreflightScreenCaptureAccess, + CGRectMakeWithDictionaryRepresentation, CGRequestScreenCaptureAccess, }; use objc2_core_media::{CMSampleBuffer, CMTime}; use objc2_core_video::{ @@ -32,24 +31,24 @@ use objc2_core_video::{ kCVImageBufferYCbCrMatrix_ITU_R_601_4, kCVImageBufferYCbCrMatrix_ITU_R_709_2, kCVImageBufferYCbCrMatrix_ITU_R_2020, kCVImageBufferYCbCrMatrixKey, }; -use objc2_foundation::{NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSValue}; +use objc2_foundation::{NSArray, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSValue}; use objc2_screen_capture_kit::{ SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, SCContentSharingPickerConfiguration, SCContentSharingPickerMode, - SCContentSharingPickerObserver, SCStream, SCStreamConfiguration, SCStreamDelegate, - SCStreamErrorCode, SCStreamErrorDomain, SCStreamFrameInfoBoundingRect, + SCContentSharingPickerObserver, SCShareableContent, SCStream, SCStreamConfiguration, + SCStreamDelegate, SCStreamErrorCode, SCStreamErrorDomain, SCStreamFrameInfoBoundingRect, SCStreamFrameInfoContentRect, SCStreamFrameInfoContentScale, SCStreamFrameInfoDirtyRects, SCStreamFrameInfoDisplayTime, SCStreamFrameInfoScaleFactor, SCStreamFrameInfoScreenRect, - SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, + SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, SCWindow, }; use crate::diagnostics::CallbackCounters; use crate::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureError, MacosCapturePixelFormat, - MacosCaptureSelection, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, - MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, - MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, + MacosCaptureSelection, MacosCaptureSelector, MacosCaptureSurface, MacosChromaLocation, + MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameMailbox, + MacosFrameStatus, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, }; @@ -59,18 +58,24 @@ struct SessionShared { mailbox: MacosFrameMailbox, status: Mutex, selection: Mutex, + selector: Mutex, counters: CallbackCounters, + capture_active: AtomicBool, current_epoch: AtomicU64, + resolution_epoch: AtomicU64, } impl SessionShared { - fn new(status: MacosProtectedSourceState) -> Self { + fn new(status: MacosProtectedSourceState, selector: MacosCaptureSelector) -> Self { Self { mailbox: MacosFrameMailbox::new(), status: Mutex::new(status), selection: Mutex::new(MacosCaptureSelection::None), + selector: Mutex::new(selector), counters: CallbackCounters::default(), + capture_active: AtomicBool::new(false), current_epoch: AtomicU64::new(0), + resolution_epoch: AtomicU64::new(0), } } @@ -90,6 +95,35 @@ impl SessionShared { *lock(&self.selection) = selection; } + fn selector(&self) -> MacosCaptureSelector { + lock(&self.selector).clone() + } + + fn set_selector(&self, selector: MacosCaptureSelector) { + *lock(&self.selector) = selector; + } + + fn capture_active(&self) -> bool { + self.capture_active.load(Ordering::Acquire) + } + + fn set_capture_active(&self, active: bool) -> bool { + self.capture_active.swap(active, Ordering::AcqRel) + } + + fn begin_resolution(&self) -> Result { + self.resolution_epoch + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |epoch| { + epoch.checked_add(1) + }) + .map(|epoch| epoch + 1) + .map_err(|_| MacosCaptureError::SequenceExhausted) + } + + fn resolution_is_current(&self, epoch: u64) -> bool { + self.resolution_epoch.load(Ordering::Acquire) == epoch + } + fn current_epoch(&self) -> u64 { self.current_epoch.load(Ordering::Acquire) } @@ -355,6 +389,7 @@ struct StreamState { struct StreamSlot { state: Mutex, shared: Arc, + next_epoch: AtomicU64, } impl StreamSlot { @@ -362,9 +397,18 @@ impl StreamSlot { Arc::new(Self { state: Mutex::new(StreamState::default()), shared, + next_epoch: AtomicU64::new(1), }) } + fn allocate_epoch(&self) -> Result { + self.next_epoch + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |epoch| { + epoch.checked_add(1) + }) + .map_err(|_| MacosCaptureError::SequenceExhausted) + } + fn stage_candidate( self: &Arc, filter: &SCContentFilter, @@ -545,8 +589,6 @@ struct PickerObserverIvars { shared: Arc, streams: Arc, request: MacosStreamRequest, - next_epoch: RefCell, - active: Cell, } define_class!( @@ -581,25 +623,16 @@ define_class!( filter: &SCContentFilter, _stream: Option<&SCStream>, ) { - if self.ivars().active.get() { - self.install_filter(filter); - } else if let Err(error) = self.ivars().streams.store_selection(filter) { - if self.ivars().streams.has_selection() { - self.ivars() - .shared - .set_status(MacosProtectedSourceState::ReadyIdle); - self.ivars().shared.publish_recoverable_error(error); - } else { - self.ivars() - .shared - .set_status(MacosProtectedSourceState::Failed); - self.ivars().shared.publish_error(error); - } - } else { - self.ivars() - .shared - .set_status(MacosProtectedSourceState::ReadyIdle); + if let Err(error) = self.ivars().shared.begin_resolution() { + handle_filter_error(&self.ivars().streams, &self.ivars().shared, error); + return; } + accept_filter( + &self.ivars().streams, + &self.ivars().shared, + self.ivars().request, + filter, + ); } #[allow(non_snake_case)] @@ -637,8 +670,6 @@ impl PickerObserver { shared, streams, request, - next_epoch: RefCell::new(1), - active: Cell::new(false), }); // SAFETY: NSObject has no additional initialization requirements for // this main-thread observer subclass. @@ -646,30 +677,12 @@ impl PickerObserver { } fn install_filter(&self, filter: &SCContentFilter) { - let epoch = *self.ivars().next_epoch.borrow(); - let result = epoch - .checked_add(1) - .ok_or(MacosCaptureError::SequenceExhausted) - .and_then(|next_epoch| { - *self.ivars().next_epoch.borrow_mut() = next_epoch; - self.ivars() - .streams - .stage_candidate(filter, self.ivars().request, epoch) - }); - if let Err(error) = result { - let preserve_current = self.ivars().streams.has_current(); - let status = if preserve_current { - MacosProtectedSourceState::Live - } else { - MacosProtectedSourceState::Failed - }; - self.ivars().shared.set_status(status); - if preserve_current { - self.ivars().shared.publish_recoverable_error(error); - } else { - self.ivars().shared.publish_error(error); - } - } + stage_filter( + &self.ivars().streams, + &self.ivars().shared, + self.ivars().request, + filter, + ); } fn present(&self, picker: &SCContentSharingPicker) { @@ -685,7 +698,7 @@ impl PickerObserver { } fn set_active(&self, active: bool) { - if self.ivars().active.replace(active) == active { + if self.ivars().shared.set_capture_active(active) == active { return; } if !active { @@ -708,11 +721,60 @@ impl PickerObserver { } fn stop(&self) { - self.ivars().active.set(false); + self.ivars().shared.set_capture_active(false); self.ivars().streams.stop(); } } +fn accept_filter( + streams: &Arc, + shared: &Arc, + request: MacosStreamRequest, + filter: &SCContentFilter, +) { + if shared.capture_active() { + stage_filter(streams, shared, request, filter); + } else if let Err(error) = streams.store_selection(filter) { + handle_filter_error(streams, shared, error); + } else { + shared.set_status(MacosProtectedSourceState::ReadyIdle); + } +} + +fn stage_filter( + streams: &Arc, + shared: &Arc, + request: MacosStreamRequest, + filter: &SCContentFilter, +) { + let result = streams + .allocate_epoch() + .and_then(|epoch| streams.stage_candidate(filter, request, epoch)); + if let Err(error) = result { + handle_filter_error(streams, shared, error); + } +} + +fn handle_filter_error(streams: &StreamSlot, shared: &SessionShared, error: MacosCaptureError) { + let preserve_current = streams.has_current(); + let preserve_selection = streams.has_selection(); + let status = if preserve_current { + MacosProtectedSourceState::Live + } else if preserve_selection { + MacosProtectedSourceState::ReadyIdle + } else if matches!(error, MacosCaptureError::DisplaySourceUnavailable(_)) { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::Failed + }; + shared.set_status(status); + if preserve_current || preserve_selection { + shared.publish_recoverable_error(error); + } else { + shared.publish_error(error); + } +} + struct MainThreadSession { picker: Retained, observer: Retained, @@ -721,19 +783,26 @@ struct MainThreadSession { pub struct MacosScreenCaptureSession { main: MainThreadBound, shared: Arc, + streams: Arc, + request: MacosStreamRequest, } impl MacosScreenCaptureSession { - pub fn new(request: MacosStreamRequest) -> Result { + pub fn new( + request: MacosStreamRequest, + selector: MacosCaptureSelector, + ) -> Result { request.cadence.timescale()?; let mtm = MainThreadMarker::new().ok_or(MacosCaptureError::NotMainThread)?; - let status = if CGPreflightScreenCaptureAccess() { + let authorized = CGPreflightScreenCaptureAccess(); + let status = if authorized { MacosProtectedSourceState::NeedsSelection } else { MacosProtectedSourceState::NeedsUserAction }; - let shared = Arc::new(SessionShared::new(status)); + let shared = Arc::new(SessionShared::new(status, selector)); let observer = PickerObserver::new(mtm, request, Arc::clone(&shared)); + let streams = Arc::clone(&observer.ivars().streams); // SAFETY: These are main-thread ScreenCaptureKit setup calls. The // observer remains retained by this session until it is removed. let picker = unsafe { @@ -756,10 +825,16 @@ impl MacosScreenCaptureSession { picker.setActive(true); picker }; - Ok(Self { + let session = Self { main: MainThreadBound::new(MainThreadSession { picker, observer }, mtm), shared, - }) + streams, + request, + }; + if authorized { + session.resolve_configured_source()?; + } + Ok(session) } pub fn screen_authorized() -> bool { @@ -767,13 +842,17 @@ impl MacosScreenCaptureSession { } pub fn request_authorization(&self) -> MacosProtectedSourceState { - let status = if CGRequestScreenCaptureAccess() { - MacosProtectedSourceState::NeedsSelection + if CGRequestScreenCaptureAccess() { + self.shared + .set_status(MacosProtectedSourceState::NeedsSelection); + if let Err(error) = self.resolve_configured_source() { + handle_filter_error(&self.streams, &self.shared, error); + } } else { - MacosProtectedSourceState::PermissionDenied - }; - self.shared.set_status(status); - status + self.shared + .set_status(MacosProtectedSourceState::PermissionDenied); + } + self.shared.status() } pub fn present_picker(&self) -> Result<(), MacosCaptureError> { @@ -782,6 +861,7 @@ impl MacosScreenCaptureSession { .set_status(MacosProtectedSourceState::NeedsUserAction); return Err(MacosCaptureError::ScreenCapturePermissionRequired); } + self.shared.begin_resolution()?; self.main .get_on_main(|main| main.observer.present(&main.picker)); Ok(()) @@ -811,6 +891,117 @@ impl MacosScreenCaptureSession { self.main .get_on_main(|main| main.observer.set_active(active)); } + + pub fn set_selector(&self, selector: MacosCaptureSelector) -> Result<(), MacosCaptureError> { + self.shared.set_selector(selector); + if CGPreflightScreenCaptureAccess() { + self.resolve_configured_source() + } else { + self.shared + .set_status(MacosProtectedSourceState::NeedsUserAction); + Ok(()) + } + } + + fn resolve_configured_source(&self) -> Result<(), MacosCaptureError> { + let selector = self.shared.selector(); + if selector == MacosCaptureSelector::SessionScoped { + self.shared + .set_status(MacosProtectedSourceState::NeedsSelection); + return Ok(()); + } + resolve_display_selector( + Arc::clone(&self.streams), + Arc::clone(&self.shared), + self.request, + selector, + ) + } +} + +fn resolve_display_selector( + streams: Arc, + shared: Arc, + request: MacosStreamRequest, + selector: MacosCaptureSelector, +) -> Result<(), MacosCaptureError> { + let resolution_epoch = shared.begin_resolution()?; + let completion = RcBlock::new( + move |content: *mut SCShareableContent, error: *mut NSError| { + if !shared.resolution_is_current(resolution_epoch) { + return; + } + // SAFETY: ScreenCaptureKit supplies callback objects for the + // duration of this invocation. Derived owners are retained before + // the callback returns. + let result = unsafe { + if let Some(error) = error.as_ref() { + Err(native_error("enumerate ScreenCaptureKit content", error)) + } else { + content + .as_ref() + .ok_or(MacosCaptureError::MissingShareableContent) + .and_then(|content| display_filter(content, &selector)) + } + }; + if !shared.resolution_is_current(resolution_epoch) { + return; + } + match result { + Ok(filter) => accept_filter(&streams, &shared, request, &filter), + Err(error) => handle_filter_error(&streams, &shared, error), + } + }, + ); + // SAFETY: ScreenCaptureKit copies the completion block for asynchronous + // use. The block owns every Rust value captured by the callback. + unsafe { SCShareableContent::getShareableContentWithCompletionHandler(&completion) }; + Ok(()) +} + +fn display_filter( + content: &SCShareableContent, + selector: &MacosCaptureSelector, +) -> Result, MacosCaptureError> { + // SAFETY: Shareable content owns an immutable display snapshot. The + // returned array and each selected display are retained locally. + let displays = unsafe { content.displays() }; + let primary_display = CGMainDisplayID(); + let mut primary_uuid_error = None; + for display in displays.to_vec() { + // SAFETY: The retained SCDisplay remains live for this query. + let display_id = unsafe { display.displayID() }; + let source_id = match display_source_id(display_id) { + Ok(source_id) => source_id, + Err(error) if display_id == primary_display => { + primary_uuid_error = Some(error); + continue; + } + Err(_) => continue, + }; + if selector.matches_display(&source_id, display_id == primary_display) { + let excluded = NSArray::::from_slice(&[]); + // SAFETY: The filter retains the selected display and the empty + // exclusion list. The display comes from this content snapshot. + return Ok(unsafe { + SCContentFilter::initWithDisplay_excludingWindows( + SCContentFilter::alloc(), + &display, + &excluded, + ) + }); + } + } + if matches!( + selector, + MacosCaptureSelector::Auto | MacosCaptureSelector::PrimaryDisplay + ) && let Some(error) = primary_uuid_error + { + return Err(error); + } + Err(MacosCaptureError::DisplaySourceUnavailable( + selector.configured_source().to_owned(), + )) } fn selection_from_filter( @@ -830,14 +1021,9 @@ fn selection_from_filter( .firstObject() .ok_or(MacosCaptureError::DisplayUuidUnavailable(0))?; let display_id = display.displayID(); - let uuid = display_uuid(display_id) - .ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))?; - let source_id = CFUUID::new_string(None, Some(&uuid)) - .ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))? - .to_string() - .to_ascii_lowercase(); + let source_id = display_source_id(display_id)?; return Ok(MacosCaptureSelection::Display { - source_id: Arc::from(format!("display:{source_id}")), + source_id: Arc::from(source_id), }); } let content_style = if !windows.is_empty() && !applications.is_empty() { @@ -855,6 +1041,16 @@ fn selection_from_filter( } } +fn display_source_id(display_id: CGDirectDisplayID) -> Result { + let uuid = + display_uuid(display_id).ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))?; + let uuid = CFUUID::new_string(None, Some(&uuid)) + .ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))? + .to_string() + .to_ascii_lowercase(); + Ok(format!("display:{uuid}")) +} + fn display_uuid(display_id: CGDirectDisplayID) -> Option> { unsafe extern "C-unwind" { fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> Option>; diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs index 390c7ac49..480f8f277 100644 --- a/crates/hypercolor-macos-capture/src/session.rs +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -28,6 +28,27 @@ impl MacosCaptureSelector { } } } + + #[must_use] + pub fn configured_source(&self) -> &str { + match self { + Self::Auto => "auto", + Self::PrimaryDisplay => "primary_display", + Self::Display { source_id } => source_id, + Self::SessionScoped => "session_scoped", + } + } + + #[must_use] + pub fn matches_display(&self, source_id: &str, primary: bool) -> bool { + match self { + Self::Auto | Self::PrimaryDisplay => primary, + Self::Display { + source_id: configured, + } => configured.as_ref() == source_id, + Self::SessionScoped => false, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 09e293404..843729ea6 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -109,6 +109,17 @@ fn capture_selectors_parse_and_normalize_display_identity() { "display:not-a-uuid".to_owned() )) ); + + let explicit = MacosCaptureSelector::parse("display:550e8400-e29b-41d4-a716-446655440000") + .expect("canonical display selector should parse"); + assert_eq!( + explicit.configured_source(), + "display:550e8400-e29b-41d4-a716-446655440000" + ); + assert!(explicit.matches_display(explicit.configured_source(), false)); + assert!(!explicit.matches_display("display:00000000-0000-0000-0000-000000000000", true)); + assert!(MacosCaptureSelector::Auto.matches_display("display:any", true)); + assert!(!MacosCaptureSelector::SessionScoped.matches_display("display:any", true)); } #[test] From b1c0fa710b631b0c8ea531db753c20a192e8cbf4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:37:49 -0700 Subject: [PATCH 042/144] feat(input): publish per-kind macOS platform state Publish keyboard TCC and lifecycle independently from pointer health. Report positive authorization with a missing tap mask as restart-required. Carry the recorded capability owner for both protected input kinds. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-core/src/input/macos.rs | 128 +++++++++++++++++- .../tests/macos_host_input_tests.rs | 49 ++++++- 2 files changed, 171 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index b97eba230..9105420f9 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -16,8 +16,9 @@ use crate::input::traits::{ InputData, InputSource, InteractionData, InteractionDegradation, MotionAggregate, PointerMode, }; use crate::input::{ - LegacyWheelProjector, SourceIssue, SourceKind, SourceSessionSlot, SourceStatusHandle, - SourceStatusReporter, + LegacyWheelProjector, MacosAuthorizationState, MacosCapabilityOwner, MacosInputPlatformStatus, + MacosProtectedSourceState, SourceIssue, SourceKind, SourcePlatformStatus, SourceSessionSlot, + SourceStatusHandle, SourceStatusReporter, }; use crate::types::event::{ InputButtonState, InputEvent, PointerScrollPhase, PointerScrollUnit, TimedInputEvent, @@ -94,6 +95,8 @@ pub struct MacosHostInput { degraded: Option, status: SourceStatusReporter, status_session: SourceSessionSlot, + keyboard_tcc: MacosAuthorizationState, + owner: MacosCapabilityOwner, #[cfg(feature = "macos-native-fixtures")] fixture: Option>, } @@ -221,7 +224,14 @@ impl MacosHostInputFixture { impl MacosHostInput { #[must_use] pub fn new(capture_keyboard: bool, capture_pointer: bool) -> Self { - Self { + let keyboard_tcc = if !capture_keyboard { + MacosAuthorizationState::Unknown + } else if input_monitoring_granted() { + MacosAuthorizationState::Authorized + } else { + MacosAuthorizationState::NotDetermined + }; + let mut source = Self { name: "MacosHostInput".to_owned(), running: false, capture_active: false, @@ -242,9 +252,15 @@ impl MacosHostInput { false, ), status_session: SourceSessionSlot::new(), + keyboard_tcc, + owner: MacosCapabilityOwner::Standalone, #[cfg(feature = "macos-native-fixtures")] fixture: None, - } + }; + source + .refresh_platform_status() + .expect("new macOS input status is not retired"); + source } #[cfg(feature = "macos-native-fixtures")] @@ -255,11 +271,22 @@ impl MacosHostInput { backend: MacosInputFixtureBackend, ) -> (Self, MacosHostInputFixture) { let mut source = Self::new(capture_keyboard, capture_pointer); + let preflight_granted = backend.preflight_granted; let state = Arc::new(FixtureState { backend: Mutex::new(backend), active_epoch: Mutex::new(None), }); source.fixture = Some(Arc::clone(&state)); + source.keyboard_tcc = if capture_keyboard && preflight_granted { + MacosAuthorizationState::Authorized + } else if capture_keyboard { + MacosAuthorizationState::NotDetermined + } else { + MacosAuthorizationState::Unknown + }; + source + .refresh_platform_status() + .expect("fixture macOS input status is not retired"); let fixture = MacosHostInputFixture { state, shared: Arc::clone(&source.shared), @@ -283,6 +310,11 @@ impl MacosHostInput { (self.capture_keyboard, self.capture_pointer) } + pub fn set_capability_owner(&mut self, owner: MacosCapabilityOwner) -> anyhow::Result<()> { + self.owner = owner; + self.refresh_platform_status() + } + #[must_use] pub fn fold_diagnostics(&self) -> MacosInputFoldDiagnostics { self.shared @@ -372,7 +404,83 @@ impl MacosHostInput { .unwrap_or_else(std::sync::PoisonError::into_inner) .preflight_granted; } - input_monitoring_granted() + self.keyboard_tcc == MacosAuthorizationState::Authorized + } + + fn effective_kinds(&self) -> (bool, bool) { + if let Some(session) = &self.session { + let masks = session.effective_masks(); + return (masks.keyboard != 0, masks.pointer != 0); + } + #[cfg(feature = "macos-native-fixtures")] + if let Some(fixture) = &self.fixture + && self.fixture_session_active() + { + let masks = fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .effective_masks; + return ( + self.capture_keyboard && masks.keyboard != 0, + self.capture_pointer && masks.pointer != 0, + ); + } + (false, false) + } + + fn refresh_platform_status(&mut self) -> anyhow::Result<()> { + let (keyboard_live, pointer_live) = self.effective_kinds(); + let interrupted = matches!(self.degraded, Some(InteractionDegradation::Unavailable(_))); + let revoked = + self.degraded == Some(InteractionDegradation::InputMonitoringPermissionRevoked); + let keyboard = if !self.capture_keyboard { + MacosProtectedSourceState::Disabled + } else if revoked { + MacosProtectedSourceState::Revoked + } else { + match self.keyboard_tcc { + MacosAuthorizationState::Unknown | MacosAuthorizationState::NotDetermined => { + MacosProtectedSourceState::NeedsUserAction + } + MacosAuthorizationState::Denied => MacosProtectedSourceState::PermissionDenied, + MacosAuthorizationState::Authorized if !self.capture_active => { + MacosProtectedSourceState::ReadyIdle + } + MacosAuthorizationState::Authorized if keyboard_live && interrupted => { + MacosProtectedSourceState::Interrupted + } + MacosAuthorizationState::Authorized if keyboard_live => { + MacosProtectedSourceState::Live + } + MacosAuthorizationState::Authorized => { + MacosProtectedSourceState::NeedsProcessRestart + } + } + }; + let pointer = if !self.capture_pointer { + MacosProtectedSourceState::Disabled + } else if !self.capture_active { + MacosProtectedSourceState::ReadyIdle + } else if pointer_live && interrupted { + MacosProtectedSourceState::Interrupted + } else if pointer_live { + MacosProtectedSourceState::Live + } else { + MacosProtectedSourceState::Failed + }; + self.status + .set_platform(Some(SourcePlatformStatus::MacosInput( + MacosInputPlatformStatus { + keyboard, + pointer, + keyboard_tcc: self.keyboard_tcc, + keyboard_owner: self.owner, + pointer_owner: self.owner, + owner_conflict: None, + }, + )))?; + Ok(()) } fn active_kind_count(&self) -> usize { @@ -592,6 +700,7 @@ impl InputSource for MacosHostInput { self.start_session(); } self.running = true; + self.refresh_platform_status()?; Ok(()) } @@ -600,10 +709,13 @@ impl InputSource for MacosHostInput { self.status.stop(); self.stop_session(); self.running = false; + self.refresh_platform_status() + .expect("live macOS input status is not retired"); } fn sample(&mut self) -> anyhow::Result { self.refresh_worker_health(); + self.refresh_platform_status()?; if !self.running || !self.capture_session_active() { return Ok(InputData::None); } @@ -619,6 +731,9 @@ impl InputSource for MacosHostInput { _delta_secs: f32, ) -> (anyhow::Result, Vec) { self.refresh_worker_health(); + if let Err(error) = self.refresh_platform_status() { + return (Err(error), Vec::new()); + } if !self.running || !self.capture_session_active() { return (Ok(InputData::None), Vec::new()); } @@ -689,10 +804,12 @@ impl InputSource for MacosHostInput { fn set_interaction_capture_active(&mut self, active: bool) -> anyhow::Result<()> { self.status.set_policy(true, true, active)?; if self.capture_active == active { + self.refresh_platform_status()?; return Ok(()); } self.capture_active = active; if !self.running { + self.refresh_platform_status()?; return Ok(()); } if active { @@ -704,6 +821,7 @@ impl InputSource for MacosHostInput { self.status_session.clear(); self.stop_session(); } + self.refresh_platform_status()?; Ok(()) } } diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs index f35926b9f..17d42e999 100644 --- a/crates/hypercolor-core/tests/macos_host_input_tests.rs +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -333,7 +333,8 @@ fn state_gap_synthesizes_releases_and_stale_epoch_is_inert() { #[cfg(feature = "macos-native-fixtures")] mod fixtures { use hypercolor_core::input::{ - InputData, InputSource, MacosHostInput, MacosInputFixtureBackend, SourceState, + InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, MacosHostInput, + MacosInputFixtureBackend, MacosProtectedSourceState, SourcePlatformStatus, SourceState, }; use hypercolor_macos_input::{MacosInputEvent, event_masks}; @@ -357,6 +358,21 @@ mod fixtures { assert!(fixture.is_active()); assert_eq!(status.snapshot().state, SourceState::Degraded); assert_eq!(status.snapshot().resource_count, 1); + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosInput(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS input platform status"); + }; + assert_eq!( + platform.keyboard, + MacosProtectedSourceState::NeedsUserAction + ); + assert_eq!(platform.pointer, MacosProtectedSourceState::Live); + assert_eq!( + platform.keyboard_tcc, + MacosAuthorizationState::NotDetermined + ); + assert_eq!(platform.keyboard_owner, MacosCapabilityOwner::Standalone); + assert_eq!(platform.pointer_owner, MacosCapabilityOwner::Standalone); assert_eq!( status .snapshot() @@ -451,6 +467,37 @@ mod fixtures { .as_ref(), "macos_input_tap_create_failed" ); + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosInput(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS input platform status"); + }; + assert_eq!( + platform.keyboard, + MacosProtectedSourceState::NeedsProcessRestart + ); + assert_eq!(platform.pointer, MacosProtectedSourceState::Failed); + assert_eq!(platform.keyboard_tcc, MacosAuthorizationState::Authorized); + } + + #[test] + fn capability_owner_updates_both_input_kinds() { + let backend = + MacosInputFixtureBackend::new(true, true, event_masks(true, true), true, desktop(1)); + let (mut source, _) = MacosHostInput::new_deterministic_fixture(true, true, backend); + let status = source + .source_status_handle() + .expect("macOS host source exposes status"); + + source + .set_capability_owner(MacosCapabilityOwner::AppSidecar) + .expect("owner update should publish"); + + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosInput(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS input platform status"); + }; + assert_eq!(platform.keyboard_owner, MacosCapabilityOwner::AppSidecar); + assert_eq!(platform.pointer_owner, MacosCapabilityOwner::AppSidecar); } #[test] From d1b8884a833cd959bc8753abaefca71c79952151 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:43:34 -0700 Subject: [PATCH 043/144] feat(macos): add explicit protected-source actions Expose Input Monitoring and Screen Recording authorization as explicit POST actions. Clone native actions out of the input graph before prompting so no manager lock crosses macOS UI or a blocking TCC request. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-core/src/input/macos.rs | 80 +++++++++++++++- crates/hypercolor-core/src/input/mod.rs | 32 ++++++- .../hypercolor-core/src/input/screen/macos.rs | 17 +++- crates/hypercolor-core/src/input/traits.rs | 21 +++++ .../tests/macos_host_input_tests.rs | 25 +++++ .../tests/macos_screen_capture_tests.rs | 25 +++++ crates/hypercolor-daemon/src/api/capture.rs | 91 +++++++++++++++++-- crates/hypercolor-daemon/src/api/mod.rs | 8 ++ crates/hypercolor-ui/src/api/config.rs | 14 +++ 9 files changed, 301 insertions(+), 12 deletions(-) diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index 9105420f9..0b77d470b 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -1,19 +1,21 @@ //! macOS host input folded from Core Graphics event-tap batches. use std::collections::{BTreeSet, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use hypercolor_macos_input::{ MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, MacosInputGapReason, MacosInputSession, MacosModifierFlags, MacosPointerButton, MacosScrollPhase, MacosScrollUnit, MacosVirtualDesktop, MacosWorkerDegradation, MacosWorkerState, input_monitoring_granted, + request_input_monitoring, }; use tracing::{info, warn}; use crate::input::keymap::{macos_key_name, macos_media_key_name}; use crate::input::traits::{ InputData, InputSource, InteractionData, InteractionDegradation, MotionAggregate, PointerMode, + ProtectedSourceAuthorizationAction, }; use crate::input::{ LegacyWheelProjector, MacosAuthorizationState, MacosCapabilityOwner, MacosInputPlatformStatus, @@ -26,6 +28,9 @@ use crate::types::event::{ const SOURCE_ID: &str = "host:macos"; const DEFAULT_EVENT_LIMIT: usize = crate::input::InteractionBatch::MAX_EVENTS; +const AUTHORIZATION_NONE: u8 = 0; +const AUTHORIZATION_GRANTED: u8 = 1; +const AUTHORIZATION_DENIED: u8 = 2; type HeldStateKey = (Vec, Vec, i32, i32, i32, i32, bool); @@ -97,6 +102,7 @@ pub struct MacosHostInput { status_session: SourceSessionSlot, keyboard_tcc: MacosAuthorizationState, owner: MacosCapabilityOwner, + authorization_result: Arc, #[cfg(feature = "macos-native-fixtures")] fixture: Option>, } @@ -254,6 +260,7 @@ impl MacosHostInput { status_session: SourceSessionSlot::new(), keyboard_tcc, owner: MacosCapabilityOwner::Standalone, + authorization_result: Arc::new(AtomicU8::new(AUTHORIZATION_NONE)), #[cfg(feature = "macos-native-fixtures")] fixture: None, }; @@ -483,6 +490,36 @@ impl MacosHostInput { Ok(()) } + fn apply_pending_authorization(&mut self) -> anyhow::Result<()> { + match self + .authorization_result + .swap(AUTHORIZATION_NONE, Ordering::AcqRel) + { + AUTHORIZATION_NONE => return Ok(()), + AUTHORIZATION_GRANTED => { + self.keyboard_tcc = MacosAuthorizationState::Authorized; + if matches!( + self.degraded, + Some(InteractionDegradation::InputMonitoringPermissionDenied) + ) { + self.degraded = None; + } + if self.running && self.capture_active { + self.stop_session(); + self.start_session(); + } + } + AUTHORIZATION_DENIED => { + self.keyboard_tcc = MacosAuthorizationState::Denied; + if self.capture_active { + self.degraded = Some(InteractionDegradation::InputMonitoringPermissionDenied); + } + } + _ => unreachable!("macOS authorization result is bounded"), + } + self.refresh_platform_status() + } + fn active_kind_count(&self) -> usize { if let Some(session) = &self.session { let masks = session.effective_masks(); @@ -714,6 +751,7 @@ impl InputSource for MacosHostInput { } fn sample(&mut self) -> anyhow::Result { + self.apply_pending_authorization()?; self.refresh_worker_health(); self.refresh_platform_status()?; if !self.running || !self.capture_session_active() { @@ -730,6 +768,9 @@ impl InputSource for MacosHostInput { &mut self, _delta_secs: f32, ) -> (anyhow::Result, Vec) { + if let Err(error) = self.apply_pending_authorization() { + return (Err(error), Vec::new()); + } self.refresh_worker_health(); if let Err(error) = self.refresh_platform_status() { return (Err(error), Vec::new()); @@ -775,6 +816,43 @@ impl InputSource for MacosHostInput { true } + fn input_authorization_action(&self) -> Option { + if !self.capture_keyboard { + return None; + } + let result = Arc::clone(&self.authorization_result); + #[cfg(feature = "macos-native-fixtures")] + let fixture = self.fixture.clone(); + Some(Arc::new(move || { + #[cfg(feature = "macos-native-fixtures")] + let granted = fixture + .as_ref() + .map_or_else(request_input_monitoring, |fixture| { + let mut backend = fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if backend.request_granted { + backend.preflight_granted = true; + true + } else { + false + } + }); + #[cfg(not(feature = "macos-native-fixtures"))] + let granted = request_input_monitoring(); + result.store( + if granted { + AUTHORIZATION_GRANTED + } else { + AUTHORIZATION_DENIED + }, + Ordering::Release, + ); + Ok(granted) + })) + } + fn interaction_diagnostics(&self) -> Option { let worker_degradation = self.session diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 7e6d9cd44..d1cc4a536 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -55,8 +55,9 @@ pub use status::{ }; pub use traits::{ InputData, InputSource, InteractionBatch, InteractionData, InteractionDegradation, - InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, ScreenData, - ScreenZoneColors, ScrollAggregate, + InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, + ProtectedSourceAuthorizationAction, ScreenData, ScreenSourcePickerAction, ScreenZoneColors, + ScrollAggregate, }; pub use windows::WindowsHostInput; #[cfg(all(target_os = "windows", feature = "windows-capture-fixtures"))] @@ -1885,6 +1886,33 @@ impl InputManager { result } + /// Resolve the explicit Input Monitoring request without retaining the + /// input-manager lock while native authorization UI runs. + #[must_use] + pub fn input_authorization_action(&self) -> Option { + self.sources + .iter() + .find_map(|source| source.input_authorization_action()) + } + + /// Resolve the explicit Screen Recording request without retaining the + /// input-manager lock while native authorization UI runs. + #[must_use] + pub fn screen_authorization_action(&self) -> Option { + self.sources + .iter() + .find_map(|source| source.screen_authorization_action()) + } + + /// Resolve the native picker action without retaining the input-manager + /// lock while system UI runs. + #[must_use] + pub fn screen_source_picker_action(&self) -> Option { + self.sources + .iter() + .find_map(|source| source.screen_source_picker_action()) + } + /// Ask screen sources to discard their persisted selection and re-prompt. /// /// # Errors diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index e9aba47da..20a64cb04 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -24,7 +24,9 @@ use super::{ analyze_screen_frame, }; use crate::input::status::SourceSessionSlot; -use crate::input::traits::{InputData, InputSource}; +use crate::input::traits::{ + InputData, InputSource, ProtectedSourceAuthorizationAction, ScreenSourcePickerAction, +}; use crate::input::{ MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, MacosScreenPlatformStatus, MacosSelectionState, SourceKind, SourcePlatformStatus, @@ -536,6 +538,19 @@ impl InputSource for MacosScreenCaptureInput { fn reselect_screen_source(&mut self) -> anyhow::Result<()> { self.present_picker() } + + fn screen_authorization_action(&self) -> Option { + let control = Arc::clone(&self.control); + Some(Arc::new(move || { + control.request_authorization(); + Ok(control.authorization() == MacosAuthorizationState::Authorized) + })) + } + + fn screen_source_picker_action(&self) -> Option { + let control = Arc::clone(&self.control); + Some(Arc::new(move || control.present_picker())) + } } impl Drop for MacosScreenCaptureInput { diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 93f206dd5..ff3e92429 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -14,6 +14,12 @@ use hypercolor_types::sensor::SystemSnapshot; use std::ops::Deref; use std::sync::Arc; +/// Explicit local authorization request detached from input-graph locks. +pub type ProtectedSourceAuthorizationAction = Arc anyhow::Result + Send + Sync>; + +/// Explicit native source-picker presentation detached from input-graph locks. +pub type ScreenSourcePickerAction = Arc anyhow::Result<()> + Send + Sync>; + // ── InputData ────────────────────────────────────────────────────────────── /// A single sample from an input source. @@ -873,4 +879,19 @@ pub trait InputSource: Send { fn reselect_screen_source(&mut self) -> anyhow::Result<()> { Ok(()) } + + /// Return the explicit Input Monitoring request owned by this source. + fn input_authorization_action(&self) -> Option { + None + } + + /// Return the explicit Screen Recording request owned by this source. + fn screen_authorization_action(&self) -> Option { + None + } + + /// Return the system source-picker action owned by this source. + fn screen_source_picker_action(&self) -> Option { + None + } } diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs index 17d42e999..5f940a524 100644 --- a/crates/hypercolor-core/tests/macos_host_input_tests.rs +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -512,4 +512,29 @@ mod fixtures { .expect("owner restart succeeds") ); } + + #[test] + fn authorization_action_publishes_granted_tcc_without_graph_locking() { + let backend = + MacosInputFixtureBackend::new(false, true, event_masks(true, true), true, desktop(1)); + let (mut source, _) = MacosHostInput::new_deterministic_fixture(true, true, backend); + let status = source + .source_status_handle() + .expect("macOS host source exposes status"); + let action = source + .input_authorization_action() + .expect("keyboard source should expose authorization"); + + assert!(action().expect("fixture authorization should succeed")); + source + .sample() + .expect("source should consume action result"); + + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosInput(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS input platform status"); + }; + assert_eq!(platform.keyboard_tcc, MacosAuthorizationState::Authorized); + assert_eq!(platform.keyboard, MacosProtectedSourceState::ReadyIdle); + } } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index 8a0f05739..52a6d4068 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -237,3 +237,28 @@ fn reconfiguration_fences_the_previous_worker_generation() { assert_eq!(data.grid_height, 1); assert_eq!(data.zone_colors.len(), 1); } + +#[test] +fn authorization_and_picker_actions_run_outside_graph_ownership() { + let (mut source, _) = fixture_source(CaptureConfig::default()); + let status = source + .source_status_handle() + .expect("macOS fixture exposes status"); + let authorize = source + .screen_authorization_action() + .expect("screen source exposes authorization"); + let picker = source + .screen_source_picker_action() + .expect("screen source exposes picker action"); + + assert!(authorize().expect("fixture authorization succeeds")); + picker().expect("fixture picker succeeds"); + source.sample().expect("source refreshes platform status"); + + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS screen status"); + }; + assert_eq!(platform.tcc, MacosAuthorizationState::Authorized); + assert_eq!(platform.state, CoreProtectedSourceState::NeedsSelection); +} diff --git a/crates/hypercolor-daemon/src/api/capture.rs b/crates/hypercolor-daemon/src/api/capture.rs index 8f48e9ffa..b4bf7c8df 100644 --- a/crates/hypercolor-daemon/src/api/capture.rs +++ b/crates/hypercolor-daemon/src/api/capture.rs @@ -9,6 +9,71 @@ use tracing::{info, warn}; use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; +/// `POST /api/v1/input/authorize` — Request macOS Input Monitoring. +pub async fn authorize_input_monitoring(State(state): State>) -> Response { + let Some(manager) = state.config_manager.as_ref() else { + return ApiError::internal("Config manager unavailable in this runtime"); + }; + let config = manager.get(); + if !config.input.enabled || !config.input.keyboard { + return ApiError::validation( + "Keyboard input is disabled; enable input.enabled and input.keyboard before authorizing", + ); + } + let action = { + let input_manager = state.input_manager.lock().await; + input_manager.input_authorization_action() + }; + let Some(action) = action else { + return ApiError::validation("No Input Monitoring authorization action is available"); + }; + match tokio::task::spawn_blocking(move || action()).await { + Ok(Ok(authorized)) => { + info!(authorized, "Input Monitoring authorization requested"); + ApiResponse::ok(serde_json::json!({ "authorized": authorized })) + } + Ok(Err(error)) => { + warn!(%error, "Input Monitoring authorization failed"); + ApiError::internal(format!("Failed to authorize Input Monitoring: {error}")) + } + Err(error) => ApiError::internal(format!( + "Input Monitoring authorization task failed: {error}" + )), + } +} + +/// `POST /api/v1/capture/authorize` — Request macOS Screen Recording. +pub async fn authorize_screen_recording(State(state): State>) -> Response { + let Some(manager) = state.config_manager.as_ref() else { + return ApiError::internal("Config manager unavailable in this runtime"); + }; + if !manager.get().capture.enabled { + return ApiError::validation( + "Screen capture is disabled; enable capture.enabled before authorizing", + ); + } + let action = { + let input_manager = state.input_manager.lock().await; + input_manager.screen_authorization_action() + }; + let Some(action) = action else { + return ApiError::validation("No Screen Recording authorization action is available"); + }; + match tokio::task::spawn_blocking(move || action()).await { + Ok(Ok(authorized)) => { + info!(authorized, "Screen Recording authorization requested"); + ApiResponse::ok(serde_json::json!({ "authorized": authorized })) + } + Ok(Err(error)) => { + warn!(%error, "Screen Recording authorization failed"); + ApiError::internal(format!("Failed to authorize Screen Recording: {error}")) + } + Err(error) => ApiError::internal(format!( + "Screen Recording authorization task failed: {error}" + )), + } +} + /// `POST /api/v1/capture/source/pick` — Re-open the portal source picker. /// /// Drops the persisted restore token so the desktop portal prompts for a @@ -25,14 +90,24 @@ pub async fn pick_capture_source(State(state): State>) -> Response ); } - let mut input_manager = state.input_manager.lock().await; - if !input_manager.has_screen_source() { - return ApiError::validation( - "No screen capture source is registered; restart the daemon or re-enable capture", - ); - } - - if let Err(error) = input_manager.reselect_screen_source() { + let picker_result = { + let mut input_manager = state.input_manager.lock().await; + if !input_manager.has_screen_source() { + return ApiError::validation( + "No screen capture source is registered; restart the daemon or re-enable capture", + ); + } + if let Some(action) = input_manager.screen_source_picker_action() { + drop(input_manager); + tokio::task::spawn_blocking(move || action()) + .await + .map_err(|error| anyhow::anyhow!("source picker task failed: {error}")) + .and_then(|result| result) + } else { + input_manager.reselect_screen_source() + } + }; + if let Err(error) = picker_result { warn!(%error, "Failed to re-open screen source picker"); return ApiError::internal(format!("Failed to re-open source picker: {error}")); } diff --git a/crates/hypercolor-daemon/src/api/mod.rs b/crates/hypercolor-daemon/src/api/mod.rs index ff0f77093..793e91df2 100644 --- a/crates/hypercolor-daemon/src/api/mod.rs +++ b/crates/hypercolor-daemon/src/api/mod.rs @@ -1544,6 +1544,14 @@ pub fn build_router(state: Arc, ui_dir: Option<&Path>) -> Router { axum::routing::get(settings::get_brightness).put(settings::set_brightness), ) // ── Screen Capture ─────────────────────────────────────────── + .route( + "/input/authorize", + axum::routing::post(capture::authorize_input_monitoring), + ) + .route( + "/capture/authorize", + axum::routing::post(capture::authorize_screen_recording), + ) .route( "/capture/source/pick", axum::routing::post(capture::pick_capture_source), diff --git a/crates/hypercolor-ui/src/api/config.rs b/crates/hypercolor-ui/src/api/config.rs index ec9c0697d..8324944fa 100644 --- a/crates/hypercolor-ui/src/api/config.rs +++ b/crates/hypercolor-ui/src/api/config.rs @@ -85,6 +85,20 @@ pub async fn pick_capture_source() -> Result<(), String> { .map_err(Into::into) } +/// Explicitly request Input Monitoring from the active macOS owner. +pub async fn authorize_input_monitoring() -> Result<(), String> { + client::post_empty("/api/v1/input/authorize") + .await + .map_err(Into::into) +} + +/// Explicitly request Screen Recording from the active macOS owner. +pub async fn authorize_screen_recording() -> Result<(), String> { + client::post_empty("/api/v1/capture/authorize") + .await + .map_err(Into::into) +} + fn applies_live(key: &str) -> bool { key == "audio" || key.starts_with("audio.") From c72ca9372ccad60cc612af8ad08510e881a4dbf3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:48:19 -0700 Subject: [PATCH 044/144] feat(macos): add opaque capture surface handoff Expose lifetime-bound native handles without leaking Objective-C types from capture vocabulary. The retained pixel buffer remains alive throughout the interop closure, preserving the IOSurface ownership boundary. Co-Authored-By: Nova (OpenAI Codex) --- .../src/diagnostics.rs | 3 +- crates/hypercolor-macos-capture/src/frame.rs | 60 +++++++++++++++++++ crates/hypercolor-macos-capture/src/lib.rs | 2 + 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 379214b0e..6be8ea17e 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -43,7 +43,8 @@ impl MacosFrameDropReason { | MacosCaptureError::UnsupportedColorAttachment(_) => Self::ColorMetadata, MacosCaptureError::MissingFramePayload | MacosCaptureError::InvalidSurface - | MacosCaptureError::MissingIoSurface => Self::Surface, + | MacosCaptureError::MissingIoSurface + | MacosCaptureError::NativeSurfaceUnavailable => Self::Surface, MacosCaptureError::InvalidCadence(_) | MacosCaptureError::NotMainThread | MacosCaptureError::ScreenCapturePermissionRequired diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index ac74ddaf4..19137e811 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -220,6 +220,38 @@ pub struct MacosCaptureSurface { owner: Arc, } +/// Borrowed native handles for handing a retained capture surface to audited +/// macOS interop code without exposing Objective-C framework types. +#[cfg(target_os = "macos")] +pub struct MacosNativeSurfaceLease<'a> { + iosurface: std::ptr::NonNull, + pixel_buffer: std::ptr::NonNull, + _owner: std::marker::PhantomData<&'a MacosCaptureSurface>, +} + +#[cfg(target_os = "macos")] +impl MacosNativeSurfaceLease<'_> { + /// Returns the borrowed native IOSurface pointer. + /// + /// The pointer is valid only during the closure passed to + /// [`MacosCaptureSurface::with_native_surface`]. Dereferencing or retaining + /// it requires the platform framework's ownership contract. + #[must_use] + pub const fn iosurface_ptr(&self) -> std::ptr::NonNull { + self.iosurface + } + + /// Returns the borrowed native Core Video pixel-buffer pointer. + /// + /// The pointer is valid only during the closure passed to + /// [`MacosCaptureSurface::with_native_surface`]. Dereferencing or retaining + /// it requires the platform framework's ownership contract. + #[must_use] + pub const fn pixel_buffer_ptr(&self) -> std::ptr::NonNull { + self.pixel_buffer + } +} + impl MacosCaptureSurface { #[cfg(feature = "capture-fixtures")] pub fn new_fixture( @@ -289,6 +321,32 @@ impl MacosCaptureSurface { Arc::strong_count(&self.owner) } + /// Hands borrowed native surface handles to audited macOS interop code. + /// + /// The retained pixel buffer owned by this surface remains alive for the + /// entire operation. Fixture surfaces do not have native handles. + #[cfg(target_os = "macos")] + pub fn with_native_surface( + &self, + operation: impl FnOnce(MacosNativeSurfaceLease<'_>) -> R, + ) -> Result { + match &*self.owner { + MacosRetainedPixelBuffer::Native { pixel_buffer } => { + let iosurface = CVPixelBufferGetIOSurface(Some(pixel_buffer)) + .ok_or(MacosCaptureError::MissingIoSurface)?; + Ok(operation(MacosNativeSurfaceLease { + iosurface: std::ptr::NonNull::from(&*iosurface).cast(), + pixel_buffer: std::ptr::NonNull::from(&**pixel_buffer).cast(), + _owner: std::marker::PhantomData, + })) + } + #[cfg(feature = "capture-fixtures")] + MacosRetainedPixelBuffer::Fixture { .. } => { + Err(MacosCaptureError::NativeSurfaceUnavailable) + } + } + } + #[cfg(feature = "capture-fixtures")] pub fn fixture_id(&self) -> Option { match &*self.owner { @@ -801,6 +859,8 @@ pub enum MacosCaptureError { InvalidSurface, #[error("complete frame has no IOSurface-backed pixel buffer")] MissingIoSurface, + #[error("capture surface has no native pixel buffer")] + NativeSurfaceUnavailable, #[error("ScreenCaptureKit filter retention failed")] RetainNativeFilterFailed, #[error("display {0} has no canonical Core Graphics UUID")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index f397bc0ee..1dbe2c400 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -18,6 +18,8 @@ pub use native::MacosScreenCaptureSession; pub use clock::{MacosDisplayClock, MacosDisplayClockError}; pub use diagnostics::{MacosCaptureCallbackDiagnostics, MacosFrameDropReason}; +#[cfg(target_os = "macos")] +pub use frame::MacosNativeSurfaceLease; pub use frame::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, MacosCapturePlane, MacosCaptureSurface, From e97b6020518993374ff2e7f9160bc1245a29a3e4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 19:58:05 -0700 Subject: [PATCH 045/144] fix(macos): select IOSurface storage by GPU family Use shared Metal storage on Apple-family devices and managed storage on other devices. Bind caches to physical texture identity and validate the created texture's mode, IOSurface, plane, and registry identity. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-macos-gpu-interop/src/macos.rs | 193 +++++++++++++++++- .../hypercolor-macos-gpu-interop/src/stubs.rs | 25 ++- .../tests/iosurface_import_tests.rs | 11 + 3 files changed, 219 insertions(+), 10 deletions(-) diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index 734e751b8..69c4cc6c8 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -13,8 +13,8 @@ use objc2_io_surface::{ kIOSurfaceHeight, kIOSurfacePixelFormat, kIOSurfaceWidth, }; use objc2_metal::{ - MTLDevice, MTLPixelFormat, MTLStorageMode, MTLTextureDescriptor, MTLTextureType, - MTLTextureUsage, + MTLDevice, MTLGPUFamily, MTLPixelFormat, MTLResource, MTLStorageMode, MTLTexture, + MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, }; use thiserror::Error; @@ -141,10 +141,78 @@ pub enum MacosGpuInteropError { /// Metal could not create a texture from the IOSurface. #[error("Metal failed to create texture from IOSurface")] MetalTextureCreateFailed, + + /// The import used another physical Metal device. + #[error("Metal registry identity mismatch: expected {expected}, got {actual}")] + MetalRegistryIdMismatch { + /// Registry identity captured when the importer was created. + expected: u64, + /// Registry identity observed during import. + actual: u64, + }, + + /// Metal created a texture with another storage mode. + #[error("Metal texture storage mode mismatch: expected {expected:?}, got {actual:?}")] + MetalStorageModeMismatch { + /// Family-selected storage mode. + expected: MacosMetalStorageMode, + /// Created texture storage mode. + actual: MacosMetalStorageMode, + }, + + /// Metal returned a texture that names another IOSurface. + #[error("Metal texture IOSurface mismatch: expected {expected}, got {actual}")] + MetalIosurfaceIdentityMismatch { + /// Source IOSurface identity. + expected: u32, + /// Created texture IOSurface identity. + actual: u32, + }, + + /// Metal returned a texture that names another IOSurface plane. + #[error("Metal texture IOSurface plane mismatch: expected {expected}, got {actual}")] + MetalIosurfacePlaneMismatch { + /// Requested IOSurface plane. + expected: usize, + /// Created texture IOSurface plane. + actual: usize, + }, + + /// Metal reported a storage mode outside the supported import contract. + #[error("unsupported Metal texture storage mode {0}")] + UnsupportedMetalStorageMode(usize), +} + +/// Family-selected Metal storage mode for imported IOSurfaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosMetalStorageMode { + /// Coherent shared storage on Apple-family GPUs. + Shared, + /// Managed storage required by non-Apple-family GPUs. + Managed, +} + +impl MacosMetalStorageMode { + const fn native(self) -> MTLStorageMode { + match self { + Self::Shared => MTLStorageMode::Shared, + Self::Managed => MTLStorageMode::Managed, + } + } + + fn from_native(mode: MTLStorageMode) -> Result { + if mode == MTLStorageMode::Shared { + Ok(Self::Shared) + } else if mode == MTLStorageMode::Managed { + Ok(Self::Managed) + } else { + Err(MacosGpuInteropError::UnsupportedMetalStorageMode(mode.0)) + } + } } /// Pixel format shared by the IOSurface and imported wgpu texture. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum ImportedFrameFormat { /// 8-bit normalized BGRA. @@ -233,6 +301,18 @@ struct CachedIosurfaceWrap { view: Arc, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct IosurfaceWrapKey { + surface_id: u32, + plane: usize, + width: u32, + height: u32, + bytes_per_row: usize, + format: ImportedFrameFormat, + storage_mode: MacosMetalStorageMode, + metal_registry_id: u64, +} + /// Reusable importer for wrapping IOSurfaces as wgpu textures. /// /// Wraps are cached per IOSurface identity, so re-importing a ring slot on @@ -240,7 +320,9 @@ struct CachedIosurfaceWrap { /// recreating it. pub struct MacosIosurfaceImporter { descriptor: MacosIosurfaceImportDescriptor, - wraps: HashMap, + storage_mode: MacosMetalStorageMode, + metal_registry_id: u64, + wraps: HashMap, } impl MacosIosurfaceImporter { @@ -251,9 +333,11 @@ impl MacosIosurfaceImporter { descriptor.height, descriptor.format, )?; - require_metal_device(device)?; + let (metal_registry_id, storage_mode) = metal_device_import_contract(device)?; Ok(Self { descriptor, + storage_mode, + metal_registry_id, wraps: HashMap::new(), }) } @@ -264,6 +348,18 @@ impl MacosIosurfaceImporter { self.descriptor } + /// Metal registry identity this importer is bound to. + #[must_use] + pub const fn metal_registry_id(&self) -> u64 { + self.metal_registry_id + } + + /// Family-selected storage mode used for IOSurface textures. + #[must_use] + pub const fn storage_mode(&self) -> MacosMetalStorageMode { + self.storage_mode + } + /// Number of IOSurface wraps currently cached. #[must_use] pub fn cached_wrap_count(&self) -> usize { @@ -282,10 +378,28 @@ impl MacosIosurfaceImporter { content_generation: u64, ) -> Result { validate_iosurface_shape(self.descriptor, iosurface)?; + validate_iosurface_format(self.descriptor, iosurface)?; + let (actual_registry_id, _) = metal_device_import_contract(device)?; + if actual_registry_id != self.metal_registry_id { + return Err(MacosGpuInteropError::MetalRegistryIdMismatch { + expected: self.metal_registry_id, + actual: actual_registry_id, + }); + } let total_start = Instant::now(); let surface_id = iosurface.id(); - if let Some(cached) = self.wraps.get(&surface_id) { + let cache_key = IosurfaceWrapKey { + surface_id, + plane: 0, + width: self.descriptor.width, + height: self.descriptor.height, + bytes_per_row: iosurface.bytes_per_row(), + format: self.descriptor.format, + storage_mode: self.storage_mode, + metal_registry_id: self.metal_registry_id, + }; + if let Some(cached) = self.wraps.get(&cache_key) { return Ok(ImportedEffectFrame { width: self.descriptor.width, height: self.descriptor.height, @@ -303,12 +417,13 @@ impl MacosIosurfaceImporter { let wrap_start = Instant::now(); let metal_texture = { let hal_device = require_metal_device(device)?; - let descriptor = metal_texture_descriptor(self.descriptor); + let descriptor = metal_texture_descriptor(self.descriptor, self.storage_mode); hal_device .raw_device() .newTextureWithDescriptor_iosurface_plane(&descriptor, iosurface, 0) .ok_or(MacosGpuInteropError::MetalTextureCreateFailed)? }; + validate_metal_texture(&metal_texture, surface_id, 0, self.storage_mode)?; let wrap_us = elapsed_micros(wrap_start); let wgpu_desc = wgpu_texture_descriptor(self.descriptor); @@ -343,7 +458,7 @@ impl MacosIosurfaceImporter { self.wraps.clear(); } self.wraps.insert( - surface_id, + cache_key, CachedIosurfaceWrap { texture: Arc::clone(&texture), view: Arc::clone(&view), @@ -471,6 +586,7 @@ pub(crate) fn create_iosurface( fn metal_texture_descriptor( descriptor: MacosIosurfaceImportDescriptor, + storage_mode: MacosMetalStorageMode, ) -> objc2::rc::Retained { // SAFETY: descriptor dimensions are validated by // MacosIosurfaceImportDescriptor::new. @@ -484,7 +600,7 @@ fn metal_texture_descriptor( }; texture_descriptor.setTextureType(MTLTextureType::Type2D); texture_descriptor.setUsage(MTLTextureUsage::ShaderRead | MTLTextureUsage::RenderTarget); - texture_descriptor.setStorageMode(MTLStorageMode::Shared); + texture_descriptor.setStorageMode(storage_mode.native()); texture_descriptor } @@ -527,6 +643,65 @@ fn validate_iosurface_shape( } } +fn validate_iosurface_format( + descriptor: MacosIosurfaceImportDescriptor, + iosurface: &IOSurfaceRef, +) -> Result<()> { + let expected = match descriptor.format { + ImportedFrameFormat::Bgra8Unorm => PIXEL_FORMAT_BGRA as u32, + }; + let actual = iosurface.pixel_format(); + if actual == expected { + Ok(()) + } else { + Err(MacosGpuInteropError::IosurfacePixelFormatMismatch { expected, actual }) + } +} + +fn validate_metal_texture( + texture: &objc2::runtime::ProtocolObject, + expected_surface_id: u32, + expected_plane: usize, + expected_storage_mode: MacosMetalStorageMode, +) -> Result<()> { + let actual_storage_mode = MacosMetalStorageMode::from_native(texture.storageMode())?; + if actual_storage_mode != expected_storage_mode { + return Err(MacosGpuInteropError::MetalStorageModeMismatch { + expected: expected_storage_mode, + actual: actual_storage_mode, + }); + } + let actual_surface_id = texture + .iosurface() + .ok_or(MacosGpuInteropError::MetalTextureCreateFailed)? + .id(); + if actual_surface_id != expected_surface_id { + return Err(MacosGpuInteropError::MetalIosurfaceIdentityMismatch { + expected: expected_surface_id, + actual: actual_surface_id, + }); + } + let actual_plane = texture.iosurfacePlane(); + if actual_plane != expected_plane { + return Err(MacosGpuInteropError::MetalIosurfacePlaneMismatch { + expected: expected_plane, + actual: actual_plane, + }); + } + Ok(()) +} + +fn metal_device_import_contract(device: &wgpu::Device) -> Result<(u64, MacosMetalStorageMode)> { + let hal_device = require_metal_device(device)?; + let raw_device = hal_device.raw_device(); + let storage_mode = if raw_device.supportsFamily(MTLGPUFamily::Apple1) { + MacosMetalStorageMode::Shared + } else { + MacosMetalStorageMode::Managed + }; + Ok((raw_device.registryID(), storage_mode)) +} + fn require_metal_device( device: &wgpu::Device, ) -> Result + '_> { diff --git a/crates/hypercolor-macos-gpu-interop/src/stubs.rs b/crates/hypercolor-macos-gpu-interop/src/stubs.rs index 1d2d7fd84..863952edc 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -34,8 +34,17 @@ pub enum MacosGpuInteropError { }, } +/// Family-selected Metal storage mode for imported IOSurfaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosMetalStorageMode { + /// Coherent shared storage on Apple-family GPUs. + Shared, + /// Managed storage required by non-Apple-family GPUs. + Managed, +} + /// Pixel format shared by the IOSurface and imported wgpu texture. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum ImportedFrameFormat { /// 8-bit normalized BGRA. @@ -115,6 +124,8 @@ pub struct ImportedFrameTimings { /// Reusable importer for wrapping IOSurfaces as wgpu textures. pub struct MacosIosurfaceImporter { descriptor: MacosIosurfaceImportDescriptor, + storage_mode: MacosMetalStorageMode, + metal_registry_id: u64, } impl MacosIosurfaceImporter { @@ -133,4 +144,16 @@ impl MacosIosurfaceImporter { pub const fn descriptor(&self) -> MacosIosurfaceImportDescriptor { self.descriptor } + + /// Metal registry identity this importer is bound to. + #[must_use] + pub const fn metal_registry_id(&self) -> u64 { + self.metal_registry_id + } + + /// Family-selected storage mode used for IOSurface textures. + #[must_use] + pub const fn storage_mode(&self) -> MacosMetalStorageMode { + self.storage_mode + } } diff --git a/crates/hypercolor-macos-gpu-interop/tests/iosurface_import_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/iosurface_import_tests.rs index 3c2ff8554..1ee23906a 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/iosurface_import_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/iosurface_import_tests.rs @@ -23,6 +23,17 @@ fn imports_synthetic_iosurface_into_wgpu_texture() -> Result<(), String> { let mut importer = MacosIosurfaceImporter::new(&wgpu.device, descriptor).map_err(|error| error.to_string())?; + assert_ne!(importer.metal_registry_id(), 0); + #[cfg(target_arch = "aarch64")] + assert_eq!( + importer.storage_mode(), + hypercolor_macos_gpu_interop::MacosMetalStorageMode::Shared + ); + #[cfg(target_arch = "x86_64")] + assert_eq!( + importer.storage_mode(), + hypercolor_macos_gpu_interop::MacosMetalStorageMode::Managed + ); let frame = importer .import_iosurface_for_test(&wgpu.device, &iosurface) .map_err(|error| error.to_string())?; From 4f6e42f4f2261c88e4160cc2ebe488f11a33adb7 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:08:10 -0700 Subject: [PATCH 046/144] feat(macos): add ScreenCaptureKit Metal bridge Add the independent screen-capture interop feature and retain Core Video owners through imported wgpu frames. Fence wrapper reuse with full physical storage identity and prove zero-copy BGRA parity with a real IOSurface. Co-Authored-By: Nova (OpenAI Codex) --- Cargo.lock | 1 + .../src/diagnostics.rs | 2 + crates/hypercolor-macos-capture/src/frame.rs | 141 ++++++++++ .../hypercolor-macos-gpu-interop/Cargo.toml | 8 + .../hypercolor-macos-gpu-interop/src/lib.rs | 6 +- .../hypercolor-macos-gpu-interop/src/macos.rs | 21 +- .../src/screen_capture.rs | 264 ++++++++++++++++++ .../hypercolor-macos-gpu-interop/src/stubs.rs | 2 +- .../tests/screen_capture_bridge_tests.rs | 209 ++++++++++++++ 9 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 crates/hypercolor-macos-gpu-interop/src/screen_capture.rs create mode 100644 crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e4fccfdd1..b67723330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5343,6 +5343,7 @@ dependencies = [ "euclid", "gleam", "glow", + "hypercolor-macos-capture", "image", "libc", "objc2 0.6.4", diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 6be8ea17e..6c14378e8 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -66,6 +66,8 @@ impl MacosFrameDropReason { | MacosCaptureError::CpuPlaneLayoutMismatch | MacosCaptureError::PixelBufferLockFailed(_) | MacosCaptureError::PixelBufferUnlockFailed(_) + | MacosCaptureError::FixturePixelLength { .. } + | MacosCaptureError::PixelBufferFixtureCreateFailed(_) | MacosCaptureError::MissingCpuPlaneAddress(_) | MacosCaptureError::UnsupportedCpuPixelFormat(_) | MacosCaptureError::UnsupportedCpuTransferFunction(_) diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 19137e811..cdc5f53f7 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -3,12 +3,19 @@ use std::sync::Arc; #[cfg(target_os = "macos")] use objc2_core_foundation::CFRetained; +#[cfg(all(target_os = "macos", feature = "capture-fixtures"))] +use objc2_core_foundation::{CFDictionary, CFString}; #[cfg(target_os = "macos")] use objc2_core_video::{ CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetIOSurface, CVPixelBufferGetPlaneCount, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVReturnSuccess, }; +#[cfg(all(target_os = "macos", feature = "capture-fixtures"))] +use objc2_core_video::{ + CVPixelBufferCreate, CVPixelBufferGetBytesPerRow, CVPixelBufferGetDataSize, + kCVPixelBufferIOSurfacePropertiesKey, +}; use thiserror::Error; use crate::geometry::{ @@ -253,6 +260,86 @@ impl MacosNativeSurfaceLease<'_> { } impl MacosCaptureSurface { + /// Creates an IOSurface-backed packed BGRA fixture and its exact plane. + #[cfg(all(target_os = "macos", feature = "capture-fixtures"))] + pub fn new_native_bgra_fixture( + extent: MacosPixelExtent, + pixels: &[u8], + ) -> Result<(Self, MacosCapturePlane), MacosCaptureError> { + let packed_stride = usize::try_from(extent.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let expected_len = packed_stride + .checked_mul(extent.height as usize) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if pixels.len() != expected_len { + return Err(MacosCaptureError::FixturePixelLength { + expected: expected_len, + actual: pixels.len(), + }); + } + + let empty = CFDictionary::::from_slices(&[], &[]); + // SAFETY: this is a framework-provided constant CFString reference. + let iosurface_key = unsafe { kCVPixelBufferIOSurfacePropertiesKey }; + let attributes = CFDictionary::::from_slices( + &[iosurface_key], + &[empty.as_opaque()], + ); + let mut raw_pixel_buffer = std::ptr::null_mut(); + // SAFETY: the output pointer is valid, the attribute dictionary owns + // valid Core Foundation types, and the dimensions were validated. + let code = unsafe { + CVPixelBufferCreate( + None, + extent.width as usize, + extent.height as usize, + BGRA8, + Some(attributes.as_opaque()), + std::ptr::NonNull::from(&mut raw_pixel_buffer), + ) + }; + if code != kCVReturnSuccess { + return Err(MacosCaptureError::PixelBufferFixtureCreateFailed(code)); + } + let raw_pixel_buffer = std::ptr::NonNull::new(raw_pixel_buffer) + .ok_or(MacosCaptureError::PixelBufferFixtureCreateFailed(code))?; + // SAFETY: a successful create call returned ownership at +1. + let pixel_buffer = unsafe { CFRetained::from_raw(raw_pixel_buffer) }; + + let lock = PixelBufferWriteLock::acquire(&pixel_buffer)?; + let bytes_per_row = CVPixelBufferGetBytesPerRow(&pixel_buffer); + let base_address = CVPixelBufferGetBaseAddress(&pixel_buffer).cast::(); + if base_address.is_null() || bytes_per_row < packed_stride { + return Err(MacosCaptureError::MissingCpuPlaneAddress(0)); + } + for (row_index, source) in pixels.chunks_exact(packed_stride).enumerate() { + // SAFETY: the pixel buffer is locked, each destination row has at + // least packed_stride bytes, and source rows have that exact size. + unsafe { + std::ptr::copy_nonoverlapping( + source.as_ptr(), + base_address.add(row_index * bytes_per_row), + packed_stride, + ); + } + } + lock.unlock()?; + let length_bytes = u64::try_from(CVPixelBufferGetDataSize(&pixel_buffer)) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let surface = Self::from_pixel_buffer(pixel_buffer)?; + Ok(( + surface, + MacosCapturePlane { + index: 0, + extent, + bytes_per_row, + length_bytes, + }, + )) + } + #[cfg(feature = "capture-fixtures")] pub fn new_fixture( iosurface_id: u32, @@ -438,6 +525,56 @@ struct PixelBufferReadLock<'a> { locked: bool, } +#[cfg(all(target_os = "macos", feature = "capture-fixtures"))] +struct PixelBufferWriteLock<'a> { + pixel_buffer: &'a CVPixelBuffer, + locked: bool, +} + +#[cfg(all(target_os = "macos", feature = "capture-fixtures"))] +impl<'a> PixelBufferWriteLock<'a> { + fn acquire(pixel_buffer: &'a CVPixelBuffer) -> Result { + // SAFETY: the retained fixture pixel buffer remains live through this + // guard, and the empty flags are used symmetrically on unlock. + let code = + unsafe { CVPixelBufferLockBaseAddress(pixel_buffer, CVPixelBufferLockFlags::empty()) }; + if code != kCVReturnSuccess { + return Err(MacosCaptureError::PixelBufferLockFailed(code)); + } + Ok(Self { + pixel_buffer, + locked: true, + }) + } + + fn unlock(mut self) -> Result<(), MacosCaptureError> { + // SAFETY: this guard owns the successful matching write lock and marks + // it released before Drop can run. + let code = unsafe { + CVPixelBufferUnlockBaseAddress(self.pixel_buffer, CVPixelBufferLockFlags::empty()) + }; + self.locked = false; + if code == kCVReturnSuccess { + Ok(()) + } else { + Err(MacosCaptureError::PixelBufferUnlockFailed(code)) + } + } +} + +#[cfg(all(target_os = "macos", feature = "capture-fixtures"))] +impl Drop for PixelBufferWriteLock<'_> { + fn drop(&mut self) { + if self.locked { + // SAFETY: Drop runs only while the successful write lock is still + // owned, including unwinding from fixture population. + let _ = unsafe { + CVPixelBufferUnlockBaseAddress(self.pixel_buffer, CVPixelBufferLockFlags::empty()) + }; + } + } +} + #[cfg(target_os = "macos")] impl<'a> PixelBufferReadLock<'a> { fn acquire(pixel_buffer: &'a CVPixelBuffer) -> Result { @@ -861,6 +998,10 @@ pub enum MacosCaptureError { MissingIoSurface, #[error("capture surface has no native pixel buffer")] NativeSurfaceUnavailable, + #[error("native BGRA fixture expects {expected} bytes, got {actual}")] + FixturePixelLength { expected: usize, actual: usize }, + #[error("Core Video fixture pixel-buffer creation failed with code {0}")] + PixelBufferFixtureCreateFailed(i32), #[error("ScreenCaptureKit filter retention failed")] RetainNativeFilterFailed, #[error("display {0} has no canonical Core Graphics UUID")] diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index 9e922a77e..04cd32c88 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -10,6 +10,7 @@ description = "macOS IOSurface/Metal texture import boundary for Hypercolor" [features] default = [] +screen-capture = ["dep:hypercolor-macos-capture"] servo-context = [ "dep:cgl", "dep:dpi", @@ -24,6 +25,7 @@ servo-context = [ ] [dependencies] +hypercolor-macos-capture = { workspace = true, optional = true } thiserror = { workspace = true } wgpu = { workspace = true } @@ -46,6 +48,7 @@ webrender_api = { workspace = true, optional = true } wgpu-hal = { workspace = true, features = ["metal"] } [dev-dependencies] +hypercolor-macos-capture = { workspace = true, features = ["capture-fixtures"] } pollster = { workspace = true } [[test]] @@ -53,6 +56,11 @@ name = "servo_context_tests" path = "tests/servo_context_tests.rs" required-features = ["servo-context"] +[[test]] +name = "screen_capture_bridge_tests" +path = "tests/screen_capture_bridge_tests.rs" +required-features = ["screen-capture"] + [lints.rust] unsafe_code = "allow" diff --git a/crates/hypercolor-macos-gpu-interop/src/lib.rs b/crates/hypercolor-macos-gpu-interop/src/lib.rs index 04d1ccb2a..f7a624500 100644 --- a/crates/hypercolor-macos-gpu-interop/src/lib.rs +++ b/crates/hypercolor-macos-gpu-interop/src/lib.rs @@ -1,9 +1,11 @@ #![deny(missing_docs)] -//! macOS GPU interop helpers for Servo effect frames. +//! macOS GPU interop helpers for IOSurface-backed frames. #[cfg(target_os = "macos")] mod macos; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +mod screen_capture; #[cfg(all(target_os = "macos", feature = "servo-context"))] mod servo_context; #[cfg(not(target_os = "macos"))] @@ -11,6 +13,8 @@ mod stubs; #[cfg(target_os = "macos")] pub use macos::*; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +pub use screen_capture::*; #[cfg(all(target_os = "macos", feature = "servo-context"))] pub use servo_context::*; #[cfg(not(target_os = "macos"))] diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index 69c4cc6c8..91251f715 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -236,7 +236,7 @@ impl ImportedFrameFormat { } /// Description of a macOS IOSurface import. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MacosIosurfaceImportDescriptor { /// Frame width in pixels. pub width: u32, @@ -303,6 +303,8 @@ struct CachedIosurfaceWrap { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct IosurfaceWrapKey { + capture_session_generation: u64, + resource_generation: u64, surface_id: u32, plane: usize, width: u32, @@ -376,6 +378,17 @@ impl MacosIosurfaceImporter { device: &wgpu::Device, iosurface: &IOSurfaceRef, content_generation: u64, + ) -> Result { + self.import_iosurface_scoped(device, iosurface, content_generation, 0, 0) + } + + pub(crate) fn import_iosurface_scoped( + &mut self, + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + content_generation: u64, + capture_session_generation: u64, + resource_generation: u64, ) -> Result { validate_iosurface_shape(self.descriptor, iosurface)?; validate_iosurface_format(self.descriptor, iosurface)?; @@ -390,6 +403,8 @@ impl MacosIosurfaceImporter { let total_start = Instant::now(); let surface_id = iosurface.id(); let cache_key = IosurfaceWrapKey { + capture_session_generation, + resource_generation, surface_id, plane: 0, width: self.descriptor.width, @@ -691,7 +706,9 @@ fn validate_metal_texture( Ok(()) } -fn metal_device_import_contract(device: &wgpu::Device) -> Result<(u64, MacosMetalStorageMode)> { +pub(crate) fn metal_device_import_contract( + device: &wgpu::Device, +) -> Result<(u64, MacosMetalStorageMode)> { let hal_device = require_metal_device(device)?; let raw_device = hal_device.raw_device(); let storage_mode = if raw_device.supportsFamily(MTLGPUFamily::Apple1) { diff --git a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs new file mode 100644 index 000000000..7e46755eb --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -0,0 +1,264 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use hypercolor_macos_capture::{MacosCaptureFrame, MacosCapturePixelFormat, MacosPixelExtent}; +use objc2_io_surface::IOSurfaceRef; +use thiserror::Error; + +use crate::macos::{ + ImportedEffectFrame, ImportedFrameFormat, MacosGpuInteropError, MacosIosurfaceImportDescriptor, + MacosIosurfaceImporter, MacosMetalStorageMode, metal_device_import_contract, +}; + +const MAX_CAPTURE_DESCRIPTORS: usize = 8; + +/// Complete physical identity of one imported capture plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosScreenStorageIdentity { + /// Capture stream generation that produced the surface. + pub capture_session_generation: u64, + /// Core resource generation authorizing this import. + pub resource_generation: u64, + /// Process-local IOSurface identity. + pub iosurface_id: u32, + /// IOSurface plane index. + pub plane: u32, + /// Plane extent. + pub extent: MacosPixelExtent, + /// Exact plane stride. + pub bytes_per_row: usize, + /// Capture pixel encoding. + pub pixel_format: MacosCapturePixelFormat, + /// Exact IOSurface allocation size. + pub allocation_bytes: u64, + /// Family-selected Metal storage mode. + pub storage_mode: MacosMetalStorageMode, + /// Physical Metal device registry identity. + pub metal_registry_id: u64, +} + +/// Imported capture frame retaining its Core Video owner and wgpu wrapper. +#[derive(Debug, Clone)] +pub struct ImportedMacosScreenFrame { + storage_identity: MacosScreenStorageIdentity, + content_sequence: u64, + capture: Arc, + imported: ImportedEffectFrame, +} + +impl ImportedMacosScreenFrame { + /// Complete physical storage identity used by the wrapper cache. + #[must_use] + pub const fn storage_identity(&self) -> MacosScreenStorageIdentity { + self.storage_identity + } + + /// Monotonic content identity within the capture session. + #[must_use] + pub const fn content_sequence(&self) -> u64 { + self.content_sequence + } + + /// Retained capture metadata and Core Video owner. + #[must_use] + pub fn capture(&self) -> &Arc { + &self.capture + } + + /// Imported wgpu texture. + #[must_use] + pub fn texture(&self) -> &Arc { + &self.imported.texture + } + + /// Default view over the imported texture. + #[must_use] + pub fn view(&self) -> &Arc { + &self.imported.view + } +} + +/// Errors raised while importing a ScreenCaptureKit frame. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum MacosScreenBridgeError { + /// The frame does not satisfy the packed BGRA import contract. + #[error("invalid macOS capture frame: {0}")] + InvalidFrame(&'static str), + /// The capture surface could not provide native handles. + #[error("macOS capture surface handoff failed: {0}")] + SurfaceHandoff(String), + /// IOSurface or Metal import failed. + #[error(transparent)] + Interop(#[from] MacosGpuInteropError), +} + +/// Core-agnostic ScreenCaptureKit IOSurface to wgpu bridge. +pub struct MacosScreenBridge { + metal_registry_id: u64, + storage_mode: MacosMetalStorageMode, + importers: Mutex>, +} + +impl MacosScreenBridge { + /// Bind a bridge to one Metal-backed wgpu device. + pub fn new(device: &wgpu::Device) -> Result { + let (metal_registry_id, storage_mode) = metal_device_import_contract(device)?; + Ok(Self { + metal_registry_id, + storage_mode, + importers: Mutex::new(HashMap::new()), + }) + } + + /// Physical Metal device registry identity. + #[must_use] + pub const fn metal_registry_id(&self) -> u64 { + self.metal_registry_id + } + + /// Family-selected storage mode. + #[must_use] + pub const fn storage_mode(&self) -> MacosMetalStorageMode { + self.storage_mode + } + + /// Import one retained packed BGRA frame without a full-frame CPU copy. + pub fn import_bgra_frame( + &self, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result { + validate_bgra_frame(&frame, resource_generation)?; + let descriptor = MacosIosurfaceImportDescriptor::new( + frame.storage_extent.width, + frame.storage_extent.height, + ImportedFrameFormat::Bgra8Unorm, + )?; + let plane = frame + .planes + .first() + .ok_or(MacosScreenBridgeError::InvalidFrame("missing packed plane"))?; + let storage_identity = MacosScreenStorageIdentity { + capture_session_generation: frame.epoch, + resource_generation, + iosurface_id: frame.surface.iosurface_id, + plane: plane.index, + extent: plane.extent, + bytes_per_row: plane.bytes_per_row, + pixel_format: frame.pixel_format, + allocation_bytes: frame.surface.allocation_bytes, + storage_mode: self.storage_mode, + metal_registry_id: self.metal_registry_id, + }; + let imported = frame + .surface + .with_native_surface(|lease| { + // SAFETY: the opaque lease was created from this exact + // retained IOSurface and cannot outlive this closure. + let iosurface = unsafe { lease.iosurface_ptr().cast::().as_ref() }; + validate_native_surface(iosurface, storage_identity)?; + let mut importers = self + .importers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !importers.contains_key(&descriptor) { + if importers.len() >= MAX_CAPTURE_DESCRIPTORS { + importers.clear(); + } + importers.insert(descriptor, MacosIosurfaceImporter::new(device, descriptor)?); + } + let importer = + importers + .get_mut(&descriptor) + .ok_or(MacosScreenBridgeError::InvalidFrame( + "capture importer cache insertion failed", + ))?; + Ok::( + importer.import_iosurface_scoped( + device, + iosurface, + frame.sequence, + frame.epoch, + resource_generation, + )?, + ) + }) + .map_err(|error| MacosScreenBridgeError::SurfaceHandoff(error.to_string()))??; + + Ok(ImportedMacosScreenFrame { + storage_identity, + content_sequence: frame.sequence, + capture: frame, + imported, + }) + } + + /// Number of cached physical IOSurface wrappers across live descriptors. + #[must_use] + pub fn cached_wrap_count(&self) -> usize { + self.importers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .fold(0, |total, importer| { + total.saturating_add(importer.cached_wrap_count()) + }) + } +} + +fn validate_bgra_frame( + frame: &MacosCaptureFrame, + resource_generation: u64, +) -> Result<(), MacosScreenBridgeError> { + if frame.epoch == 0 || frame.sequence == 0 || resource_generation == 0 { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture and resource generations must be nonzero", + )); + } + if frame.pixel_format != MacosCapturePixelFormat::Bgra8 { + return Err(MacosScreenBridgeError::InvalidFrame( + "packed BGRA import received another pixel format", + )); + } + let [plane] = &*frame.planes else { + return Err(MacosScreenBridgeError::InvalidFrame( + "packed BGRA import requires exactly one plane", + )); + }; + let minimum_stride = usize::try_from(frame.storage_extent.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or(MacosScreenBridgeError::InvalidFrame( + "packed BGRA stride overflowed", + ))?; + if plane.index != 0 + || plane.extent != frame.storage_extent + || plane.bytes_per_row < minimum_stride + { + return Err(MacosScreenBridgeError::InvalidFrame( + "packed BGRA plane descriptor is inconsistent", + )); + } + Ok(()) +} + +fn validate_native_surface( + iosurface: &IOSurfaceRef, + expected: MacosScreenStorageIdentity, +) -> Result<(), MacosScreenBridgeError> { + let allocation_bytes = u64::try_from(iosurface.alloc_size()) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("IOSurface allocation exceeds u64"))?; + if iosurface.id() != expected.iosurface_id + || iosurface.width() != expected.extent.width as usize + || iosurface.height() != expected.extent.height as usize + || iosurface.bytes_per_row() != expected.bytes_per_row + || allocation_bytes != expected.allocation_bytes + { + return Err(MacosScreenBridgeError::InvalidFrame( + "IOSurface physical descriptor changed after capture validation", + )); + } + Ok(()) +} diff --git a/crates/hypercolor-macos-gpu-interop/src/stubs.rs b/crates/hypercolor-macos-gpu-interop/src/stubs.rs index 863952edc..d138f2afb 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -62,7 +62,7 @@ impl ImportedFrameFormat { } /// Description of a macOS IOSurface import. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MacosIosurfaceImportDescriptor { /// Frame width in pixels. pub width: u32, diff --git a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs new file mode 100644 index 000000000..0bd256d6d --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -0,0 +1,209 @@ +#![cfg(target_os = "macos")] + +use std::sync::{Arc, mpsc}; + +use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, MacosPixelRect, + MacosPointRect, MacosScale, MacosTransferFunction, +}; +use hypercolor_macos_gpu_interop::{MacosMetalStorageMode, MacosScreenBridge}; + +const WIDTH: u32 = 4; +const HEIGHT: u32 = 3; + +#[test] +fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), String> { + let wgpu = WgpuFixture::new()?; + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + assert_ne!(bridge.metal_registry_id(), 0); + #[cfg(target_arch = "aarch64")] + assert_eq!(bridge.storage_mode(), MacosMetalStorageMode::Shared); + #[cfg(target_arch = "x86_64")] + assert_eq!(bridge.storage_mode(), MacosMetalStorageMode::Managed); + assert_eq!(bridge.cached_wrap_count(), 0); + + let frame = Arc::new(capture_frame()?); + let first = bridge + .import_bgra_frame(&wgpu.device, 11, Arc::clone(&frame)) + .map_err(|error| error.to_string())?; + let second = bridge + .import_bgra_frame(&wgpu.device, 11, Arc::clone(&frame)) + .map_err(|error| error.to_string())?; + + assert_eq!(first.content_sequence(), 7); + assert_eq!(first.storage_identity().capture_session_generation, 5); + assert_eq!(first.storage_identity().resource_generation, 11); + assert_eq!( + first.storage_identity().iosurface_id, + frame.surface.iosurface_id + ); + assert_eq!( + first.storage_identity().bytes_per_row, + frame.planes[0].bytes_per_row + ); + assert!(Arc::ptr_eq(first.capture(), &frame)); + assert!(Arc::ptr_eq(first.texture(), second.texture())); + assert!(Arc::ptr_eq(first.view(), second.view())); + assert_eq!(bridge.cached_wrap_count(), 1); + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, first.texture(), WIDTH, HEIGHT,)?, + fixture_pixels() + ); + + let next_resource = bridge + .import_bgra_frame(&wgpu.device, 12, Arc::clone(&frame)) + .map_err(|error| error.to_string())?; + assert!(!Arc::ptr_eq(first.texture(), next_resource.texture())); + assert_eq!(bridge.cached_wrap_count(), 2); + + drop(frame); + assert_eq!(Arc::strong_count(first.capture()), 3); + assert_eq!(first.capture().surface.retained_owner_count(), 1); + Ok(()) +} + +fn capture_frame() -> Result { + let extent = MacosPixelExtent::new(WIDTH, HEIGHT).map_err(|error| error.to_string())?; + let pixels = fixture_pixels(); + let (surface, plane) = MacosCaptureSurface::new_native_bgra_fixture(extent, &pixels) + .map_err(|error| error.to_string())?; + Ok(MacosCaptureFrame { + epoch: 5, + sequence: 7, + display_time: 13, + storage_extent: extent, + planes: Arc::from([plane]), + pixel_format: MacosCapturePixelFormat::Bgra8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0).map_err(|error| error.to_string())?, + content_scale: MacosScale::new(1.0).map_err(|error| error.to_string())?, + content_rect_points: MacosPointRect::new(0.0, 0.0, WIDTH.into(), HEIGHT.into()) + .map_err(|error| error.to_string())?, + content_rect_pixels: MacosPixelRect::new(0, 0, WIDTH, HEIGHT) + .map_err(|error| error.to_string())?, + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([]), + cursor_composed: true, + surface, + }) +} + +struct WgpuFixture { + _instance: wgpu::Instance, + device: wgpu::Device, + queue: wgpu::Queue, +} + +impl WgpuFixture { + fn new() -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + })) + .map_err(|error| format!("could not create wgpu adapter: {error}"))?; + if adapter.get_info().backend != wgpu::Backend::Metal { + return Err(format!( + "requires Metal wgpu backend, got {:?}", + adapter.get_info().backend + )); + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("hypercolor macOS screen bridge fixture"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + })) + .map_err(|error| format!("could not create wgpu device: {error}"))?; + Ok(Self { + _instance: instance, + device, + queue, + }) + } +} + +fn fixture_pixels() -> Vec { + [17, 43, 91, 255].repeat((WIDTH * HEIGHT) as usize) +} + +fn read_texture_pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result, String> { + let unpadded_bytes_per_row = width * 4; + let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer_size = u64::from(padded_bytes_per_row) * u64::from(height); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("hypercolor macOS screen bridge readback"), + size: buffer_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("hypercolor macOS screen bridge readback"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let submission = queue.submit(Some(encoder.finish())); + let slice = buffer.slice(..buffer_size); + let (sender, receiver) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| format!("screen bridge readback poll failed: {error:?}"))?; + receiver + .recv() + .map_err(|error| format!("screen bridge readback callback failed: {error}"))? + .map_err(|error| format!("screen bridge readback mapping failed: {error}"))?; + let mapped = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((unpadded_bytes_per_row * height) as usize); + for row in mapped.chunks_exact(padded_bytes_per_row as usize) { + pixels.extend_from_slice(&row[..unpadded_bytes_per_row as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(pixels) +} From ea06477ce6939ce8bdfb4318238885cc8215b4af Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:15:33 -0700 Subject: [PATCH 047/144] fix(macos): service capture UI on the main thread Run the daemon runtime off the process main thread while Core Foundation services main-queue work. Construct ScreenCaptureKit sessions through that coordinator so live config no longer depends on the Axum worker thread. Co-Authored-By: Nova (OpenAI Codex) --- Cargo.lock | 2 + crates/hypercolor-daemon/Cargo.toml | 2 + crates/hypercolor-daemon/src/main.rs | 46 ++++++++++++++++++- crates/hypercolor-macos-capture/src/native.rs | 9 +++- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b67723330..451584bf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5062,6 +5062,7 @@ dependencies = [ "clap", "cpal", "criterion", + "dispatch2", "fast_image_resize", "gif", "http 1.4.0", @@ -5079,6 +5080,7 @@ dependencies = [ "if-addrs", "image", "mdns-sd", + "objc2-core-foundation", "owo-colors", "pollster", "reqwest 0.12.28", diff --git a/crates/hypercolor-daemon/Cargo.toml b/crates/hypercolor-daemon/Cargo.toml index ec31db642..d1d319b29 100644 --- a/crates/hypercolor-daemon/Cargo.toml +++ b/crates/hypercolor-daemon/Cargo.toml @@ -84,6 +84,8 @@ sd-notify = "0.4" sysinfo = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] +dispatch2 = "0.3.1" +objc2-core-foundation = { workspace = true, features = ["std", "CFRunLoop"] } sysinfo = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/hypercolor-daemon/src/main.rs b/crates/hypercolor-daemon/src/main.rs index d433e7378..822b6ec29 100644 --- a/crates/hypercolor-daemon/src/main.rs +++ b/crates/hypercolor-daemon/src/main.rs @@ -125,13 +125,57 @@ fn main() -> Result<()> { return windows_service::run(args.into_run_options()); } + run_daemon(args.into_run_options()) +} + +#[cfg(not(target_os = "macos"))] +fn run_daemon(options: DaemonRunOptions) -> Result<()> { let runtime = daemon::build_main_runtime()?; runtime.block_on(async move { let shutdown_rx = install_signal_handlers(); - daemon::run(args.into_run_options(), shutdown_rx).await + daemon::run(options, shutdown_rx).await }) } +#[cfg(target_os = "macos")] +fn run_daemon(options: DaemonRunOptions) -> Result<()> { + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let runtime_thread = std::thread::Builder::new() + .name("hypercolor-daemon-runtime".to_owned()) + .spawn(move || { + let _run_loop_stop = MainRunLoopStop; + let result = daemon::build_main_runtime().and_then(|runtime| { + runtime.block_on(async move { + let shutdown_rx = install_signal_handlers(); + daemon::run(options, shutdown_rx).await + }) + }); + let _ = result_tx.send(result); + }) + .context("failed to spawn the macOS daemon runtime thread")?; + + objc2_core_foundation::CFRunLoop::run(); + let result = result_rx.recv(); + runtime_thread + .join() + .map_err(|_| anyhow::anyhow!("macOS daemon runtime thread panicked"))?; + result.context("macOS daemon runtime exited without a result")? +} + +#[cfg(target_os = "macos")] +struct MainRunLoopStop; + +#[cfg(target_os = "macos")] +impl Drop for MainRunLoopStop { + fn drop(&mut self) { + dispatch2::run_on_main(|_mtm| { + if let Some(run_loop) = objc2_core_foundation::CFRunLoop::main() { + run_loop.stop(); + } + }); + } +} + fn daemon_instance_name() -> String { #[cfg(target_os = "macos")] { diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index f5ed0cee7..5f3c65b36 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -793,7 +793,14 @@ impl MacosScreenCaptureSession { selector: MacosCaptureSelector, ) -> Result { request.cadence.timescale()?; - let mtm = MainThreadMarker::new().ok_or(MacosCaptureError::NotMainThread)?; + dispatch2::run_on_main(move |mtm| Self::new_on_main(request, selector, mtm)) + } + + fn new_on_main( + request: MacosStreamRequest, + selector: MacosCaptureSelector, + mtm: MainThreadMarker, + ) -> Result { let authorized = CGPreflightScreenCaptureAccess(); let status = if authorized { MacosProtectedSourceState::NeedsSelection From 9f794cb6f9e7fb6ab9487afa0bf0266c716e16e4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:24:54 -0700 Subject: [PATCH 048/144] feat(capture): add LED tone mapping configuration Expose the calibrated LED white point, reference white, peak luminance, and user exposure as serde-defaulted capture settings. Reject invalid chromaticity, luminance, and exposure values before runtime startup. Carry validated values into the core capture runtime so HDR processing can consume one exact configuration contract. Co-Authored-By: Nova (OpenAI Codex) --- .../hypercolor-core/src/input/screen/mod.rs | 20 ++++ .../hypercolor-daemon/src/startup/services.rs | 5 + .../src/startup/services/tests.rs | 10 ++ crates/hypercolor-types/src/config.rs | 90 +++++++++++++++ crates/hypercolor-types/tests/config_tests.rs | 109 ++++++++++++++++++ 5 files changed, 234 insertions(+) diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 9ba96a0e6..9a5c2c197 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -325,6 +325,21 @@ pub struct CaptureConfig { /// Color tuning applied to zone colors after smoothing. pub tuning: ColorTuning, + /// Target LED white-point x coordinate in CIE xy chromaticity space. + pub target_led_white_x: f32, + + /// Target LED white-point y coordinate in CIE xy chromaticity space. + pub target_led_white_y: f32, + + /// Target LED reference white in nits for HDR tone mapping. + pub target_led_reference_white_nits: f32, + + /// Calibrated target LED peak in nits for HDR tone mapping. + pub target_led_peak_nits: f32, + + /// User exposure adjustment in exposure-value stops. + pub exposure_ev: f32, + /// XDG portal restore token from a previous session, if any. pub restore_token: Option, @@ -345,6 +360,11 @@ impl Default for CaptureConfig { letterbox_threshold: 0.02, letterbox_enabled: false, tuning: ColorTuning::default(), + target_led_white_x: 0.3127, + target_led_white_y: 0.3290, + target_led_reference_white_nits: 203.0, + target_led_peak_nits: 406.0, + exposure_ev: 0.0, restore_token: None, source: "auto".to_owned(), } diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index c20379059..cf7c3bedb 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -1315,6 +1315,11 @@ pub(crate) fn screen_capture_config_from( brightness: capture.brightness, gamma: capture.gamma, }, + target_led_white_x: capture.target_led_white_x, + target_led_white_y: capture.target_led_white_y, + target_led_reference_white_nits: capture.target_led_reference_white_nits, + target_led_peak_nits: capture.target_led_peak_nits, + exposure_ev: capture.exposure_ev, restore_token: capture.restore_token.clone(), source: capture.source.clone(), }) diff --git a/crates/hypercolor-daemon/src/startup/services/tests.rs b/crates/hypercolor-daemon/src/startup/services/tests.rs index edeb017bd..f68aac6a2 100644 --- a/crates/hypercolor-daemon/src/startup/services/tests.rs +++ b/crates/hypercolor-daemon/src/startup/services/tests.rs @@ -226,6 +226,11 @@ fn screen_capture_config_conversion_preserves_validated_values_exactly() { grid_rows: 1, smoothing: 1.0, gamma: 5.0, + target_led_white_x: 0.2, + target_led_white_y: 0.3, + target_led_reference_white_nits: 100.0, + target_led_peak_nits: 1_000.0, + exposure_ev: -2.0, ..hypercolor_types::config::CaptureConfig::default() }; @@ -240,6 +245,11 @@ fn screen_capture_config_conversion_preserves_validated_values_exactly() { assert_eq!(runtime.analysis_memory_bytes, u64::MAX); assert!((runtime.smoothing_alpha - 1.0).abs() < f32::EPSILON); assert!((runtime.tuning.gamma - 5.0).abs() < f32::EPSILON); + assert!((runtime.target_led_white_x - 0.2).abs() < f32::EPSILON); + assert!((runtime.target_led_white_y - 0.3).abs() < f32::EPSILON); + assert!((runtime.target_led_reference_white_nits - 100.0).abs() < f32::EPSILON); + assert!((runtime.target_led_peak_nits - 1_000.0).abs() < f32::EPSILON); + assert!((runtime.exposure_ev - -2.0).abs() < f32::EPSILON); } #[test] diff --git a/crates/hypercolor-types/src/config.rs b/crates/hypercolor-types/src/config.rs index 35ae64e8c..ead9b3938 100644 --- a/crates/hypercolor-types/src/config.rs +++ b/crates/hypercolor-types/src/config.rs @@ -125,6 +125,21 @@ mod defaults { pub fn capture_letterbox_threshold() -> f32 { 0.02 } + pub fn capture_target_led_white_x() -> f32 { + 0.3127 + } + pub fn capture_target_led_white_y() -> f32 { + 0.3290 + } + pub fn capture_target_led_reference_white_nits() -> f32 { + 203.0 + } + pub fn capture_target_led_peak_nits() -> f32 { + 406.0 + } + pub fn capture_exposure_ev() -> f32 { + 0.0 + } pub fn unit_scale() -> f32 { 1.0 } @@ -743,6 +758,26 @@ pub struct CaptureConfig { #[serde(default = "defaults::unit_scale")] pub gamma: f32, + /// Target LED white-point x coordinate in CIE xy chromaticity space. + #[serde(default = "defaults::capture_target_led_white_x")] + pub target_led_white_x: f32, + + /// Target LED white-point y coordinate in CIE xy chromaticity space. + #[serde(default = "defaults::capture_target_led_white_y")] + pub target_led_white_y: f32, + + /// Target LED reference white in nits for HDR tone mapping. + #[serde(default = "defaults::capture_target_led_reference_white_nits")] + pub target_led_reference_white_nits: f32, + + /// Calibrated target LED peak in nits for HDR tone mapping. + #[serde(default = "defaults::capture_target_led_peak_nits")] + pub target_led_peak_nits: f32, + + /// User exposure adjustment in exposure-value stops. + #[serde(default = "defaults::capture_exposure_ev")] + pub exposure_ev: f32, + /// XDG portal restore token so the picked source survives restarts. #[serde(default, skip_serializing_if = "Option::is_none")] pub restore_token: Option, @@ -822,6 +857,26 @@ pub enum CaptureConfigValidationError { /// Rejected value. value: f32, }, + /// The target LED white point lies outside the CIE xy triangle. + #[error( + "capture target LED white point must be finite with x > 0, y > 0, and x + y < 1, got ({x}, {y})" + )] + WhitePointChromaticity { + /// Rejected CIE xy x coordinate. + x: f32, + /// Rejected CIE xy y coordinate. + y: f32, + }, + /// Target peak does not leave any headroom above reference white. + #[error( + "capture.target_led_peak_nits must be greater than target_led_reference_white_nits ({reference}), got {peak}" + )] + PeakNotAboveReference { + /// Configured target reference white in nits. + reference: f32, + /// Rejected target peak in nits. + peak: f32, + }, /// The selected source cannot be represented by the native backend. #[error("capture.source is invalid for {platform}: {reason}")] Source { @@ -867,6 +922,36 @@ impl CaptureConfig { validate_capture_float("saturation", self.saturation, 0.0, 4.0)?; validate_capture_float("brightness", self.brightness, 0.0, 4.0)?; validate_capture_float("gamma", self.gamma, 0.2, 5.0)?; + if !self.target_led_white_x.is_finite() + || !self.target_led_white_y.is_finite() + || self.target_led_white_x <= 0.0 + || self.target_led_white_y <= 0.0 + || self.target_led_white_x + self.target_led_white_y >= 1.0 + { + return Err(CaptureConfigValidationError::WhitePointChromaticity { + x: self.target_led_white_x, + y: self.target_led_white_y, + }); + } + validate_capture_float( + "target_led_reference_white_nits", + self.target_led_reference_white_nits, + 1.0, + 5_000.0, + )?; + validate_capture_float( + "target_led_peak_nits", + self.target_led_peak_nits, + 1.0, + 10_000.0, + )?; + if self.target_led_peak_nits <= self.target_led_reference_white_nits { + return Err(CaptureConfigValidationError::PeakNotAboveReference { + reference: self.target_led_reference_white_nits, + peak: self.target_led_peak_nits, + }); + } + validate_capture_float("exposure_ev", self.exposure_ev, -8.0, 8.0)?; validate_capture_source(platform, &self.source, self.enabled)?; if matches!(platform, CapturePlatform::Unsupported) && self.enabled { return Err(CaptureConfigValidationError::UnsupportedPlatform); @@ -979,6 +1064,11 @@ impl Default for CaptureConfig { saturation: defaults::unit_scale(), brightness: defaults::unit_scale(), gamma: defaults::unit_scale(), + target_led_white_x: defaults::capture_target_led_white_x(), + target_led_white_y: defaults::capture_target_led_white_y(), + target_led_reference_white_nits: defaults::capture_target_led_reference_white_nits(), + target_led_peak_nits: defaults::capture_target_led_peak_nits(), + exposure_ev: defaults::capture_exposure_ev(), restore_token: None, } } diff --git a/crates/hypercolor-types/tests/config_tests.rs b/crates/hypercolor-types/tests/config_tests.rs index daa50bb47..6dfd8d0a2 100644 --- a/crates/hypercolor-types/tests/config_tests.rs +++ b/crates/hypercolor-types/tests/config_tests.rs @@ -113,9 +113,29 @@ fn capture_defaults_match_spec() { assert!((c.saturation - 1.0).abs() < f32::EPSILON); assert!((c.brightness - 1.0).abs() < f32::EPSILON); assert!((c.gamma - 1.0).abs() < f32::EPSILON); + assert!((c.target_led_white_x - 0.3127).abs() < f32::EPSILON); + assert!((c.target_led_white_y - 0.3290).abs() < f32::EPSILON); + assert!((c.target_led_reference_white_nits - 203.0).abs() < f32::EPSILON); + assert!((c.target_led_peak_nits - 406.0).abs() < f32::EPSILON); + assert!(c.exposure_ev.abs() < f32::EPSILON); assert_eq!(c.restore_token, None); } +#[test] +fn capture_tone_mapping_fields_default_when_omitted() { + let parsed: CaptureConfig = toml::from_str("").expect("empty capture config parses"); + let expected = CaptureConfig::default(); + + assert_eq!(parsed.target_led_white_x, expected.target_led_white_x); + assert_eq!(parsed.target_led_white_y, expected.target_led_white_y); + assert_eq!( + parsed.target_led_reference_white_nits, + expected.target_led_reference_white_nits + ); + assert_eq!(parsed.target_led_peak_nits, expected.target_led_peak_nits); + assert_eq!(parsed.exposure_ev, expected.exposure_ev); +} + #[test] fn capture_platform_matches_build_target() { #[cfg(target_os = "windows")] @@ -220,6 +240,95 @@ fn capture_config_rejects_empty_grid_and_invalid_float_values() { )); } +#[test] +fn capture_config_accepts_tone_mapping_boundaries() { + let platform = CapturePlatform::WindowsDesktopDuplication; + let mut config = CaptureConfig { + target_led_white_x: 0.000_1, + target_led_white_y: 0.999_8, + target_led_reference_white_nits: 1.0, + target_led_peak_nits: 10_000.0, + exposure_ev: -8.0, + ..CaptureConfig::default() + }; + config + .validate_for_platform(platform) + .expect("minimum tone-mapping boundaries should validate"); + + config.target_led_reference_white_nits = 5_000.0; + config.exposure_ev = 8.0; + config + .validate_for_platform(platform) + .expect("maximum tone-mapping boundaries should validate"); +} + +#[test] +fn capture_config_rejects_invalid_target_white_point() { + let platform = CapturePlatform::WindowsDesktopDuplication; + for (x, y) in [ + (f32::NAN, 0.3290), + (0.3127, f32::INFINITY), + (0.0, 0.3290), + (0.3127, 0.0), + (0.4, 0.6), + ] { + let config = CaptureConfig { + target_led_white_x: x, + target_led_white_y: y, + ..CaptureConfig::default() + }; + assert!(matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::WhitePointChromaticity { .. }) + )); + } +} + +#[test] +fn capture_config_rejects_invalid_target_luminance_and_exposure() { + let platform = CapturePlatform::WindowsDesktopDuplication; + let mut config = CaptureConfig { + target_led_reference_white_nits: 0.99, + ..CaptureConfig::default() + }; + assert!(matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::FloatRange { + field: "target_led_reference_white_nits", + .. + }) + )); + + config.target_led_reference_white_nits = 203.0; + config.target_led_peak_nits = 10_000.1; + assert!(matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::FloatRange { + field: "target_led_peak_nits", + .. + }) + )); + + config.target_led_peak_nits = 203.0; + assert!(matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::PeakNotAboveReference { + reference: 203.0, + peak: 203.0 + }) + )); + + config.target_led_peak_nits = 406.0; + config.exposure_ev = 8.01; + assert!(matches!( + config.validate_for_platform(platform), + Err(CaptureConfigValidationError::FloatRange { + field: "exposure_ev", + .. + }) + )); +} + #[test] fn capture_config_accepts_optional_nonzero_publication_memory_budget() { let platform = CapturePlatform::WindowsDesktopDuplication; From 6e5ee432cce395fbb344929a119ec30d111a6355 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:29:46 -0700 Subject: [PATCH 049/144] feat(capture): preserve smoothing during curve transitions Thread an explicit scene-cut suppression flag through both transactional and legacy smoothing seams. Existing Windows, Linux, and non-transition callers keep scene-cut bypass enabled through explicit false values. This lets the macOS HDR handover smooth through its complete curve blend without weakening history resets or public smoother behavior. Co-Authored-By: Nova (OpenAI Codex) --- .../src/input/screen/materialize.rs | 2 + .../hypercolor-core/src/input/screen/mod.rs | 3 ++ .../src/input/screen/smooth.rs | 11 +++-- .../screen_cpu_branch_processing_tests.rs | 45 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/materialize.rs b/crates/hypercolor-core/src/input/screen/materialize.rs index 6ad897a64..8f31844c3 100644 --- a/crates/hypercolor-core/src/input/screen/materialize.rs +++ b/crates/hypercolor-core/src/input/screen/materialize.rs @@ -601,6 +601,7 @@ impl PreparedCpuSurfaceMaterializer { elapsed, self.committed_bars .is_some_and(|committed| committed != bars), + false, )?; for (pixel, color) in output .chunks_exact_mut(BYTES_PER_PIXEL) @@ -1054,6 +1055,7 @@ impl PreparedCpuZoneMaterializer { self.transfer, elapsed, reset_history, + false, )?; self.apply_tuning(&mut output[..color_count]); output[color_count..].fill([0, 0, 0]); diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 9a5c2c197..afcf041f0 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -1011,6 +1011,7 @@ impl ScreenCaptureInput { &mut self.policy_pixels, elapsed, reset_smoother, + false, &self.surface_resource_owner, )? else { @@ -1584,6 +1585,7 @@ fn downscale_frame( policy_pixels: &mut Vec<[u8; 3]>, elapsed: Duration, reset_smoother: bool, + suppress_scene_cut_bypass: bool, surface_resource_owner: &Arc, ) -> Result, SurfaceResourceError> { if width == 0 || height == 0 || target_width == 0 || target_height == 0 { @@ -1662,6 +1664,7 @@ fn downscale_frame( target_height, elapsed, reset_smoother, + suppress_scene_cut_bypass, ) { lease.release(); return Ok(None); diff --git a/crates/hypercolor-core/src/input/screen/smooth.rs b/crates/hypercolor-core/src/input/screen/smooth.rs index f74e1a2c8..3bf67bd6f 100644 --- a/crates/hypercolor-core/src/input/screen/smooth.rs +++ b/crates/hypercolor-core/src/input/screen/smooth.rs @@ -123,6 +123,8 @@ impl PreparedTemporalSmoother { /// Stage smoothing for one encoded RGB grid without committing history. /// /// `reset_history` is used when content cropping changes spatial identity. + /// `suppress_scene_cut_bypass` keeps smoothing active while a caller is + /// already blending between color transforms. /// Scene-cut distance is normalized to `0.0..=1.0` per channel in linear /// light. The exponential response derives directly from the configured /// time constant and capture timestamp delta. @@ -139,6 +141,7 @@ impl PreparedTemporalSmoother { transfer: CaptureTransferFunction, elapsed: Duration, reset_history: bool, + suppress_scene_cut_bypass: bool, ) -> Result<(), PreparedTemporalSmoothingError> { if self.staged_shape.is_some() { return Err(PreparedTemporalSmoothingError::StagePending); @@ -179,7 +182,8 @@ impl PreparedTemporalSmoother { let reset = reset_history || self.committed.len() != expected || self.committed_shape != Some(shape) - || scene_cut_detected(scene_cut, transfer, &self.committed, colors); + || !suppress_scene_cut_bypass + && scene_cut_detected(scene_cut, transfer, &self.committed, colors); if reset { self.staged.extend( colors @@ -475,7 +479,7 @@ impl TemporalSmoother { height: u32, elapsed: Duration, ) { - if self.stage_for_elapsed_grid(colors, width, height, elapsed, false) { + if self.stage_for_elapsed_grid(colors, width, height, elapsed, false, false) { self.commit_staged(); } } @@ -487,6 +491,7 @@ impl TemporalSmoother { height: u32, elapsed: Duration, reset_history: bool, + suppress_scene_cut_bypass: bool, ) -> bool { let Some(expected_len) = usize::try_from(width) .ok() @@ -532,7 +537,7 @@ impl TemporalSmoother { let diff = self.frame_difference(colors); // Scene cut detected — snap to new colors immediately. - if diff > self.scene_cut_threshold { + if !suppress_scene_cut_bypass && diff > self.scene_cut_threshold { self.staged.extend(colors.iter().map(|color| { [ srgb_u8_to_linear(color[0]) * 255.0, diff --git a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs index a2f8d243a..2703e04cb 100644 --- a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs @@ -364,6 +364,7 @@ fn smoothed_color( CaptureTransferFunction::Srgb, Duration::ZERO, false, + false, ) .expect("reference baseline stages"); assert!(smoother.commit_staged()); @@ -376,6 +377,7 @@ fn smoothed_color( CaptureTransferFunction::Srgb, elapsed, false, + false, ) .expect("reference response stages"); colors[0] @@ -1369,6 +1371,7 @@ fn prepared_smoothing_is_equivalent_at_30_60_and_120_hz() { CaptureTransferFunction::Srgb, Duration::ZERO, false, + false, ) .expect("initial state stages"); assert!(smoother.commit_staged()); @@ -1383,6 +1386,7 @@ fn prepared_smoothing_is_equivalent_at_30_60_and_120_hz() { CaptureTransferFunction::Srgb, interval, false, + false, ) .expect("response stage succeeds"); assert!(smoother.commit_staged()); @@ -1418,6 +1422,7 @@ fn normalized_scene_cut_resets_independent_of_grid_size() { CaptureTransferFunction::Srgb, Duration::ZERO, false, + false, ) .expect("baseline stages"); assert!(smoother.commit_staged()); @@ -1430,12 +1435,52 @@ fn normalized_scene_cut_resets_independent_of_grid_size() { CaptureTransferFunction::Srgb, Duration::from_millis(16), false, + false, ) .expect("scene cut stages"); assert!(colors.iter().all(|color| *color == [255, 255, 255])); } } +#[test] +fn prepared_smoothing_can_suppress_scene_cut_bypass() { + let policy = ScreenSmoothingPolicy::Exponential { + time_constant: Duration::from_mins(1), + scene_cut: ScreenSceneCutPolicy::MeanAbsoluteDelta { + threshold: scalar(0.01), + }, + }; + let mut smoother = PreparedTemporalSmoother::try_new(policy, 1, 1).expect("smoother prepares"); + let mut colors = [[0, 0, 0]]; + smoother + .stage( + &mut colors, + 1, + 1, + CaptureTransferFunction::Srgb, + Duration::ZERO, + false, + false, + ) + .expect("baseline stages"); + assert!(smoother.commit_staged()); + + colors[0] = [255, 255, 255]; + smoother + .stage( + &mut colors, + 1, + 1, + CaptureTransferFunction::Srgb, + Duration::from_millis(16), + false, + true, + ) + .expect("suppressed scene cut stages"); + + assert!(colors[0][0] < 255); +} + #[test] fn prepared_state_admits_odd_portrait_ultrawide_and_one_pixel_shapes() { for (width, height) in [(1, 1), (7, 5), (127, 3), (3, 127)] { From c09d0f9418cd836b42255c68d7fb97bfaa803631 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:39:44 -0700 Subject: [PATCH 050/144] feat(capture): add LED calibration controls Expose white-point, luminance, and exposure controls in the advanced capture settings surface using the shared typed configuration. Add one atomic calibration reset scope that restores the four calibrated target fields while preserving explicit user exposure. The daemon validates and commits the complete capture candidate transactionally. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-daemon/src/api/config.rs | 131 ++++++++++++++++-- .../src/components/settings_sections.rs | 76 ++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) diff --git a/crates/hypercolor-daemon/src/api/config.rs b/crates/hypercolor-daemon/src/api/config.rs index 34865e7fe..017ad0d4e 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -43,6 +43,14 @@ pub struct ResetConfigRequest { pub live: Option, } +const CAPTURE_CALIBRATION_RESET_KEY: &str = "capture.calibration"; +const CAPTURE_CALIBRATION_FIELDS: [&str; 4] = [ + "capture.target_led_white_x", + "capture.target_led_white_y", + "capture.target_led_reference_white_nits", + "capture.target_led_peak_nits", +]; + /// `GET /api/v1/config` — Show full effective config. pub async fn show_config(State(state): State>) -> Response { ApiResponse::ok(config_snapshot(&state)) @@ -269,15 +277,11 @@ pub async fn reset_config_value( let normalized_key = body.key.as_deref().map(normalize_config_key); if let Some(key) = normalized_key.as_deref() { - let Some(default_value) = get_json_path(&defaults, key) else { + if !reset_json_scope(&mut current, &defaults, key) { return ApiError::not_found(format!( "Unknown config key: {}", body.key.as_deref().unwrap_or(key) )); - }; - - if !set_json_path(&mut current, key, default_value.clone()) { - return ApiError::validation(format!("Invalid config key path: {key}")); } } else { current = defaults; @@ -399,6 +403,24 @@ pub async fn reset_config_value( })) } +fn reset_json_scope( + current: &mut serde_json::Value, + defaults: &serde_json::Value, + key: &str, +) -> bool { + if key == CAPTURE_CALIBRATION_RESET_KEY { + return CAPTURE_CALIBRATION_FIELDS.iter().all(|field| { + get_json_path(defaults, field) + .cloned() + .is_some_and(|value| set_json_path(current, field, value)) + }); + } + + get_json_path(defaults, key) + .cloned() + .is_some_and(|value| set_json_path(current, key, value)) +} + fn config_snapshot(state: &AppState) -> HypercolorConfig { if let Some(manager) = state.config_manager.as_ref() { let current = manager.get(); @@ -1225,9 +1247,10 @@ mod tests { use hypercolor_types::config::InteractionRoutePolicy; use super::{ - CaptureConfigTransactionError, SetConfigRequest, apply_capture_config_transaction, - canvas_dimensions_differ, capture_statuses_match, maybe_apply_input_config_change, - set_config_value, validate_prepared_capture_status, + CAPTURE_CALIBRATION_RESET_KEY, CaptureConfigTransactionError, ResetConfigRequest, + SetConfigRequest, apply_capture_config_transaction, canvas_dimensions_differ, + capture_statuses_match, maybe_apply_input_config_change, reset_config_value, + reset_json_scope, set_config_value, validate_prepared_capture_status, }; use crate::api::AppState; @@ -1450,6 +1473,98 @@ mod tests { assert!(!manager.capture_runtime_matches(&divergent)); } + #[test] + fn calibration_reset_restores_only_calibrated_target_fields() { + let mut config = hypercolor_types::config::HypercolorConfig::default(); + config.capture.target_led_white_x = 0.2; + config.capture.target_led_white_y = 0.3; + config.capture.target_led_reference_white_nits = 100.0; + config.capture.target_led_peak_nits = 1_000.0; + config.capture.exposure_ev = 2.5; + let mut current = serde_json::to_value(config).expect("config serializes"); + let defaults = serde_json::to_value(hypercolor_types::config::HypercolorConfig::default()) + .expect("default config serializes"); + + assert!(reset_json_scope( + &mut current, + &defaults, + CAPTURE_CALIBRATION_RESET_KEY + )); + + let reset: hypercolor_types::config::HypercolorConfig = + serde_json::from_value(current).expect("reset config deserializes"); + assert_eq!( + reset.capture.target_led_white_x, + hypercolor_types::config::CaptureConfig::default().target_led_white_x + ); + assert_eq!( + reset.capture.target_led_white_y, + hypercolor_types::config::CaptureConfig::default().target_led_white_y + ); + assert_eq!( + reset.capture.target_led_reference_white_nits, + hypercolor_types::config::CaptureConfig::default().target_led_reference_white_nits + ); + assert_eq!( + reset.capture.target_led_peak_nits, + hypercolor_types::config::CaptureConfig::default().target_led_peak_nits + ); + assert!((reset.capture.exposure_ev - 2.5).abs() < f32::EPSILON); + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + #[tokio::test] + async fn calibration_reset_endpoint_commits_one_valid_capture_config() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let manager = Arc::new( + ConfigManager::new(tempdir.path().join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| { + config.capture.enabled = false; + config.capture.target_led_white_x = 0.2; + config.capture.target_led_white_y = 0.3; + config.capture.target_led_reference_white_nits = 100.0; + config.capture.target_led_peak_nits = 1_000.0; + config.capture.exposure_ev = 2.5; + }); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + let state = Arc::new(state); + state + .input_manager + .lock() + .await + .set_screen_capacity_plan( + ScreenAdmissionCapacity::new(40_000, 40_000), + ScreenAdmissionCapacity::new(30_000, 40_000), + ScreenAdmissionCapacity::new(20_000, 40_000), + ) + .expect("empty manager should accept test capacity"); + + let response = reset_config_value( + axum::extract::State(Arc::clone(&state)), + axum::Json(ResetConfigRequest { + key: Some(CAPTURE_CALIBRATION_RESET_KEY.to_owned()), + live: Some(true), + }), + ) + .await; + + assert_eq!(response.status(), axum::http::StatusCode::OK); + let capture = &manager.get().capture; + let defaults = hypercolor_types::config::CaptureConfig::default(); + assert_eq!(capture.target_led_white_x, defaults.target_led_white_x); + assert_eq!(capture.target_led_white_y, defaults.target_led_white_y); + assert_eq!( + capture.target_led_reference_white_nits, + defaults.target_led_reference_white_nits + ); + assert_eq!(capture.target_led_peak_nits, defaults.target_led_peak_nits); + assert!((capture.exposure_ev - 2.5).abs() < f32::EPSILON); + assert!(manager.capture_runtime_matches(capture)); + } + #[test] fn screen_runtime_commit_preserves_demand_and_retires_after_swap() { let mut manager = InputManager::new(); diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index b88f63618..d5058b926 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -5,6 +5,7 @@ use std::net::IpAddr; use hypercolor_types::config::{HypercolorConfig, NetworkAccessMode, NetworkClientScope}; use hypercolor_types::session::{OffOutputBehavior, SleepBehavior}; use leptos::prelude::*; +use leptos_icons::Icon; use crate::components::settings_controls::*; use crate::icons::*; @@ -143,6 +144,25 @@ pub fn CaptureSection( let brightness = Signal::derive(move || read_config(config, |cfg| f64::from(cfg.capture.brightness))); let gamma = Signal::derive(move || read_config(config, |cfg| f64::from(cfg.capture.gamma))); + let target_led_white_x = Signal::derive(move || { + read_config(config, |cfg| f64::from(cfg.capture.target_led_white_x)) + }); + let target_led_white_y = Signal::derive(move || { + read_config(config, |cfg| f64::from(cfg.capture.target_led_white_y)) + }); + let target_led_reference_white_nits = Signal::derive(move || { + read_config(config, |cfg| { + f64::from(cfg.capture.target_led_reference_white_nits) + }) + }); + let target_led_peak_nits = Signal::derive(move || { + read_config(config, |cfg| f64::from(cfg.capture.target_led_peak_nits)) + }); + let exposure_ev = + Signal::derive(move || read_config(config, |cfg| f64::from(cfg.capture.exposure_ev))); + let reset_calibration = Callback::new(move |()| { + on_reset.run("capture.calibration".to_owned()); + }); // Monitor picker data. Empty means the platform's backend owns source // selection (the XDG portal on Linux), so the portal button renders @@ -271,6 +291,62 @@ pub fn CaptureSection( on_change=on_change min=0.4 max=2.5 step=0.05 /> +
+
"LED tone mapping"
+
+ "Calibrate HDR white, output headroom, and exposure" +
+
+ + + + + + Date: Tue, 11 Aug 2026 20:41:14 -0700 Subject: [PATCH 051/144] fix(macos): admit the first capture surface ScreenCaptureKit frame sequences start at zero within each capture epoch. Treating zero as invalid caused the Metal bridge to reject the first frame from every native session even though epoch and resource generations were valid. Pin the real IOSurface fixture to sequence zero so import, cache identity, and GPU readback cover the decoder's first-frame contract. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-macos-gpu-interop/src/screen_capture.rs | 2 +- .../tests/screen_capture_bridge_tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs index 7e46755eb..66b0ff6d2 100644 --- a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -212,7 +212,7 @@ fn validate_bgra_frame( frame: &MacosCaptureFrame, resource_generation: u64, ) -> Result<(), MacosScreenBridgeError> { - if frame.epoch == 0 || frame.sequence == 0 || resource_generation == 0 { + if frame.epoch == 0 || resource_generation == 0 { return Err(MacosScreenBridgeError::InvalidFrame( "capture and resource generations must be nonzero", )); diff --git a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs index 0bd256d6d..f332094f3 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -31,7 +31,7 @@ fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), S .import_bgra_frame(&wgpu.device, 11, Arc::clone(&frame)) .map_err(|error| error.to_string())?; - assert_eq!(first.content_sequence(), 7); + assert_eq!(first.content_sequence(), 0); assert_eq!(first.storage_identity().capture_session_generation, 5); assert_eq!(first.storage_identity().resource_generation, 11); assert_eq!( @@ -70,7 +70,7 @@ fn capture_frame() -> Result { .map_err(|error| error.to_string())?; Ok(MacosCaptureFrame { epoch: 5, - sequence: 7, + sequence: 0, display_time: 13, storage_extent: extent, planes: Arc::from([plane]), From 05cc92d7c0bc6056b5f0b6117ffb63e70952dd00 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 20:52:24 -0700 Subject: [PATCH 052/144] refactor(macos): bound ScreenCaptureKit callbacks ScreenCaptureKit callbacks now retain only the pixel buffer and copied frame attachments before replacing one latest native sample. A dedicated worker owns frame validation, generation checks, and decoded publication. Source retirement closes and joins the worker before it awaits the native stop completion. Callback objects stay alive until ScreenCaptureKit confirms shutdown. Fixtures pin supersession, off-callback decode errors, and clean idle teardown. Co-Authored-By: Nova (OpenAI Codex) --- .../src/diagnostics.rs | 12 +- crates/hypercolor-macos-capture/src/frame.rs | 6 + crates/hypercolor-macos-capture/src/lib.rs | 1 + crates/hypercolor-macos-capture/src/native.rs | 240 +++++++++++----- crates/hypercolor-macos-capture/src/worker.rs | 258 ++++++++++++++++++ 5 files changed, 452 insertions(+), 65 deletions(-) create mode 100644 crates/hypercolor-macos-capture/src/worker.rs diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 6c14378e8..cd8cfa0b4 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -51,6 +51,9 @@ impl MacosFrameDropReason { | MacosCaptureError::InvalidSourceSelector(_) | MacosCaptureError::NativeOperation { .. } | MacosCaptureError::RetainNativeFilterFailed + | MacosCaptureError::CaptureWorkerStartFailed(_) + | MacosCaptureError::CaptureWorkerPanicked + | MacosCaptureError::StreamStopCompletionLost | MacosCaptureError::DisplayUuidUnavailable(_) | MacosCaptureError::DisplaySourceUnavailable(_) | MacosCaptureError::MissingShareableContent @@ -103,6 +106,7 @@ pub(crate) struct CallbackCounters { frames_received: AtomicU64, frames_published: AtomicU64, lifecycle_events: AtomicU64, + native_samples_superseded: AtomicU64, dropped: [AtomicU64; MacosFrameDropReason::ALL.len()], } @@ -119,6 +123,11 @@ impl CallbackCounters { self.lifecycle_events.fetch_add(1, Ordering::Relaxed); } + pub(crate) fn record_native_sample_superseded(&self) { + self.native_samples_superseded + .fetch_add(1, Ordering::Relaxed); + } + pub(crate) fn record_drop(&self, error: &MacosCaptureError) { self.dropped[MacosFrameDropReason::from_error(error) as usize] .fetch_add(1, Ordering::Relaxed); @@ -129,7 +138,8 @@ impl CallbackCounters { frames_received: self.frames_received.load(Ordering::Relaxed), frames_published: self.frames_published.load(Ordering::Relaxed), lifecycle_events: self.lifecycle_events.load(Ordering::Relaxed), - superseded_deliveries, + superseded_deliveries: superseded_deliveries + .saturating_add(self.native_samples_superseded.load(Ordering::Relaxed)), dropped: std::array::from_fn(|index| self.dropped[index].load(Ordering::Relaxed)), } } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index cdc5f53f7..dd5b781a1 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -1004,6 +1004,12 @@ pub enum MacosCaptureError { PixelBufferFixtureCreateFailed(i32), #[error("ScreenCaptureKit filter retention failed")] RetainNativeFilterFailed, + #[error("failed to start the macOS capture worker: {0}")] + CaptureWorkerStartFailed(String), + #[error("the macOS capture worker panicked")] + CaptureWorkerPanicked, + #[error("ScreenCaptureKit dropped its stop completion")] + StreamStopCompletionLost, #[error("display {0} has no canonical Core Graphics UUID")] DisplayUuidUnavailable(u32), #[error("configured display source is unavailable: {0}")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 1dbe2c400..479b2f255 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -12,6 +12,7 @@ mod mailbox; #[cfg(target_os = "macos")] mod native; mod session; +mod worker; #[cfg(target_os = "macos")] pub use native::MacosScreenCaptureSession; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 5f3c65b36..929807aaa 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -43,6 +43,7 @@ use objc2_screen_capture_kit::{ }; use crate::diagnostics::CallbackCounters; +use crate::worker::{LatestSampleInput, LatestSampleWorker, SamplePublishOutcome}; use crate::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureError, MacosCapturePixelFormat, @@ -171,9 +172,66 @@ impl SessionShared { } } +#[derive(Debug)] +struct RetainedNativeSample { + attachments: MacosRawFrameAttachments, + pixel_buffer: Option>, + cursor_composed: bool, +} + +// SAFETY: The retained Core Video pixel buffer is reference-counted and the +// decode worker only reads its immutable descriptor metadata. +unsafe impl Send for RetainedNativeSample {} + +fn retain_sample( + sample: &CMSampleBuffer, + cursor_composed: bool, +) -> Result { + // SAFETY: ScreenCaptureKit supplied a live CMSampleBuffer reference for + // the duration of this callback. + if !unsafe { sample.is_valid() } { + return Err(MacosCaptureError::InvalidSampleBuffer); + } + // SAFETY: The same callback lifetime makes the sample reference valid. + if !unsafe { sample.data_is_ready() } { + return Err(MacosCaptureError::SampleDataNotReady); + } + let attachments = FrameAttachments::from_sample(sample)?.decode(); + // SAFETY: The valid, ready sample remains live while Core Media returns a + // retained image-buffer owner. Lifecycle samples may have no image buffer. + let pixel_buffer = unsafe { sample.image_buffer() }; + Ok(RetainedNativeSample { + attachments, + pixel_buffer, + cursor_composed, + }) +} + +fn publish_decoded_result( + result: Result, + epoch: u64, + streams: &Weak, + shared: &SessionShared, +) { + match result { + Ok(MacosFrameEvent::Frame(frame)) => { + let active = shared.current_epoch() == epoch + || streams + .upgrade() + .is_some_and(|streams| streams.activate(epoch)); + if active { + shared.publish(MacosFrameEvent::Frame(frame)); + } + } + Ok(event) if shared.current_epoch() == epoch => shared.publish(event), + Ok(_) => {} + Err(error) => shared.counters.record_drop(&error), + } +} + #[derive(Debug)] struct CaptureOutputIvars { - decoder: Mutex, + samples: LatestSampleInput>, shared: Arc, streams: Weak, epoch: u64, @@ -199,29 +257,24 @@ define_class!( output_type: SCStreamOutputType, ) { self.ivars().shared.counters.record_received(); - let result = if output_type == SCStreamOutputType::Screen { - let mut decoder = lock(&self.ivars().decoder); - decode_sample(&mut decoder, sample_buffer, self.ivars().cursor_composed) + if self + .ivars() + .streams + .upgrade() + .is_none_or(|streams| !streams.accepts_epoch(self.ivars().epoch)) + { + return; + } + let sample = if output_type == SCStreamOutputType::Screen { + retain_sample(sample_buffer, self.ivars().cursor_composed) } else { Err(MacosCaptureError::UnexpectedStreamOutputType(output_type.0)) }; - match result { - Ok(MacosFrameEvent::Frame(frame)) => { - let active = self.ivars().shared.current_epoch() == self.ivars().epoch - || self - .ivars() - .streams - .upgrade() - .is_some_and(|streams| streams.activate(self.ivars().epoch)); - if active { - self.ivars().shared.publish(MacosFrameEvent::Frame(frame)); - } - } - Ok(event) if self.ivars().shared.current_epoch() == self.ivars().epoch => { - self.ivars().shared.publish(event); - } - Ok(_) => {} - Err(error) => self.ivars().shared.counters.record_drop(&error), + if self.ivars().samples.publish(sample) == SamplePublishOutcome::Superseded { + self.ivars() + .shared + .counters + .record_native_sample_superseded(); } } } @@ -267,13 +320,14 @@ define_class!( impl CaptureOutput { fn new( epoch: u64, + samples: LatestSampleInput>, shared: Arc, streams: Weak, cursor_composed: bool, display_filter: bool, ) -> Retained { let this = Self::alloc().set_ivars(CaptureOutputIvars { - decoder: Mutex::new(MacosFrameDecoder::new(epoch)), + samples, shared, streams, epoch, @@ -297,6 +351,7 @@ struct NativeStream { stream: Retained, filter: NativeFilter, selection: MacosCaptureSelection, + worker: LatestSampleWorker>, _output: Retained, _queue: DispatchRetained, } @@ -322,8 +377,23 @@ impl NativeStream { Retained::retain(ptr::from_ref(filter).cast_mut()) .ok_or(MacosCaptureError::RetainNativeFilterFailed)? }; + let mut decoder = MacosFrameDecoder::new(epoch); + let worker_shared = Arc::clone(&shared); + let worker_streams = streams.clone(); + let worker = LatestSampleWorker::spawn( + "hypercolor-macos-screen-capture", + move |sample: Result| { + sample.and_then(|sample| decode_sample(&mut decoder, sample)) + }, + move |result| { + publish_decoded_result(result, epoch, &worker_streams, &worker_shared); + }, + ) + .map_err(|error| MacosCaptureError::CaptureWorkerStartFailed(error.to_string()))?; + let samples = worker.input(); let output = CaptureOutput::new( epoch, + samples, shared, streams, request.cursor_composed, @@ -360,15 +430,49 @@ impl NativeStream { stream, filter: NativeFilter(retained_filter), selection, + worker, _output: output, _queue: queue, }) } - fn stop(&self) { - // SAFETY: Stopping an owned SCStream without a completion callback is - // valid and retains no borrowed Rust state. - unsafe { self.stream.stopCaptureWithCompletionHandler(None) }; + fn epoch(&self) -> u64 { + self._output.ivars().epoch + } + + fn stop(mut self) -> Result<(), MacosCaptureError> { + self.worker.close(); + let worker_result = self + .worker + .join() + .map_err(|_| MacosCaptureError::CaptureWorkerPanicked); + let (completion_tx, completion_rx) = std::sync::mpsc::sync_channel(1); + let completion = RcBlock::new(move |error: *mut NSError| { + // SAFETY: ScreenCaptureKit supplies either null or a live NSError + // for the duration of this completion invocation. + let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + Err(native_error("stop ScreenCaptureKit stream", error)) + }); + let _ = completion_tx.send(result); + }); + // SAFETY: ScreenCaptureKit copies the completion block and the stream + // remains retained until the completion result is received. + unsafe { + self.stream + .stopCaptureWithCompletionHandler(Some(&completion)); + } + let stop_result = completion_rx + .recv() + .map_err(|_| MacosCaptureError::StreamStopCompletionLost) + .and_then(std::convert::identity); + stop_result.and(worker_result) + } + + fn retire_after_native_stop(mut self) -> Result<(), MacosCaptureError> { + self.worker.close(); + self.worker + .join() + .map_err(|_| MacosCaptureError::CaptureWorkerPanicked) } } @@ -425,7 +529,7 @@ impl StreamSlot { let stream = candidate.stream.clone(); let replaced = lock(&self.state).candidate.replace(candidate); if let Some(replaced) = replaced { - replaced.stop(); + self.stop_stream(replaced); } self.shared.set_status(MacosProtectedSourceState::Starting); start_stream( @@ -442,7 +546,7 @@ impl StreamSlot { let mut state = lock(&self.state); let Some(candidate) = state .candidate - .take_if(|candidate| candidate._output.ivars().epoch == epoch) + .take_if(|candidate| candidate.epoch() == epoch) else { return false; }; @@ -455,31 +559,42 @@ impl StreamSlot { previous }; if let Some(previous) = previous { - previous.stop(); + self.stop_stream(previous); } true } - fn remove(&self, epoch: u64) -> StreamRole { + fn remove(&self, epoch: u64) -> (StreamRole, Option) { let mut state = lock(&self.state); if state .candidate .as_ref() - .is_some_and(|candidate| candidate._output.ivars().epoch == epoch) + .is_some_and(|candidate| candidate.epoch() == epoch) { - state.candidate.take(); - return StreamRole::Candidate; + return (StreamRole::Candidate, state.candidate.take()); } if state .current .as_ref() - .is_some_and(|current| current._output.ivars().epoch == epoch) + .is_some_and(|current| current.epoch() == epoch) { - state.current.take(); + let current = state.current.take(); self.shared.activate_epoch(0); - return StreamRole::Current; + return (StreamRole::Current, current); } - StreamRole::Stale + (StreamRole::Stale, None) + } + + fn accepts_epoch(&self, epoch: u64) -> bool { + let state = lock(&self.state); + state + .current + .as_ref() + .is_some_and(|stream| stream.epoch() == epoch) + || state + .candidate + .as_ref() + .is_some_and(|stream| stream.epoch() == epoch) } fn has_current(&self) -> bool { @@ -527,10 +642,16 @@ impl StreamSlot { }; self.shared.activate_epoch(0); if let Some(candidate) = candidate { - candidate.stop(); + self.stop_stream(candidate); } if let Some(current) = current { - current.stop(); + self.stop_stream(current); + } + } + + fn stop_stream(&self, stream: NativeStream) { + if let Err(error) = stream.stop() { + self.shared.publish_recoverable_error(error); } } } @@ -559,9 +680,14 @@ fn handle_stream_error( shared: &SessionShared, error: &NSError, ) { - let role = streams + let (role, retired) = streams .upgrade() - .map_or(StreamRole::Stale, |streams| streams.remove(epoch)); + .map_or((StreamRole::Stale, None), |streams| streams.remove(epoch)); + if let Some(retired) = retired + && let Err(worker_error) = retired.retire_after_native_stop() + { + shared.counters.record_drop(&worker_error); + } let preserve_current = match role { StreamRole::Candidate if streams @@ -1179,24 +1305,11 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { .unwrap_or_else(std::sync::PoisonError::into_inner) } -pub(crate) fn decode_sample( +fn decode_sample( decoder: &mut MacosFrameDecoder, - sample: &CMSampleBuffer, - cursor_composed: bool, + sample: RetainedNativeSample, ) -> Result { - // SAFETY: ScreenCaptureKit supplied a live CMSampleBuffer reference for - // the duration of this callback. - if !unsafe { sample.is_valid() } { - return Err(MacosCaptureError::InvalidSampleBuffer); - } - // SAFETY: The same callback lifetime makes the sample reference valid. - if !unsafe { sample.data_is_ready() } { - return Err(MacosCaptureError::SampleDataNotReady); - } - - let attachments = FrameAttachments::from_sample(sample)?; - let raw_attachments = attachments.decode(); - let status = match raw_attachments.status { + let status = match sample.attachments.status { MacosAttachment::Value(status) => MacosFrameStatus::try_from(status)?, MacosAttachment::Missing => return Err(MacosCaptureError::MissingAttachment("status")), MacosAttachment::Malformed => { @@ -1206,18 +1319,17 @@ pub(crate) fn decode_sample( if status != MacosFrameStatus::Complete { return decoder.decode(MacosRawCaptureSample { frame: None, - attachments: raw_attachments, + attachments: sample.attachments, }); } - // SAFETY: The valid, ready sample is retained by the callback while Core - // Media returns a retained image-buffer owner. - let pixel_buffer = - unsafe { sample.image_buffer() }.ok_or(MacosCaptureError::MissingFramePayload)?; - let frame = decode_complete_frame(pixel_buffer, cursor_composed)?; + let pixel_buffer = sample + .pixel_buffer + .ok_or(MacosCaptureError::MissingFramePayload)?; + let frame = decode_complete_frame(pixel_buffer, sample.cursor_composed)?; decoder.decode(MacosRawCaptureSample { frame: Some(frame), - attachments: raw_attachments, + attachments: sample.attachments, }) } diff --git a/crates/hypercolor-macos-capture/src/worker.rs b/crates/hypercolor-macos-capture/src/worker.rs new file mode 100644 index 000000000..f7b4b48a1 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/worker.rs @@ -0,0 +1,258 @@ +use std::io; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::thread::{self, JoinHandle}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SamplePublishOutcome { + Accepted, + Superseded, + Closed, +} + +#[derive(Debug)] +pub(crate) struct LatestSampleInput { + inner: Arc>, +} + +#[derive(Debug)] +struct LatestSampleInner { + state: Mutex>, + ready: Condvar, +} + +#[derive(Debug)] +struct LatestSampleState { + latest: Option, + closed: bool, +} + +pub(crate) struct LatestSampleWorker { + input: LatestSampleInput, + worker: Option>, +} + +impl Clone for LatestSampleInput { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl LatestSampleInput { + fn new() -> Self { + Self { + inner: Arc::new(LatestSampleInner { + state: Mutex::new(LatestSampleState { + latest: None, + closed: false, + }), + ready: Condvar::new(), + }), + } + } + + pub(crate) fn publish(&self, sample: T) -> SamplePublishOutcome { + let mut state = self.lock(); + if state.closed { + return SamplePublishOutcome::Closed; + } + let outcome = if state.latest.replace(sample).is_some() { + SamplePublishOutcome::Superseded + } else { + SamplePublishOutcome::Accepted + }; + drop(state); + self.inner.ready.notify_one(); + outcome + } + + fn close(&self) { + let mut state = self.lock(); + state.closed = true; + state.latest = None; + drop(state); + self.inner.ready.notify_all(); + } + + fn take_next(&self) -> Option { + let state = self.lock(); + let mut state = self + .inner + .ready + .wait_while(state, |state| state.latest.is_none() && !state.closed) + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed { + return None; + } + state.latest.take() + } + + fn lock(&self) -> MutexGuard<'_, LatestSampleState> { + self.inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl LatestSampleWorker { + pub(crate) fn spawn( + thread_name: &str, + mut decode: impl FnMut(T) -> O + Send + 'static, + mut publish: impl FnMut(O) + Send + 'static, + ) -> io::Result { + let input = LatestSampleInput::new(); + let worker_input = input.clone(); + let worker = thread::Builder::new() + .name(thread_name.to_owned()) + .spawn(move || { + while let Some(sample) = worker_input.take_next() { + publish(decode(sample)); + } + })?; + Ok(Self { + input, + worker: Some(worker), + }) + } + + pub(crate) fn input(&self) -> LatestSampleInput { + self.input.clone() + } + + pub(crate) fn close(&self) { + self.input.close(); + } + + pub(crate) fn join(&mut self) -> thread::Result<()> { + self.worker.take().map_or(Ok(()), JoinHandle::join) + } +} + +impl Drop for LatestSampleWorker { + fn drop(&mut self) { + self.input.close(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, mpsc}; + use std::thread; + use std::time::Duration; + + use super::{LatestSampleWorker, SamplePublishOutcome}; + + #[test] + fn callback_handoff_stays_bounded_while_decode_is_blocked() { + let (decode_started_tx, decode_started_rx) = mpsc::channel(); + let (release_decode_tx, release_decode_rx) = mpsc::channel(); + let (published_tx, published_rx) = mpsc::channel(); + let mut first = true; + let mut worker = LatestSampleWorker::spawn( + "macos-capture-bounded-callback-test", + move |sample| { + if first { + first = false; + decode_started_tx + .send(()) + .expect("decode start should be observable"); + release_decode_rx.recv().expect("decode should be released"); + } + sample + }, + move |sample| { + published_tx + .send(sample) + .expect("decoded sample should publish"); + }, + ) + .expect("worker should start"); + let input = worker.input(); + + assert_eq!(input.publish(1), SamplePublishOutcome::Accepted); + decode_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("worker should begin decoding"); + assert_eq!(input.publish(2), SamplePublishOutcome::Accepted); + assert_eq!(input.publish(3), SamplePublishOutcome::Superseded); + release_decode_tx + .send(()) + .expect("blocked decode should resume"); + + assert_eq!( + published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("first sample should publish"), + 1 + ); + assert_eq!( + published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("latest sample should publish"), + 3 + ); + worker.close(); + worker.join().expect("worker should join"); + } + + #[test] + fn decode_errors_are_emitted_from_the_worker_thread() { + let caller = thread::current().id(); + let (published_tx, published_rx) = mpsc::channel(); + let mut worker = LatestSampleWorker::spawn( + "macos-capture-decode-error-test", + move |sample: Result<(), &'static str>| (thread::current().id(), sample), + move |result| { + published_tx + .send(result) + .expect("decode result should publish"); + }, + ) + .expect("worker should start"); + + assert_eq!( + worker.input().publish(Err("malformed frame")), + SamplePublishOutcome::Accepted + ); + let (decoder, result) = published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("worker should emit the decode error"); + assert_ne!(decoder, caller); + assert_eq!(result, Err("malformed frame")); + worker.close(); + worker.join().expect("worker should join"); + } + + #[test] + fn teardown_wakes_and_joins_an_idle_worker() { + struct ExitMarker(Arc); + + impl Drop for ExitMarker { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + let exited = Arc::new(AtomicBool::new(false)); + let marker = ExitMarker(Arc::clone(&exited)); + let mut worker = LatestSampleWorker::spawn( + "macos-capture-teardown-test", + |sample: ()| sample, + move |_| { + let _ = ▮ + }, + ) + .expect("worker should start"); + + worker.close(); + worker.join().expect("idle worker should join"); + assert!(exited.load(Ordering::Acquire)); + assert_eq!(worker.input().publish(()), SamplePublishOutcome::Closed); + } +} From 85b21e6a024640e2b3d9cc19bb229e05cd02f4c5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:17:54 -0700 Subject: [PATCH 053/144] feat(macos): publish exact capture surfaces Resolve macOS ScreenCaptureKit frames into the exact publication graph with transactional CPU and identity-preserving Metal branches. Native surfaces retain capture, target, and admission lifetimes through daemon composition. Reduced RGBA requests stay on the CPU path until the native reduction kernel can produce a truthful reduced owner-backed texture. Co-Authored-By: Nova (GPT-5.4) --- .../hypercolor-core/src/input/screen/macos.rs | 1414 ++++++++++++++++- .../hypercolor-core/src/input/screen/mod.rs | 5 +- 2 files changed, 1383 insertions(+), 36 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 20a64cb04..5f4d4f3d1 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -1,14 +1,17 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, mpsc}; use std::thread; use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureContentStyle, MacosCaptureFrame, MacosCaptureSelection, MacosDisplayClock, - MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, - MacosProtectedSourceState as NativeProtectedSourceState, + MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCaptureSelection, MacosColorPrimaries, MacosDisplayClock, MacosFrameEvent, + MacosFrameMailbox, MacosFrameStatus, MacosProtectedSourceState as NativeProtectedSourceState, + MacosTransferFunction, }; +use tokio::sync::oneshot; #[cfg(target_os = "macos")] use hypercolor_macos_capture::{ @@ -16,12 +19,27 @@ use hypercolor_macos_capture::{ }; use super::{ - CaptureConfig, CaptureCursor, CaptureCursorContent, CaptureDamage, CaptureFrame, - CaptureFrameMetadata, CapturePixelFormat, CapturePlanePool, CaptureRotation, CaptureSourceId, - CaptureStorage, CpuCaptureStorage, PixelExtent, PixelRect, RawCaptureSurface, - ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, - ScreenByteAdmissionCoordinator, ScreenCaptureDemand, ScreenCaptureInput, SourceScale, - analyze_screen_frame, + AdmittedScreenNativeTargetPreparation, BoundScreenNativeTargetPreparation, CaptureCadence, + CaptureColorSpace, CaptureColorimetry, CaptureConfig, CaptureCursor, CaptureCursorContent, + CaptureDamage, CaptureDynamicRange, CaptureEpoch, CaptureFrame, CaptureFrameMetadata, + CapturePixelFormat, CapturePlanePool, CaptureRotation, CaptureSourceId, CaptureStorage, + CaptureTransferFunction, CpuCaptureStorage, CpuExactReductionWorkPlan, CpuReductionExecutor, + PixelExtent, PixelRect, PlatformGpuApi, PlatformGpuSurface, PreparedCpuPublicationFanout, + PreparedCpuPublicationFanoutCandidate, RawCaptureSurface, RegisteredScreenBranchDemand, + ResolvedScreenBranchDemand, ResolvedScreenColorTransform, ResolvedScreenPublicationDescriptor, + ResolvedScreenSource, ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, + ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, + ScreenBranchPayload, ScreenBranchPublisher, ScreenByteAdmissionCoordinator, + ScreenCaptureBackend, ScreenCaptureDemand, ScreenCaptureInput, + ScreenColorTransformCapabilities, ScreenCursorCapabilities, ScreenExecutorColorCapabilities, + ScreenGpuSurfacePayload, ScreenNativePreparationPayload, ScreenPhysicalGpuDeviceIdentity, + ScreenPreparedWorkerToken, ScreenPublicationColorimetry, ScreenPublicationExecutor, + ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, + ScreenPublicationHubError, ScreenPublicationMetadata, ScreenRequiredResourceMinimum, + ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, + ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, + ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, + ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; use crate::input::status::SourceSessionSlot; use crate::input::traits::{ @@ -35,6 +53,59 @@ use crate::input::{ const WORKER_WAIT: Duration = Duration::from_millis(100); +/// Descriptor-keyed source data passed to the daemon-owned Metal target. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MacosNativeTargetManifest { + capture_session_generation: u64, + resource_generation: u64, + metal_registry_id: u64, +} + +impl MacosNativeTargetManifest { + fn new(descriptor: &ResolvedScreenPublicationDescriptor) -> anyhow::Result { + let resources = descriptor.physical().source().resources(); + let ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(metal_registry_id) = resources + .physical_gpu_device() + .ok_or_else(|| anyhow!("macOS native publication is missing Metal identity"))? + else { + return Err(anyhow!( + "macOS native publication selected a non-Metal device" + )); + }; + if *metal_registry_id == 0 + || resources.device_generation() == 0 + || resources.resource_generation() == 0 + { + return Err(anyhow!( + "macOS native publication generations must be nonzero" + )); + } + Ok(Self { + capture_session_generation: resources.device_generation(), + resource_generation: resources.resource_generation(), + metal_registry_id: *metal_registry_id, + }) + } + + /// Capture-session generation whose surfaces this target accepts. + #[must_use] + pub const fn capture_session_generation(&self) -> u64 { + self.capture_session_generation + } + + /// Storage-descriptor generation whose surfaces this target accepts. + #[must_use] + pub const fn resource_generation(&self) -> u64 { + self.resource_generation + } + + /// Physical Metal device registry identity. + #[must_use] + pub const fn metal_registry_id(&self) -> u64 { + self.metal_registry_id + } +} + trait MacosCaptureControl: Send + Sync { fn mailbox(&self) -> MacosFrameMailbox; fn set_active(&self, active: bool); @@ -101,6 +172,280 @@ struct MacosPublication { latest: Option>, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct MacosPublicationSource { + epoch: CaptureEpoch, + geometry: super::CaptureGeometry, + logical_extent: PixelExtent, + colorimetry: CaptureColorimetry, + pixel_format: MacosCapturePixelFormat, + resource_generation: u64, + allocation_bytes: u64, + cursor_composed: bool, +} + +impl MacosPublicationSource { + fn from_frame( + source_id: CaptureSourceId, + topology_generation: u64, + resource_generation: u64, + frame: &MacosCaptureFrame, + ) -> anyhow::Result { + let storage_extent = + PixelExtent::new(frame.storage_extent.width, frame.storage_extent.height)?; + let content = frame.geometry.content_rect_pixels; + let content_x = u32::try_from(content.x)?; + let content_y = u32::try_from(content.y)?; + let content_rect = PixelRect::new(content_x, content_y, content.width, content.height)?; + let crop = (content_x != 0 + || content_y != 0 + || content.width != storage_extent.width() + || content.height != storage_extent.height()) + .then_some(content_rect); + Ok(Self { + epoch: CaptureEpoch { + source_id, + topology_generation, + session_generation: frame.epoch, + }, + geometry: super::CaptureGeometry::new( + capture_origin(frame)?, + storage_extent, + storage_extent, + CaptureRotation::Identity, + crop, + SourceScale::ONE, + )?, + logical_extent: content_rect.extent(), + colorimetry: capture_colorimetry(frame.color)?, + pixel_format: frame.pixel_format, + resource_generation, + allocation_bytes: frame.surface.allocation_bytes, + cursor_composed: frame.cursor_composed, + }) + } + + fn matches_selector(&self, selector: &ScreenSourceSelector) -> bool { + match selector { + ScreenSourceSelector::Configured | ScreenSourceSelector::Primary => true, + ScreenSourceSelector::Exact(source_id) => source_id == &self.epoch.source_id, + } + } + + fn cursor_capabilities(&self) -> ScreenCursorCapabilities { + if self.cursor_composed { + ScreenCursorCapabilities::composed_only() + } else { + ScreenCursorCapabilities::clean_only() + } + } + + fn cpu_source(&self, selector: ScreenSourceSelector) -> ResolvedScreenSource { + ResolvedScreenSource::new( + selector, + self.epoch.clone(), + ResolvedScreenSourceConfig::new_with_cursor_capabilities( + self.geometry, + self.logical_extent, + ScreenSourceReflection::None, + CapturePixelFormat::Rgba8, + CaptureColorimetry::SRGB, + self.cursor_capabilities(), + ScreenBackendResourceIdentity::new( + ScreenCaptureBackend::MacosScreenCaptureKit, + ScreenResourceApi::Cpu, + self.epoch.session_generation, + self.resource_generation, + ), + ), + ) + } + + fn gpu_source( + &self, + selector: ScreenSourceSelector, + physical_gpu_device: ScreenPhysicalGpuDeviceIdentity, + ) -> anyhow::Result { + let ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(registry_id) = physical_gpu_device + else { + return Err(anyhow!("macOS capture requires a Metal execution target")); + }; + if registry_id == 0 { + return Err(anyhow!( + "macOS capture received a zero Metal registry identity" + )); + } + let pixel_format = match self.pixel_format { + MacosCapturePixelFormat::Bgra8 => CapturePixelFormat::Bgra8, + _ => { + return Err(anyhow!( + "macOS native capture format is not implemented yet" + )); + } + }; + Ok(ResolvedScreenSource::new( + selector, + self.epoch.clone(), + ResolvedScreenSourceConfig::new_with_cursor_capabilities( + self.geometry, + self.logical_extent, + ScreenSourceReflection::None, + pixel_format, + self.colorimetry, + self.cursor_capabilities(), + ScreenBackendResourceIdentity::new_with_physical_gpu_device( + ScreenCaptureBackend::MacosScreenCaptureKit, + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Metal), + ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(registry_id), + self.epoch.session_generation, + self.resource_generation, + ), + ), + )) + } +} + +struct MacosOwnedSource { + source_id: CaptureSourceId, + binding: ScreenWorkerBinding, + _runtime_lifetime: ScreenResourceLifetime, +} + +#[derive(Default)] +struct MacosExactPublicationShared { + source: Mutex>, + owned_sources: Mutex>, + hub: Mutex>>, + cpu_executor: Mutex>>, + resolution_revision: AtomicU64, +} + +impl MacosExactPublicationShared { + fn replace_source(&self, next: Option) { + let mut source = lock(&self.source); + if *source == next { + return; + } + *source = next; + self.resolution_revision + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |revision| { + revision.checked_add(1) + }) + .expect("macOS screen publication resolution revision exhausted"); + } + + fn source(&self) -> Option { + lock(&self.source).clone() + } + + fn hub(&self) -> Option> { + lock(&self.hub).clone() + } + + fn owns_source(&self, source_id: &CaptureSourceId) -> bool { + self.source() + .is_some_and(|source| &source.epoch.source_id == source_id) + || lock(&self.owned_sources) + .iter() + .any(|source| &source.source_id == source_id) + } + + fn register_owned_source(&self, source: MacosOwnedSource) { + lock(&self.owned_sources).push(source); + } + + fn reap_owned_sources(&self) { + let authority = self.hub().map(|hub| hub.committed_state()); + lock(&self.owned_sources).retain(|source| { + authority + .as_ref() + .is_some_and(|authority| authority.owns_runtime_binding(&source.binding)) + }); + } + + fn clear_owned_sources(&self) { + lock(&self.owned_sources).clear(); + } + + fn cpu_executor(&self) -> anyhow::Result> { + let mut executor = lock(&self.cpu_executor); + if let Some(executor) = executor.as_ref() { + return Ok(Arc::clone(executor)); + } + let prepared = Arc::new(CpuReductionExecutor::new( + thread::available_parallelism().unwrap_or(NonZeroUsize::MIN), + NonZeroU32::new(16).expect("CPU reduction tile height is nonzero"), + )?); + *executor = Some(Arc::clone(&prepared)); + Ok(prepared) + } +} + +struct MacosNativeRoute { + descriptor: ResolvedScreenPublicationDescriptor, + target: BoundScreenNativeTargetPreparation, + capture_lifetime: ScreenResourceLifetime, + pacer: super::CapturePacer, + next_publish_at: Instant, + last_accepted_sequence: Option, + publisher: Option, +} + +struct MacosExactRuntime { + source: MacosPublicationSource, + binding: ScreenWorkerBinding, + _lifetimes: Box<[ScreenResourceLifetime]>, + native_routes: Box<[MacosNativeRoute]>, + fanout_candidate: Option, + fanout: Option, +} + +impl MacosExactRuntime { + fn bind_if_current(&mut self, hub: &ScreenPublicationHub) -> anyhow::Result<()> { + let authority = hub.committed_state(); + if !authority.owns_runtime_binding(&self.binding) { + return Ok(()); + } + match self.binding.state() { + ScreenWorkerBindingState::Active | ScreenWorkerBindingState::Retired => {} + ScreenWorkerBindingState::Prepared | ScreenWorkerBindingState::Armed => return Ok(()), + ScreenWorkerBindingState::Aborted => { + return Err(anyhow!("macOS exact runtime was aborted after commit")); + } + } + for route in &mut self.native_routes { + if route.publisher.is_none() { + route.publisher = + Some(authority.publisher_for_runtime(&route.descriptor, &self.binding)?); + } + } + if self.fanout.is_none() + && let Some(candidate) = self.fanout_candidate.take() + { + self.fanout = Some(candidate.bind(&authority, &self.binding)?); + } + Ok(()) + } + + fn is_bound(&self) -> bool { + self.native_routes + .iter() + .all(|route| route.publisher.is_some()) + && self.fanout_candidate.is_none() + } +} + +enum WorkerCommand { + PrepareExact { + ticket: ScreenWorkerPreparationTicket, + cancelled: Arc, + completion: oneshot::Sender>, + }, + ReapExact { + completion: Option>>, + }, +} + struct PreparedWorker { analyzer: ScreenCaptureInput, plane_pool: CapturePlanePool, @@ -110,6 +455,7 @@ struct PreparedWorker { struct CaptureWorker { stop: Arc, mailbox: MacosFrameMailbox, + command_tx: mpsc::Sender, exit_rx: mpsc::Receiver>, join: Option>, } @@ -119,6 +465,7 @@ pub struct MacosScreenCaptureInput { control: Arc, admission: ScreenByteAdmissionCoordinator, publication: Arc>, + exact: Arc, worker: Option, worker_generation: u64, demand: ScreenCaptureDemand, @@ -136,7 +483,7 @@ impl MacosScreenCaptureInput { ) -> anyhow::Result { let request = MacosStreamRequest::new( MacosCaptureCadence::FramesPerSecond(config.target_fps), - true, + false, )?; let selector = MacosCaptureSelector::parse(&config.source)?; let session = MacosScreenCaptureSession::new(request, selector)?; @@ -159,6 +506,7 @@ impl MacosScreenCaptureInput { control, admission, publication: Arc::new(Mutex::new(MacosPublication::default())), + exact: Arc::new(MacosExactPublicationShared::default()), worker: None, worker_generation: 0, demand: ScreenCaptureDemand::Inactive, @@ -252,6 +600,7 @@ impl MacosScreenCaptureInput { let worker_mailbox = mailbox.clone(); let control = Arc::clone(&self.control); let publication = Arc::clone(&self.publication); + let exact = Arc::clone(&self.exact); let status_session = self.status_session.clone(); let target_fps = prepared.target_fps; let stop = Arc::new(AtomicBool::new(false)); @@ -259,6 +608,7 @@ impl MacosScreenCaptureInput { let start = Arc::new(AtomicBool::new(false)); let worker_start = Arc::clone(&start); let (exit_tx, exit_rx) = mpsc::channel(); + let (command_tx, command_rx) = mpsc::channel(); let join = thread::Builder::new() .name("hypercolor-macos-screen-capture".to_owned()) .spawn(move || { @@ -272,11 +622,13 @@ impl MacosScreenCaptureInput { prepared, mailbox, publication, + exact, worker_generation, target_fps, status_session, worker_stop, control, + command_rx, ) }; let _ = exit_tx.send(result); @@ -292,6 +644,7 @@ impl MacosScreenCaptureInput { self.worker = Some(CaptureWorker { stop, mailbox: worker_mailbox, + command_tx, exit_rx, join: Some(join), }); @@ -315,6 +668,7 @@ impl MacosScreenCaptureInput { let _ = join.join(); } lock(&self.publication).latest = None; + self.exact.replace_source(None); } fn observe_worker_exit(&mut self) -> anyhow::Result<()> { @@ -506,6 +860,86 @@ impl InputSource for MacosScreenCaptureInput { Ok(()) } + fn set_screen_publication_hub(&mut self, hub: Arc) { + *lock(&self.exact.hub) = Some(hub); + } + + fn screen_publication_resolution_revision(&self) -> u64 { + self.exact.resolution_revision.load(Ordering::Acquire) + } + + fn resolve_screen_publication_branch( + &self, + demand: &RegisteredScreenBranchDemand, + ) -> anyhow::Result> { + let Some(source) = self.exact.source() else { + return Ok(None); + }; + resolve_macos_publication_branch(&source, demand) + } + + fn owns_screen_publication_source(&self, source_id: &CaptureSourceId) -> bool { + self.exact.owns_source(source_id) + } + + fn begin_screen_publication_preparation( + &mut self, + ticket: ScreenWorkerPreparationTicket, + ) -> anyhow::Result { + let worker = self.worker.as_ref().ok_or_else(|| { + anyhow!("macOS capture worker is unavailable for exact publication preparation") + })?; + let cancelled = Arc::new(AtomicBool::new(false)); + let (completion_tx, completion_rx) = oneshot::channel(); + worker + .command_tx + .send(WorkerCommand::PrepareExact { + ticket, + cancelled: Arc::clone(&cancelled), + completion: completion_tx, + }) + .map_err(|_| anyhow!("macOS capture worker rejected exact publication preparation"))?; + worker.mailbox.wake(); + let abort_tx = worker.command_tx.clone(); + let abort_mailbox = worker.mailbox.clone(); + Ok(ScreenWorkerPreparation::with_abort( + async move { + completion_rx.await.map_err(|_| { + anyhow!("macOS capture worker exited during exact publication preparation") + })? + }, + move || { + cancelled.store(true, Ordering::Release); + let _ = abort_tx.send(WorkerCommand::ReapExact { completion: None }); + abort_mailbox.wake(); + }, + )) + } + + fn begin_screen_publication_retirement(&mut self) -> Option { + let worker = self.worker.as_ref()?; + let (completion_tx, completion_rx) = oneshot::channel(); + if worker + .command_tx + .send(WorkerCommand::ReapExact { + completion: Some(completion_tx), + }) + .is_err() + { + return Some(ScreenWorkerRetirement::new(async { + Err(anyhow!( + "macOS capture worker rejected exact publication retirement" + )) + })); + } + worker.mailbox.wake(); + Some(ScreenWorkerRetirement::new(async move { + completion_rx.await.map_err(|_| { + anyhow!("macOS capture worker exited during exact publication retirement") + })? + })) + } + fn reconfigure_screen_capture(&mut self, config: &CaptureConfig) -> anyhow::Result<()> { let prepared = self .demand @@ -553,6 +987,66 @@ impl InputSource for MacosScreenCaptureInput { } } +fn resolve_macos_publication_branch( + source: &MacosPublicationSource, + demand: &RegisteredScreenBranchDemand, +) -> anyhow::Result> { + let selector = demand.request().selector(); + if !source.matches_selector(selector) { + return Ok(None); + } + let selector = selector.clone(); + let capabilities = ScreenColorTransformCapabilities::new(true, false, false, NonZeroU32::MIN); + if matches!( + demand.request().executor(), + ScreenPublicationExecutorRequest::Cpu + ) { + return Ok(Some(demand.resolve_with_color_capabilities( + &source.cpu_source(selector), + capabilities, + )?)); + } + + let ScreenPublicationExecutorRequest::SourceNative(target) = demand.request().executor() else { + unreachable!("screen publication executor requests are exhaustive"); + }; + if target.accepted_api() == &PlatformGpuApi::Metal + && let Ok(native_source) = + source.gpu_source(selector.clone(), target.physical_gpu_device().clone()) + && let Ok(resolved) = demand.resolve_with_executor_capabilities( + &native_source, + ScreenExecutorColorCapabilities::new(capabilities, capabilities), + ) + && matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + ) + && macos_native_descriptor_is_identity(resolved.descriptor(), source) + && MacosNativeTargetManifest::new(resolved.descriptor()).is_ok() + { + return Ok(Some(resolved)); + } + + Ok(Some(demand.resolve_with_color_capabilities( + &source.cpu_source(selector), + capabilities, + )?)) +} + +fn macos_native_descriptor_is_identity( + descriptor: &ResolvedScreenPublicationDescriptor, + source: &MacosPublicationSource, +) -> bool { + source.geometry.crop().is_none() + && descriptor.geometry().output_extent() == source.geometry.storage_extent() + && descriptor.physical().reduction_extent() == source.geometry.storage_extent() + && descriptor.physical().target_pixel_format() == CapturePixelFormat::Bgra8 + && matches!( + descriptor.physical().color_pipeline().transform(), + ResolvedScreenColorTransform::PreserveEncodedSamples + ) +} + impl Drop for MacosScreenCaptureInput { fn drop(&mut self) { self.control.set_active(false); @@ -560,19 +1054,394 @@ impl Drop for MacosScreenCaptureInput { } } +struct PendingMacosNativeRoute { + resource_name: Arc, + capture_resource_name: Arc, + descriptor: ResolvedScreenPublicationDescriptor, + target: AdmittedScreenNativeTargetPreparation, + requested_hz: NonZeroU32, +} + +fn checked_macos_metadata_bytes(count: usize, resource: &str) -> anyhow::Result { + u64::try_from(count) + .ok() + .and_then(|count| { + u64::try_from(std::mem::size_of::()) + .ok() + .and_then(|size| count.checked_mul(size)) + }) + .ok_or_else(|| anyhow!("macOS exact {resource} metadata accounting overflow")) +} + +fn preflight_macos_scope_bytes( + ledger: &mut ScreenWorkerExactLedgerBuilder, + minimum_remaining: &mut u64, + bytes: u64, +) -> anyhow::Result<()> { + let modeled = bytes.min(*minimum_remaining); + *minimum_remaining -= modeled; + let additional = bytes - modeled; + if additional > 0 { + ledger.preflight_additional_bytes(additional)?; + } + Ok(()) +} + +fn prepare_macos_exact_runtime( + ticket: ScreenWorkerPreparationTicket, + source: Option<&MacosPublicationSource>, + exact: &MacosExactPublicationShared, +) -> anyhow::Result<( + ScreenPreparedWorkerToken, + Option<(MacosExactRuntime, MacosOwnedSource)>, +)> { + let candidate = ticket.candidate_plan().clone(); + let source_branches = candidate + .branches() + .iter() + .filter(|branch| branch.descriptor().source_epoch().source_id == *ticket.source_id()) + .collect::>(); + if source_branches.is_empty() { + let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket)?; + let reports = ledger + .ticket() + .required_minimums() + .iter() + .map(|minimum| (Arc::clone(minimum.name()), minimum.minimum_bytes())) + .collect::>(); + for (name, bytes) in reports { + ledger.report(&name, bytes)?; + } + let (token, _) = ledger.finish()?.into_parts(); + return Ok((token, None)); + } + + let source = source + .filter(|source| &source.epoch.source_id == ticket.source_id()) + .ok_or_else(|| anyhow!("macOS exact publication source changed before preparation"))?; + let cpu_source = source.cpu_source(ScreenSourceSelector::Exact(source.epoch.source_id.clone())); + let executor = exact.cpu_executor()?; + let compute_plan = + CpuExactReductionWorkPlan::try_for_source(&candidate, ticket.source_id(), |_| true)?; + let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket)?; + let mut processing_minimum_remaining = ledger + .ticket() + .required_minimums() + .iter() + .find(|minimum| minimum.resource() == ScreenResourceKind::ProcessingProfileState) + .map_or(0, ScreenRequiredResourceMinimum::minimum_bytes); + let mut worker_minimum_remaining = ledger + .ticket() + .required_minimums() + .iter() + .find(|minimum| minimum.resource() == ScreenResourceKind::WorkerAdditional) + .map_or(0, ScreenRequiredResourceMinimum::minimum_bytes); + let plane_minimum_bytes = ledger + .ticket() + .required_minimums() + .iter() + .filter(|minimum| minimum.resource() == ScreenResourceKind::PhysicalPlane) + .try_fold(0_u64, |total, minimum| { + total + .checked_add(minimum.minimum_bytes()) + .ok_or_else(|| anyhow!("macOS exact physical-plane accounting overflow")) + })?; + let runtime_metadata_bytes = checked_macos_metadata_bytes::(1, "runtime")? + .checked_add(checked_macos_metadata_bytes::( + 1, + "owned source", + )?) + .and_then(|bytes| { + bytes.checked_add( + checked_macos_metadata_bytes::( + source_branches.len(), + "native routes", + ) + .ok()?, + ) + }) + .ok_or_else(|| anyhow!("macOS exact runtime metadata accounting overflow"))?; + preflight_macos_scope_bytes( + &mut ledger, + &mut worker_minimum_remaining, + runtime_metadata_bytes, + )?; + + let (fanout_candidate, fanout_bytes, workspace_bytes) = if compute_plan.cpu_reduction_count() + == 0 + { + (None, 0, 0) + } else { + let batch_quote = executor.batch_allocation_quote(&cpu_source, &candidate)?; + preflight_macos_scope_bytes(&mut ledger, &mut processing_minimum_remaining, batch_quote)?; + let batch = executor.prepare_batch(&cpu_source, &candidate)?; + let workspace_quote = batch.materialization_workspace_allocation_quote(&candidate)?; + let workspace_additional_bytes = workspace_quote + .checked_sub(plane_minimum_bytes) + .ok_or_else(|| anyhow!("macOS workspace quote understates physical-plane minima"))?; + preflight_macos_scope_bytes( + &mut ledger, + &mut worker_minimum_remaining, + workspace_additional_bytes, + )?; + let workspace = batch.prepare_materialization_workspace(&candidate)?; + let workspace_bytes = workspace.allocation_byte_len(); + let fanout_quote = PreparedCpuPublicationFanout::candidate_allocation_quote( + &batch, &workspace, &candidate, + )?; + let fanout_additional_bytes = fanout_quote + .checked_sub(batch_quote) + .ok_or_else(|| anyhow!("macOS fanout quote understates retained batch backing"))?; + preflight_macos_scope_bytes( + &mut ledger, + &mut processing_minimum_remaining, + fanout_additional_bytes, + )?; + let candidate = PreparedCpuPublicationFanout::prepare_executable_candidate( + &executor, &batch, workspace, &candidate, + )?; + let bytes = candidate.allocation_byte_len(); + (Some(candidate), bytes, workspace_bytes) + }; + + let mut pending_native = Vec::new(); + pending_native.try_reserve_exact(source_branches.len())?; + for (index, branch) in source_branches.iter().enumerate() { + let ScreenPublicationExecutor::SourceNative(target) = branch.descriptor().executor() else { + continue; + }; + let manifest = Arc::new(MacosNativeTargetManifest::new(branch.descriptor())?); + let platform = ScreenNativePreparationPayload::new( + branch.descriptor(), + ledger.ticket().plan_generation(), + manifest, + ); + let resource_name: Arc = Arc::from(format!("macos-native-target-{index}")); + let capture_resource_name: Arc = Arc::from(format!("macos-native-capture-{index}")); + let prepared = ledger.prepare_native_target( + target, + branch.descriptor(), + &platform, + Arc::clone(&resource_name), + "worker-runtime-total", + )?; + ledger.preflight_additional_bytes(source.allocation_bytes)?; + ledger.report_scoped( + &capture_resource_name, + "worker-runtime-total", + source.allocation_bytes, + )?; + pending_native.push(PendingMacosNativeRoute { + resource_name, + capture_resource_name, + descriptor: branch.descriptor().clone(), + target: prepared, + requested_hz: branch.requested_hz(), + }); + } + + let processing_scope = ledger + .ticket() + .required_minimums() + .iter() + .find(|minimum| minimum.resource() == ScreenResourceKind::ProcessingProfileState) + .map(|minimum| Arc::clone(minimum.name())); + if fanout_bytes > 0 && processing_scope.is_none() { + ledger.report_scoped("macos-cpu-fanout", "worker-runtime-total", fanout_bytes)?; + } + let expected_lifetime_count = ledger.prospective_resource_count()?; + let lifetime_metadata_bytes = checked_macos_metadata_bytes::( + expected_lifetime_count, + "runtime lifetimes", + )?; + preflight_macos_scope_bytes( + &mut ledger, + &mut worker_minimum_remaining, + lifetime_metadata_bytes, + )?; + let worker_metadata_bytes = workspace_bytes + .saturating_sub(plane_minimum_bytes) + .checked_add(runtime_metadata_bytes) + .and_then(|bytes| bytes.checked_add(lifetime_metadata_bytes)) + .ok_or_else(|| anyhow!("macOS exact worker accounting overflow"))?; + let reports = ledger + .ticket() + .required_minimums() + .iter() + .map(|minimum| { + ( + Arc::clone(minimum.name()), + minimum.resource(), + minimum.minimum_bytes(), + ) + }) + .collect::>(); + for (name, resource, minimum) in &reports { + let actual = match resource { + ScreenResourceKind::ProcessingProfileState + if processing_scope.as_ref() == Some(name) => + { + fanout_bytes.max(*minimum) + } + ScreenResourceKind::WorkerAdditional => worker_metadata_bytes.max(*minimum), + _ => *minimum, + }; + ledger.report(name, actual)?; + } + let exact_ledger = ledger.finish()?; + if exact_ledger.lifetimes().len() != expected_lifetime_count { + return Err(anyhow!( + "macOS exact lifetime metadata changed during preparation" + )); + } + let binding = exact_ledger.token().binding().clone(); + let (token, lifetimes) = exact_ledger.into_parts(); + let mut native_routes = Vec::new(); + native_routes.try_reserve_exact(pending_native.len())?; + for pending in pending_native { + let lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name() == &pending.resource_name) + .cloned() + .ok_or_else(|| anyhow!("macOS native target lifetime is missing"))?; + let capture_lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name() == &pending.capture_resource_name) + .cloned() + .ok_or_else(|| anyhow!("macOS native capture lifetime is missing"))?; + native_routes.push(MacosNativeRoute { + descriptor: pending.descriptor, + target: pending.target.bind(lifetime)?, + capture_lifetime, + pacer: CaptureCadence::new(pending.requested_hz.get())?.pacer(), + next_publish_at: Instant::now(), + last_accepted_sequence: None, + publisher: None, + }); + } + let runtime_lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name().as_ref() == "worker-runtime-total") + .cloned() + .ok_or_else(|| anyhow!("macOS worker runtime lifetime is missing"))?; + Ok(( + token, + Some(( + MacosExactRuntime { + source: source.clone(), + binding: binding.clone(), + _lifetimes: lifetimes, + native_routes: native_routes.into_boxed_slice(), + fanout_candidate, + fanout: None, + }, + MacosOwnedSource { + source_id: source.epoch.source_id.clone(), + binding, + _runtime_lifetime: runtime_lifetime, + }, + )), + )) +} + +fn reap_macos_exact_runtimes( + runtimes: &mut Vec, + exact: &MacosExactPublicationShared, +) { + exact.reap_owned_sources(); + let authority = exact.hub().map(|hub| hub.committed_state()); + runtimes.retain(|runtime| { + authority + .as_ref() + .is_some_and(|authority| authority.owns_runtime_binding(&runtime.binding)) + }); +} + +fn bind_current_macos_exact_runtime<'a>( + runtimes: &'a mut [MacosExactRuntime], + source: &MacosPublicationSource, + hub: &ScreenPublicationHub, +) -> anyhow::Result> { + let authority = hub.committed_state(); + let Some(current_binding) = authority.runtime_binding(&source.epoch.source_id) else { + return Ok(None); + }; + let runtime = runtimes + .iter_mut() + .find(|runtime| runtime.source == *source && runtime.binding.is_same(current_binding)); + let Some(runtime) = runtime else { + return Ok(None); + }; + runtime.bind_if_current(hub)?; + Ok(runtime.is_bound().then_some(runtime)) +} + +fn handle_exact_commands( + command_rx: &mpsc::Receiver, + runtimes: &mut Vec, + exact: &MacosExactPublicationShared, +) { + while let Ok(command) = command_rx.try_recv() { + match command { + WorkerCommand::PrepareExact { + ticket, + cancelled, + completion, + } => { + if cancelled.load(Ordering::Acquire) { + let _ = completion.send(Err(anyhow!( + "macOS exact publication preparation was cancelled" + ))); + continue; + } + let source = exact.source(); + match prepare_macos_exact_runtime(ticket, source.as_ref(), exact) { + Ok((token, runtime)) if !cancelled.load(Ordering::Acquire) => { + if let Some((runtime, owned_source)) = runtime { + exact.register_owned_source(owned_source); + runtimes.push(runtime); + } + if completion.send(Ok(token)).is_err() { + reap_macos_exact_runtimes(runtimes, exact); + } + } + Ok((_token, _runtime)) => { + let _ = completion.send(Err(anyhow!( + "macOS exact publication preparation was cancelled" + ))); + } + Err(error) => { + let _ = completion.send(Err(error)); + } + } + } + WorkerCommand::ReapExact { completion } => { + reap_macos_exact_runtimes(runtimes, exact); + if let Some(completion) = completion { + let _ = completion.send(Ok(())); + } + } + } + } +} + fn run_worker( mut prepared: PreparedWorker, mailbox: MacosFrameMailbox, publication: Arc>, + exact: Arc, worker_generation: u64, target_fps: u32, status_session: SourceSessionSlot, stop: Arc, control: Arc, + command_rx: mpsc::Receiver, ) -> anyhow::Result<()> { - let source_id = CaptureSourceId::new(Arc::::from("macos:session"))?; let mut topology = TopologyState::default(); + let mut resources = ResourceState::default(); + let mut exact_runtimes = Vec::new(); while !stop.load(Ordering::Acquire) { + handle_exact_commands(&command_rx, &mut exact_runtimes, &exact); let Some(delivery) = mailbox.wait_latest_while(WORKER_WAIT, || !stop.load(Ordering::Acquire)) else { @@ -582,10 +1451,13 @@ fn run_worker( Ok(MacosFrameEvent::Frame(frame)) => { publish_frame( &mut prepared, - *frame, - &source_id, + Arc::from(frame), + capture_source_id(control.selection())?, &mut topology, + &mut resources, &publication, + &exact, + &mut exact_runtimes, worker_generation, target_fps, &status_session, @@ -600,6 +1472,9 @@ fn run_worker( Ok(MacosFrameEvent::RecoverableError(_)) => {} } } + exact.replace_source(None); + exact.clear_owned_sources(); + exact_runtimes.clear(); prepared.analyzer.stop(); Ok(()) } @@ -607,16 +1482,52 @@ fn run_worker( #[allow(clippy::too_many_arguments)] fn publish_frame( prepared: &mut PreparedWorker, - frame: MacosCaptureFrame, - source_id: &CaptureSourceId, + frame: Arc, + source_id: CaptureSourceId, topology: &mut TopologyState, + resources: &mut ResourceState, publication: &Mutex, + exact: &MacosExactPublicationShared, + exact_runtimes: &mut [MacosExactRuntime], worker_generation: u64, target_fps: u32, status_session: &SourceSessionSlot, control: &Arc, ) -> anyhow::Result<()> { let extent = PixelExtent::new(frame.storage_extent.width, frame.storage_extent.height)?; + let captured_at = control.captured_at(frame.display_time)?; + let fresh_until = captured_at + .checked_add(Duration::from_nanos( + 2_000_000_000_u64.div_ceil(u64::from(target_fps)), + )) + .ok_or_else(|| anyhow!("macOS capture freshness deadline overflow"))?; + let topology_generation = topology.observe(&frame)?; + let resource_generation = resources.observe(&frame)?; + let source = MacosPublicationSource::from_frame( + source_id.clone(), + topology_generation, + resource_generation, + &frame, + )?; + exact.replace_source(Some(source.clone())); + let exact_delivery = publish_macos_native_exact( + &frame, + captured_at, + fresh_until, + &source, + exact, + exact_runtimes, + )?; + if exact_delivery.native && !exact_delivery.cpu { + if lock(publication).worker_generation == worker_generation { + lock(publication).latest = None; + } + if let Some(status) = status_session.load() { + status.record_sample(captured_at, fresh_until, 1)?; + } + return Ok(()); + } + let row_stride = usize::try_from(extent.width()) .ok() .and_then(|width| width.checked_mul(4)) @@ -627,21 +1538,6 @@ fn publish_frame( let mut plane = prepared.plane_pool.try_acquire(byte_len)?; plane.resize(byte_len, 0); frame.convert_bgra8_sdr_to_rgba8(&mut plane, row_stride)?; - let captured_at = control.captured_at(frame.display_time)?; - let fresh_until = captured_at - .checked_add(Duration::from_nanos( - 2_000_000_000_u64.div_ceil(u64::from(target_fps)), - )) - .ok_or_else(|| anyhow!("macOS capture freshness deadline overflow"))?; - let topology_generation = topology.observe(&frame)?; - let geometry = super::CaptureGeometry::new( - capture_origin(&frame)?, - extent, - extent, - CaptureRotation::Identity, - None, - SourceScale::ONE, - )?; let cursor = CaptureCursor { visible: frame.cursor_composed, position: None, @@ -675,14 +1571,14 @@ fn publish_frame( .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; let capture = CaptureFrame::::new( CaptureFrameMetadata { - source_id: source_id.clone(), + source_id, topology_generation, session_generation: frame.epoch, sequence, captured_at, fresh_until, - geometry, - colorimetry: super::CaptureColorimetry::SRGB, + geometry: source.geometry, + colorimetry: CaptureColorimetry::SRGB, cursor, }, CaptureStorage::Cpu(CpuCaptureStorage::from_owner( @@ -693,6 +1589,7 @@ fn publish_frame( )), damage, )?; + publish_macos_cpu_exact(&capture, &source, exact, exact_runtimes)?; let snapshot = analyze_screen_frame(&mut prepared.analyzer, capture)?; if snapshot.geometry_frame().metadata().topology_generation != topology_generation { return Err(anyhow!("macOS analysis changed topology generation")); @@ -714,6 +1611,113 @@ fn publish_frame( Ok(()) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct MacosExactDelivery { + native: bool, + cpu: bool, +} + +fn publish_macos_native_exact( + frame: &Arc, + captured_at: Instant, + fresh_until: Instant, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], +) -> anyhow::Result { + let Some(hub) = exact.hub() else { + return Ok(MacosExactDelivery::default()); + }; + let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub)? else { + return Ok(MacosExactDelivery::default()); + }; + let delivery = MacosExactDelivery { + native: !runtime.native_routes.is_empty(), + cpu: runtime.fanout.is_some(), + }; + let published_at = Instant::now(); + if published_at > fresh_until { + return Ok(delivery); + } + let native_sequence = frame + .sequence + .checked_add(1) + .and_then(NonZeroU64::new) + .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; + for route in &mut runtime.native_routes { + if published_at < route.next_publish_at + || route + .last_accepted_sequence + .is_some_and(|accepted| frame.sequence <= accepted) + { + continue; + } + let publisher = route + .publisher + .as_ref() + .ok_or_else(|| anyhow!("macOS native route has no committed publisher"))?; + let surface = PlatformGpuSurface::new( + PlatformGpuApi::Metal, + u64::from(frame.surface.iosurface_id), + route.descriptor.geometry().output_extent(), + route.descriptor.physical().target_pixel_format(), + Arc::clone(frame), + )?; + let surface = route + .target + .retain_on_surface_with_capture_allocation(surface, route.capture_lifetime.clone())?; + let metadata = ScreenPublicationMetadata::try_new( + source.epoch.clone(), + publisher.plan_generation(), + native_sequence, + captured_at, + published_at, + fresh_until, + ScreenPublicationHealth::Healthy, + )?; + let payload = ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new( + ScreenPublicationColorimetry::new( + route.descriptor.physical().color_pipeline().output(), + ), + &surface, + )); + match hub.publish(publisher, payload, &metadata) { + Ok(_) => { + route.last_accepted_sequence = Some(frame.sequence); + route.next_publish_at = route + .pacer + .advance_deadline(route.next_publish_at, published_at)?; + } + Err(ScreenPublicationHubError::PublicationPressure { .. }) => {} + Err(error) => return Err(error.into()), + } + } + Ok(delivery) +} + +fn publish_macos_cpu_exact( + frame: &CaptureFrame, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], +) -> anyhow::Result<()> { + let Some(hub) = exact.hub() else { + return Ok(()); + }; + let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub)? else { + return Ok(()); + }; + if let Some(fanout) = runtime.fanout.as_mut() { + fanout.publish_due( + &hub, + Some(frame), + Instant::now(), + ScreenPublicationHealth::Healthy, + )?; + } + Ok(()) +} + #[derive(Default)] struct TopologyState { descriptor: Option, @@ -734,6 +1738,57 @@ impl TopologyState { } } +#[derive(Default)] +struct ResourceState { + descriptor: Option, + generation: u64, +} + +impl ResourceState { + fn observe(&mut self, frame: &MacosCaptureFrame) -> anyhow::Result { + let descriptor = ResourceDescriptor::from_frame(frame); + if self.descriptor.as_ref() != Some(&descriptor) { + self.generation = self + .generation + .checked_add(1) + .ok_or_else(|| anyhow!("macOS resource generation exhausted"))?; + self.descriptor = Some(descriptor); + } + Ok(self.generation) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResourceDescriptor { + width: u32, + height: u32, + pixel_format: MacosCapturePixelFormat, + planes: Vec<(u32, u32, u32, usize, u64)>, +} + +impl ResourceDescriptor { + fn from_frame(frame: &MacosCaptureFrame) -> Self { + Self { + width: frame.storage_extent.width, + height: frame.storage_extent.height, + pixel_format: frame.pixel_format, + planes: frame + .planes + .iter() + .map(|plane| { + ( + plane.index, + plane.extent.width, + plane.extent.height, + plane.bytes_per_row, + plane.length_bytes, + ) + }) + .collect(), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] struct TopologyDescriptor { width: u32, @@ -763,6 +1818,50 @@ impl TopologyDescriptor { } } +fn capture_source_id(selection: MacosCaptureSelection) -> anyhow::Result { + let source: Arc = match selection { + MacosCaptureSelection::Display { source_id } => source_id, + MacosCaptureSelection::SessionScoped { content_style } => Arc::from(match content_style { + MacosCaptureContentStyle::Window => "macos:session:window", + MacosCaptureContentStyle::MultipleWindows => "macos:session:multiple-windows", + MacosCaptureContentStyle::Application => "macos:session:application", + MacosCaptureContentStyle::MultipleApplications => "macos:session:multiple-applications", + MacosCaptureContentStyle::Mixed => "macos:session:mixed", + }), + MacosCaptureSelection::None => Arc::from("macos:session"), + }; + Ok(CaptureSourceId::new(source)?) +} + +fn capture_colorimetry(color: MacosCaptureColorimetry) -> anyhow::Result { + let color_space = match color.primaries { + MacosColorPrimaries::Srgb => CaptureColorSpace::Srgb, + MacosColorPrimaries::DisplayP3 => CaptureColorSpace::DisplayP3, + MacosColorPrimaries::Rec2020 => CaptureColorSpace::Rec2020, + }; + let (transfer_function, dynamic_range) = match color.transfer { + MacosTransferFunction::Srgb => { + (CaptureTransferFunction::Srgb, CaptureDynamicRange::Standard) + } + MacosTransferFunction::Linear => ( + CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard, + ), + MacosTransferFunction::Pq => (CaptureTransferFunction::Pq, CaptureDynamicRange::High), + MacosTransferFunction::Hlg => (CaptureTransferFunction::Hlg, CaptureDynamicRange::High), + MacosTransferFunction::Rec709 | MacosTransferFunction::Rec2020 => ( + CaptureTransferFunction::Unknown, + CaptureDynamicRange::Standard, + ), + }; + Ok(CaptureColorimetry::new( + color_space, + transfer_function, + Some(dynamic_range), + None, + )?) +} + fn capture_origin(frame: &MacosCaptureFrame) -> anyhow::Result { let rect = frame .geometry @@ -941,3 +2040,250 @@ impl MacosScreenCaptureFixture { *lock(&self.control.selection) = selection; } } + +#[cfg(all(test, feature = "macos-capture-fixtures"))] +mod tests { + use super::*; + use crate::input::screen::{ + InputPublicationDemandRevision, ScreenAdmissionCapacity, ScreenAspectPolicy, + ScreenExtentRequest, ScreenInputGraphGeneration, ScreenNativeExecutionTarget, + ScreenNativeExecutionTargetId, ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, + ScreenPlanBuilder, ScreenProcessingProfile, ScreenProcessingProfileConfig, + ScreenPublicationKind, ScreenPublicationRequest, + }; + use hypercolor_macos_capture::{ + MacosAttachment, MacosCaptureSurface, MacosColorRange, MacosFrameDecoder, MacosPixelExtent, + MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, + MacosRawFrameAttachments, + }; + + const BGRA8: u32 = 0x4247_5241; + + #[derive(Debug)] + struct TestPreparedTarget; + + struct TestTargetPreparer; + + impl ScreenNativeTargetPreparer for TestTargetPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + MacosNativeTargetManifest::new(platform.descriptor())?; + Ok(0) + } + + fn prepare( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + MacosNativeTargetManifest::new(platform.descriptor())?; + Ok(ScreenNativeTargetPreparation::new( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(TestPreparedTarget), + ), + 0, + )) + } + } + + fn frame() -> Arc { + let extent = MacosPixelExtent::new(4, 2).expect("fixture extent is valid"); + let surface = MacosCaptureSurface::new_cpu_fixture( + 7, + 32, + 1, + vec![Arc::<[u8]>::from([0_u8, 0, 255, 255].repeat(8))], + ) + .expect("fixture surface is valid"); + let sample = MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: extent, + planes: vec![MacosRawCapturePlane { + index: 0, + extent, + bytes_per_row: 16, + length_bytes: 32, + }], + pixel_format_fourcc: BGRA8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + cursor_composed: false, + surface, + }), + attachments: MacosRawFrameAttachments { + status: MacosAttachment::Value(0), + display_time: MacosAttachment::Value(1_000), + display_scale_factor: MacosAttachment::Value(1.0), + content_scale: MacosAttachment::Value(1.0), + content_rect: MacosAttachment::Value( + MacosPointRect::new(0.0, 0.0, 4.0, 2.0).expect("fixture content rect is valid"), + ), + dirty_rects: MacosAttachment::Missing, + screen_rect: MacosAttachment::Missing, + bounding_rect: MacosAttachment::Missing, + }, + }; + let mut decoder = MacosFrameDecoder::new(7); + let MacosFrameEvent::Frame(frame) = decoder.decode(sample).expect("fixture frame decodes") + else { + panic!("complete fixture sample produces a frame"); + }; + Arc::from(frame) + } + + fn source(frame: &MacosCaptureFrame) -> MacosPublicationSource { + MacosPublicationSource::from_frame( + CaptureSourceId::new("display:test").expect("fixture source id is valid"), + 3, + 5, + frame, + ) + .expect("fixture source resolves") + } + + fn target() -> ScreenNativeExecutionTarget { + ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new(NonZeroU64::new(11).expect("nonzero target")), + PlatformGpuApi::Metal, + ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(91), + NonZeroU32::new(16_384).expect("nonzero texture limit"), + Arc::new(TestTargetPreparer), + ) + } + + fn native_demand(target: &ScreenNativeExecutionTarget) -> RegisteredScreenBranchDemand { + RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ScreenExtentRequest::Native, + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::new( + ScreenProcessingProfileConfig::exact_encoded_identity( + CapturePixelFormat::Bgra8, + ), + )), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + ) + } + + #[test] + fn native_publication_commits_owner_backed_metal_surface() { + let frame = frame(); + let source = source(&frame); + let demand = native_demand(&target()); + let resolved = resolve_macos_publication_branch(&source, &demand) + .expect("native demand resolves") + .expect("configured macOS source owns native demand"); + assert!(matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + )); + + let exact = MacosExactPublicationShared::default(); + exact.replace_source(Some(source.clone())); + let mut builder = ScreenPlanBuilder::new(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let revision = InputPublicationDemandRevision::new(1); + let graph = ScreenInputGraphGeneration::new(1); + let mut preparing = builder + .prepare( + [resolved], + None, + revision, + graph, + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("native candidate plan prepares"); + let ticket = preparing + .worker_ticket(&source.epoch.source_id) + .expect("macOS source owns its worker ticket"); + let (token, runtime) = prepare_macos_exact_runtime(ticket, Some(&source), &exact) + .expect("native runtime prepares"); + let (runtime, owned_source) = runtime.expect("native branch owns a runtime"); + exact.register_owned_source(owned_source); + let mut runtimes = vec![runtime]; + preparing + .acknowledge(token) + .expect("native worker token matches candidate"); + let armed = preparing + .arm(builder.current().generation(), revision, graph) + .unwrap_or_else(|failure| panic!("native plan arms: {}", failure.error())); + let committed = builder + .commit(armed, revision, graph) + .unwrap_or_else(|failure| panic!("native plan commits: {}", failure.error())); + let (_, retirement) = committed.into_parts(); + retirement + .try_reclaim() + .expect("initial plan has no retired readers"); + + let now = Instant::now(); + publish_macos_native_exact( + &frame, + now, + now + Duration::from_secs(1), + &source, + &exact, + &mut runtimes, + ) + .expect("native frame publishes"); + let hub = exact.hub().expect("test hub remains installed"); + let (_, lease) = hub.observe_matching_lease(|_| true); + let publication = lease + .expect("committed native branch has a lease") + .read() + .expect("native branch has a publication"); + assert_eq!(publication.native_sequence(), NonZeroU64::MIN); + let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { + panic!("macOS native branch publishes a GPU surface"); + }; + let surface = payload.surface(); + assert_eq!(surface.api(), &PlatformGpuApi::Metal); + assert_eq!(surface.handle_id(), 7); + assert_eq!(surface.format(), CapturePixelFormat::Bgra8); + assert!(surface.owner::().is_some()); + assert!(surface.retained_owner::().is_some()); + assert!(surface.resource_lifetime().is_some()); + assert!(surface.capture_resource_lifetime().is_some()); + } + + #[test] + fn reduced_rgba_demand_falls_back_until_native_reducer_exists() { + let frame = frame(); + let source = source(&frame); + let demand = RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target()), + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + super::super::ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::default()), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + ); + let resolved = resolve_macos_publication_branch(&source, &demand) + .expect("reduced demand resolves") + .expect("configured macOS source owns reduced demand"); + assert!(matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::Cpu + )); + } +} diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index afcf041f0..36e1ed1c4 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -75,7 +75,8 @@ pub use frame::{ CapturePlanePool, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureStageKind, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, GeometryNormalizedCaptureSurface, KnownCaptureColorimetry, MoveRegion, PhysicalOrigin, PixelExtent, PixelRect, PlatformGpuApi, - PlatformGpuSurface, PooledCapturePlane, RawCaptureSurface, SourceScale, + PlatformGpuSurface, PlatformGpuSurfaceOwner, PooledCapturePlane, RawCaptureSurface, + SourceScale, }; pub use hub::{ PreparedScreenPublication, ScreenBranchDeliveryLifecycle, ScreenBranchDeliveryState, @@ -93,7 +94,7 @@ pub use ledger::{ }; #[cfg(feature = "macos-capture-fixtures")] pub use macos::MacosScreenCaptureFixture; -pub use macos::MacosScreenCaptureInput; +pub use macos::{MacosNativeTargetManifest, MacosScreenCaptureInput}; pub use materialize::{ CpuSurfaceMaterializationError, CpuZoneMaterializationError, PreparedCpuSurfaceMaterializer, PreparedCpuZoneMaterializer, StagedCpuZonePublication, From a5609ecb3b548b3fc6b35e65538f971580959518 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:22:17 -0700 Subject: [PATCH 054/144] feat(macos): add frame diagnostic tool The production-boundary example captures a bounded number of complete ScreenCaptureKit frames and prints only redacted descriptors, canonical attachments, color, IOSurface allocation, timing, and drop counters. Authorization and picker presentation require explicit flags. Pixel export requires an explicit path, accepts one SDR BGRA frame, and emits a privacy warning before opening the destination. Link ColorSync at the raw display UUID declaration so standalone consumers resolve the owning framework without a local workaround. Co-Authored-By: Nova (OpenAI Codex) --- .../examples/dump_macos_frame.rs | 561 ++++++++++++++++++ crates/hypercolor-macos-capture/src/native.rs | 1 + 2 files changed, 562 insertions(+) create mode 100644 crates/hypercolor-macos-capture/examples/dump_macos_frame.rs diff --git a/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs new file mode 100644 index 000000000..222361b18 --- /dev/null +++ b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs @@ -0,0 +1,561 @@ +//! Inspect live ScreenCaptureKit frames at Hypercolor's production boundary. +//! +//! The default mode prints metadata only. Authorization prompts, Apple's +//! picker, and pixel export each require an explicit command-line flag. + +use std::ffi::OsString; +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +use std::fmt::Write as _; +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +use std::io::{BufWriter, Write}; +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +use hypercolor_macos_capture::MacosCaptureFrame; +use hypercolor_macos_capture::MacosCaptureSelector; +#[cfg(target_os = "macos")] +use hypercolor_macos_capture::MacosFrameDropReason; + +const DEFAULT_FRAME_COUNT: usize = 1; +const MAX_FRAME_COUNT: usize = 600; +const DEFAULT_TIMEOUT_SECONDS: u64 = 30; +const MAX_TIMEOUT_SECONDS: u64 = 300; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolOptions { + frame_count: usize, + timeout: Duration, + selector: MacosCaptureSelector, + authorize: bool, + picker: bool, + output: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ToolCommand { + Run(ToolOptions), + Help, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +struct FrameTiming { + since_start_us: u128, + delivery_latency_us: Option, + inter_frame_us: Option, +} + +fn main() { + let command = match parse_args(std::env::args_os().skip(1)) { + Ok(command) => command, + Err(error) => { + eprintln!("{error}\n\n{}", usage()); + std::process::exit(2); + } + }; + if command == ToolCommand::Help { + println!("{}", usage()); + return; + } + + #[cfg(not(target_os = "macos"))] + { + let _ = command; + eprintln!("dump_macos_frame requires macOS 15.2 or newer"); + std::process::exit(1); + } + + #[cfg(target_os = "macos")] + if let ToolCommand::Run(options) = command + && let Err(error) = run_macos(options) + { + eprintln!("capture diagnostic failed: {error}"); + std::process::exit(1); + } +} + +fn usage() -> &'static str { + "Usage: cargo run -p hypercolor-macos-capture --example \ +dump_macos_frame -- [OPTIONS]\n\ +\n\ +Options:\n\ + --frames COUNT Complete frames to inspect, 1 through 600 (default: 1)\n\ + --timeout-seconds N Total capture budget, 1 through 300 (default: 30)\n\ + --source SELECTOR auto, primary_display, display:, or session_scoped\n\ + --authorize Explicitly request Screen Recording authorization\n\ + --picker Explicitly present Apple's system content picker\n\ + --output PATH Export one SDR BGRA frame as RGBA PAM pixels\n\ + -h, --help Print this help\n\ +\n\ +Metadata-only mode is the default. --output requires --frames 1 and prints a\n\ +privacy warning before touching the destination path." +} + +fn parse_args(args: impl IntoIterator) -> Result { + let mut options = ToolOptions { + frame_count: DEFAULT_FRAME_COUNT, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECONDS), + selector: MacosCaptureSelector::Auto, + authorize: false, + picker: false, + output: None, + }; + let mut args = args.into_iter(); + while let Some(argument) = args.next() { + let argument = argument + .to_str() + .ok_or_else(|| "option names must be valid UTF-8".to_owned())?; + match argument { + "-h" | "--help" => return Ok(ToolCommand::Help), + "--frames" => { + options.frame_count = parse_bounded(args.next(), "--frames", 1, MAX_FRAME_COUNT)?; + } + "--timeout-seconds" => { + let seconds = + parse_bounded(args.next(), "--timeout-seconds", 1, MAX_TIMEOUT_SECONDS)?; + options.timeout = Duration::from_secs(seconds); + } + "--source" => { + let source = next_utf8(&mut args, "--source")?; + options.selector = MacosCaptureSelector::parse(&source) + .map_err(|_| format!("invalid --source value: {source}"))?; + } + "--authorize" => options.authorize = true, + "--picker" => options.picker = true, + "--output" => { + let path = args + .next() + .ok_or_else(|| "--output requires a path".to_owned())?; + if path.is_empty() { + return Err("--output requires a nonempty path".to_owned()); + } + options.output = Some(PathBuf::from(path)); + } + unknown => return Err(format!("unknown option: {unknown}")), + } + } + if options.output.is_some() && options.frame_count != 1 { + return Err("--output requires --frames 1 to prevent implicit file naming".to_owned()); + } + if options.selector == MacosCaptureSelector::SessionScoped && !options.picker { + return Err("session_scoped capture requires the explicit --picker action".to_owned()); + } + Ok(ToolCommand::Run(options)) +} + +fn next_utf8(args: &mut impl Iterator, option: &str) -> Result { + args.next() + .ok_or_else(|| format!("{option} requires a value"))? + .into_string() + .map_err(|_| format!("{option} requires a UTF-8 value")) +} + +fn parse_bounded( + value: Option, + option: &str, + minimum: T, + maximum: T, +) -> Result +where + T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, +{ + let value = value.ok_or_else(|| format!("{option} requires a value"))?; + let value = value + .to_str() + .ok_or_else(|| format!("{option} requires a UTF-8 integer"))?; + let parsed = value + .parse::() + .map_err(|_| format!("{option} requires an integer"))?; + if parsed < minimum || parsed > maximum { + return Err(format!("{option} must be between {minimum} and {maximum}")); + } + Ok(parsed) +} + +#[cfg(target_os = "macos")] +fn run_macos(options: ToolOptions) -> Result<(), String> { + use std::time::Instant; + + use hypercolor_macos_capture::{ + MacosCaptureCadence, MacosDisplayClock, MacosFrameEvent, MacosScreenCaptureSession, + MacosStreamRequest, + }; + + let request = MacosStreamRequest::new(MacosCaptureCadence::NativeRefresh, true) + .map_err(|_| "native-refresh capture configuration was rejected".to_owned())?; + let session = MacosScreenCaptureSession::new(request, options.selector.clone()) + .map_err(|_| "could not create the production ScreenCaptureKit session".to_owned())?; + + if options.authorize { + println!("user action: requesting Screen Recording authorization"); + let state = session.request_authorization(); + println!("authorization state: {state:?}"); + } + if !MacosScreenCaptureSession::screen_authorized() { + return Err( + "Screen Recording is not authorized; rerun with --authorize to request it".to_owned(), + ); + } + if options.picker { + println!("user action: presenting Apple's system content picker"); + session + .present_picker() + .map_err(|_| "Apple's content picker could not be presented".to_owned())?; + } + + println!( + "capture source: {}; frame budget: {}; timeout: {}s; pixels: {}", + redacted_selector(&options.selector), + options.frame_count, + options.timeout.as_secs(), + if options.output.is_some() { + "explicit export" + } else { + "metadata only" + } + ); + + let clock = MacosDisplayClock::system().ok(); + let started = Instant::now(); + let deadline = started + options.timeout; + let mut previous_display = None; + let mut captured = 0_usize; + let mailbox = session.mailbox(); + session.set_capture_active(true); + let result = (|| { + while captured < options.frame_count { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!( + "timed out after receiving {captured} of {} complete frames", + options.frame_count + )); + } + let Some(delivery) = mailbox.wait_latest(remaining) else { + continue; + }; + match delivery { + Ok(MacosFrameEvent::Frame(frame)) => { + let now = Instant::now(); + let display = clock + .as_ref() + .and_then(|clock| clock.timestamp(frame.display_time).ok()); + let timing = FrameTiming { + since_start_us: now.duration_since(started).as_micros(), + delivery_latency_us: display + .map(|display| now.saturating_duration_since(display).as_micros()), + inter_frame_us: display.zip(previous_display).map(|(display, previous)| { + display.saturating_duration_since(previous).as_micros() + }), + }; + previous_display = display; + captured += 1; + println!("frame {captured}/{}", options.frame_count); + print!("{}", format_frame_metadata(&frame, timing)); + if let Some(path) = options.output.as_deref() { + export_frame_with_warning(&frame, path, |warning| { + eprintln!("{warning}"); + })?; + } + } + Ok(MacosFrameEvent::Lifecycle(state)) => { + println!("lifecycle: {state:?}"); + } + Ok(MacosFrameEvent::RecoverableError(_)) => { + eprintln!("recoverable capture error; frame metadata remains redacted"); + } + Err(_) => { + return Err("capture failed; native error text was redacted".to_owned()); + } + } + } + Ok(()) + })(); + session.stop(); + + let diagnostics = session.diagnostics(); + println!( + "diagnostics: received={} published={} lifecycle={} superseded={} dropped={}", + diagnostics.frames_received, + diagnostics.frames_published, + diagnostics.lifecycle_events, + diagnostics.superseded_deliveries, + diagnostics.total_dropped() + ); + for reason in MacosFrameDropReason::ALL { + let count = diagnostics.dropped(reason); + if count != 0 { + println!(" dropped.{reason:?}={count}"); + } + } + result +} + +#[cfg(target_os = "macos")] +fn redacted_selector(selector: &MacosCaptureSelector) -> &'static str { + match selector { + MacosCaptureSelector::Auto => "auto", + MacosCaptureSelector::PrimaryDisplay => "primary_display", + MacosCaptureSelector::Display { .. } => "explicit_display", + MacosCaptureSelector::SessionScoped => "session_scoped", + } +} + +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +fn format_frame_metadata(frame: &MacosCaptureFrame, timing: FrameTiming) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + " descriptor: epoch={} sequence={} extent={}x{} format={:?} cursor_composed={}", + frame.epoch, + frame.sequence, + frame.storage_extent.width, + frame.storage_extent.height, + frame.pixel_format, + frame.cursor_composed + ); + for plane in &*frame.planes { + let _ = writeln!( + output, + " plane[{}]: extent={}x{} stride={} length={}", + plane.index, + plane.extent.width, + plane.extent.height, + plane.bytes_per_row, + plane.length_bytes + ); + } + let _ = writeln!( + output, + " attachments: status=complete display_time={} display_scale={} content_scale={}", + frame.display_time, + frame.geometry.display_scale_factor.get(), + frame.geometry.content_scale.get() + ); + let _ = writeln!( + output, + " content_rect_points={:?} content_rect_pixels={:?}", + frame.geometry.content_rect_points, frame.geometry.content_rect_pixels + ); + let _ = writeln!( + output, + " screen_rect_points={:?} bounding_rect_points={:?} bounding_rect_pixels={:?}", + frame.geometry.screen_rect_points, + frame.geometry.bounding_rect_points, + frame.geometry.bounding_rect_pixels + ); + let _ = writeln!(output, " dirty_rects={:?}", frame.damage); + let _ = writeln!( + output, + " color: primaries={:?} transfer={:?} matrix={:?} range={:?} chroma={:?}", + frame.color.primaries, + frame.color.transfer, + frame.color.matrix, + frame.color.range, + frame.color.chroma_location + ); + let _ = writeln!( + output, + " iosurface: id={} allocation_bytes={}", + frame.surface.iosurface_id, frame.surface.allocation_bytes + ); + let _ = writeln!( + output, + " timing: since_start_us={} delivery_latency_us={} inter_frame_us={}", + timing.since_start_us, + optional_micros(timing.delivery_latency_us), + optional_micros(timing.inter_frame_us) + ); + output +} + +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +fn optional_micros(value: Option) -> String { + value.map_or_else(|| "unavailable".to_owned(), |value| value.to_string()) +} + +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +fn export_frame_with_warning( + frame: &MacosCaptureFrame, + path: &Path, + warn: impl FnOnce(&str), +) -> Result<(), String> { + let warning = format!( + "PRIVACY WARNING: writing captured screen pixels to {}; the image may reveal private content", + path.display() + ); + warn(&warning); + + let row_bytes = usize::try_from(frame.storage_extent.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| "pixel export dimensions overflowed".to_owned())?; + let length = row_bytes + .checked_mul(frame.storage_extent.height as usize) + .ok_or_else(|| "pixel export length overflowed".to_owned())?; + let mut rgba = vec![0_u8; length]; + frame + .convert_bgra8_sdr_to_rgba8(&mut rgba, row_bytes) + .map_err(|_| "pixel export supports SDR BGRA frames only".to_owned())?; + + let file = std::fs::File::create(path) + .map_err(|error| format!("could not create explicit output path: {error}"))?; + let mut output = BufWriter::new(file); + write!( + output, + "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", + frame.storage_extent.width, frame.storage_extent.height + ) + .map_err(|error| format!("could not write PAM header: {error}"))?; + output + .write_all(&rgba) + .map_err(|error| format!("could not write captured pixels: {error}"))?; + output + .flush() + .map_err(|error| format!("could not flush captured pixels: {error}")) +} + +#[cfg(all(test, feature = "capture-fixtures"))] +mod tests { + use std::sync::Arc; + + use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCapturePlane, MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + }; + + use super::{ + FrameTiming, ToolCommand, export_frame_with_warning, format_frame_metadata, parse_args, + }; + + #[test] + fn defaults_are_bounded_and_metadata_only() { + let ToolCommand::Run(options) = parse_args(Vec::new()).expect("defaults should parse") + else { + panic!("defaults should run the diagnostic"); + }; + assert_eq!(options.frame_count, 1); + assert_eq!(options.timeout.as_secs(), 30); + assert!(!options.authorize); + assert!(!options.picker); + assert!(options.output.is_none()); + } + + #[test] + fn parser_requires_explicit_bounded_pixel_export() { + assert!(parse_args(["--frames".into(), "0".into()]).is_err()); + assert!(parse_args(["--frames".into(), "601".into()]).is_err()); + assert!( + parse_args([ + "--frames".into(), + "2".into(), + "--output".into(), + "capture.pam".into(), + ]) + .is_err() + ); + assert!(parse_args(["--source".into(), "session_scoped".into()]).is_err()); + assert!( + parse_args([ + "--source".into(), + "session_scoped".into(), + "--picker".into(), + ]) + .is_ok() + ); + } + + #[test] + fn metadata_contains_no_titles_pixels_or_paths() { + let metadata = format_frame_metadata( + &fixture_frame(), + FrameTiming { + since_start_us: 10, + delivery_latency_us: Some(2), + inter_frame_us: None, + }, + ); + assert!(metadata.contains("allocation_bytes=4")); + assert!(metadata.contains("status=complete")); + assert!(!metadata.contains("window_title")); + assert!(!metadata.contains("application_name")); + assert!(!metadata.contains("capture.pam")); + assert!(!metadata.contains("[10, 20, 30, 255]")); + } + + #[test] + fn export_warns_before_touching_the_explicit_path() { + let path = std::env::temp_dir().join(format!( + "hypercolor-dump-macos-frame-{}-{}.pam", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should follow Unix epoch") + .as_nanos() + )); + let mut warned = false; + export_frame_with_warning(&fixture_frame(), &path, |warning| { + assert!(!path.exists()); + assert!(warning.starts_with("PRIVACY WARNING:")); + assert!(warning.contains(&path.display().to_string())); + warned = true; + }) + .expect("explicit SDR export should succeed"); + assert!(warned); + let bytes = std::fs::read(&path).expect("export should create the explicit path"); + assert!(bytes.starts_with(b"P7\nWIDTH 1\nHEIGHT 1\n")); + assert!(bytes.ends_with(&[30, 20, 10, 255])); + std::fs::remove_file(&path).expect("fixture output should be removable"); + } + + fn fixture_frame() -> MacosCaptureFrame { + let extent = MacosPixelExtent::new(1, 1).expect("fixture extent should be valid"); + MacosCaptureFrame { + epoch: 7, + sequence: 3, + display_time: 99, + storage_extent: extent, + planes: Arc::from([MacosCapturePlane { + index: 0, + extent, + bytes_per_row: 4, + length_bytes: 4, + }]), + pixel_format: MacosCapturePixelFormat::Bgra8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0) + .expect("display scale should be valid"), + content_scale: MacosScale::new(1.0).expect("content scale should be valid"), + content_rect_points: MacosPointRect::new(0.0, 0.0, 1.0, 1.0) + .expect("content rect should be valid"), + content_rect_pixels: MacosPixelRect::new(0, 0, 1, 1) + .expect("pixel rect should be valid"), + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([ + MacosPixelRect::new(0, 0, 1, 1).expect("damage rect should be valid") + ]), + cursor_composed: true, + surface: MacosCaptureSurface::new_cpu_fixture( + 1, + 4, + 11, + vec![Arc::from([10_u8, 20, 30, 255])], + ) + .expect("fixture surface should be valid"), + } + } +} diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 929807aaa..03aa80b0f 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -1185,6 +1185,7 @@ fn display_source_id(display_id: CGDirectDisplayID) -> Result Option> { + #[link(name = "ColorSync", kind = "framework")] unsafe extern "C-unwind" { fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> Option>; } From 1c361bb48828700b2c1b8043aa29d54d75e28499 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:24:42 -0700 Subject: [PATCH 055/144] feat(macos): import native screen surfaces in compositor Register the compositor's Metal device as an exact native screen target and validate descriptor-bound capture generations before publication. Import retained ScreenCaptureKit IOSurfaces through the macOS bridge and keep capture, target, and allocation owners alive through GPU cache use. Route copy-incompatible BGRA textures through the shader copy path. Co-Authored-By: Nova (GPT-5 Codex) --- Cargo.lock | 2 + crates/hypercolor-daemon/Cargo.toml | 19 +- .../src/render_thread/frame_composer.rs | 27 +- .../src/render_thread/producer_queue.rs | 109 +++++- .../src/render_thread/sparkleflinger/gpu.rs | 369 +++++++++++++++++- .../sparkleflinger/gpu/compositor.rs | 52 ++- .../sparkleflinger/gpu/screen_upload.rs | 2 + .../sparkleflinger/gpu/source.rs | 57 ++- .../render_thread/sparkleflinger/gpu/tests.rs | 112 +++++- .../src/render_thread/sparkleflinger/mod.rs | 32 +- 10 files changed, 724 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 451584bf0..9023b699a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5071,6 +5071,8 @@ dependencies = [ "hypercolor-driver-api", "hypercolor-driver-builtin", "hypercolor-leptos-ext", + "hypercolor-macos-capture", + "hypercolor-macos-gpu-interop", "hypercolor-network", "hypercolor-platform-fs", "hypercolor-types", diff --git a/crates/hypercolor-daemon/Cargo.toml b/crates/hypercolor-daemon/Cargo.toml index d1d319b29..1e485eb51 100644 --- a/crates/hypercolor-daemon/Cargo.toml +++ b/crates/hypercolor-daemon/Cargo.toml @@ -31,8 +31,20 @@ wgpu = [ "dep:hypercolor-windows-capture", "dep:hypercolor-windows-gpu-interop", ] +screen-capture = [ + "wgpu", + "dep:hypercolor-macos-capture", + "dep:hypercolor-macos-gpu-interop", + "hypercolor-macos-gpu-interop/screen-capture", +] servo-gpu-import = ["servo", "wgpu", "hypercolor-core/servo-gpu-import"] -default = ["builtin-drivers", "wgpu", "servo", "servo-gpu-import"] +default = [ + "builtin-drivers", + "wgpu", + "screen-capture", + "servo", + "servo-gpu-import", +] servo = ["hypercolor-core/servo"] [dependencies] @@ -85,9 +97,14 @@ sysinfo = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] dispatch2 = "0.3.1" +hypercolor-macos-capture = { workspace = true, optional = true } +hypercolor-macos-gpu-interop = { workspace = true, optional = true } objc2-core-foundation = { workspace = true, features = ["std", "CFRunLoop"] } sysinfo = { workspace = true } +[target.'cfg(target_os = "macos")'.dev-dependencies] +hypercolor-macos-capture = { workspace = true, features = ["capture-fixtures"] } + [target.'cfg(target_os = "windows")'.dependencies] hypercolor-windows-capture = { workspace = true, optional = true } hypercolor-windows-gpu-interop = { workspace = true, features = ["screen-capture"], optional = true } diff --git a/crates/hypercolor-daemon/src/render_thread/frame_composer.rs b/crates/hypercolor-daemon/src/render_thread/frame_composer.rs index 26f67a271..1a32bd219 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_composer.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_composer.rs @@ -477,7 +477,13 @@ impl ComposeContext<'_> { fn latch_screen_frame(&mut self) -> Option { let native_submitted = { - #[cfg(all(feature = "wgpu", target_os = "windows"))] + #[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ))] { self.inputs.screen_publication.as_ref().is_some_and( |publication| match self @@ -518,7 +524,13 @@ impl ComposeContext<'_> { }, ) } - #[cfg(not(all(feature = "wgpu", target_os = "windows")))] + #[cfg(not(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + )))] { false } @@ -719,7 +731,16 @@ pub(super) fn synchronize_screen_plan_generation( changed } -#[cfg(any(test, all(feature = "wgpu", target_os = "windows")))] +#[cfg(any( + test, + all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ) +))] fn native_copy_failure_retains_last_frame(screen_queue: &ProducerQueue) -> bool { screen_queue.has_latest() } diff --git a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs index bb6a7150a..7849c2c79 100644 --- a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs +++ b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs @@ -1,11 +1,23 @@ #[cfg(feature = "servo-gpu-import")] use hypercolor_core::effect::ImportedEffectFrame; -#[cfg(all(feature = "wgpu", target_os = "windows"))] +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +use hypercolor_core::input::screen::PlatformGpuSurfaceOwner; +#[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) +))] use hypercolor_core::input::screen::ScreenResourceLifetime; use hypercolor_core::input::screen::{ CapturePixelFormat, ScreenBranchPayload, ScreenBranchPublication, ScreenSurfacePayload, }; use hypercolor_core::types::canvas::{Canvas, PublishedSurface}; +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +use hypercolor_macos_capture::MacosCaptureFrame; +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +use hypercolor_macos_gpu_interop::ImportedMacosScreenFrame; #[cfg(all(feature = "wgpu", target_os = "windows"))] use hypercolor_windows_gpu_interop::ScreenTextureCopy; use std::sync::Arc; @@ -28,6 +40,8 @@ pub(crate) struct GpuTextureFrame { pub(crate) immutable_lease: Option>, #[cfg(target_os = "windows")] pub(crate) windows_screen_lease: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + pub(crate) macos_screen_lease: Option, } #[cfg(feature = "wgpu")] @@ -55,18 +69,86 @@ impl WindowsScreenTextureLease { _capture_lifetime: capture_lifetime, } } +} +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +#[derive(Clone)] +pub(crate) struct MacosScreenTextureLease { + _imported: ImportedMacosScreenFrame, + _capture_owner: PlatformGpuSurfaceOwner, + _target_owner: PlatformGpuSurfaceOwner< + crate::render_thread::sparkleflinger::gpu::PreparedMacosScreenTarget, + >, + _target_lifetime: ScreenResourceLifetime, +} - pub(crate) const fn target_lifetime(&self) -> &ScreenResourceLifetime { - &self.target_lifetime +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +impl MacosScreenTextureLease { + pub(crate) fn new( + imported: ImportedMacosScreenFrame, + capture_owner: PlatformGpuSurfaceOwner, + target_owner: PlatformGpuSurfaceOwner< + crate::render_thread::sparkleflinger::gpu::PreparedMacosScreenTarget, + >, + target_lifetime: ScreenResourceLifetime, + ) -> Self { + Self { + _imported: imported, + _capture_owner: capture_owner, + _target_owner: target_owner, + _target_lifetime: target_lifetime, + } + } +} +#[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] +impl std::fmt::Debug for MacosScreenTextureLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MacosScreenTextureLease") + .finish_non_exhaustive() } } -#[cfg(all(feature = "wgpu", target_os = "windows"))] +#[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) +))] +#[derive(Debug, Clone)] +#[allow( + dead_code, + reason = "cache lease payloads are retained for ownership rather than inspected" +)] +pub(crate) enum NativeScreenCacheLease { + #[cfg(target_os = "windows")] + Windows(ScreenResourceLifetime), + #[cfg(target_os = "macos")] + Macos(MacosScreenTextureLease), +} + +#[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) +))] impl GpuTextureFrame { - pub(crate) fn screen_target_lifetime(&self) -> Option<&ScreenResourceLifetime> { - self.windows_screen_lease - .as_ref() - .map(WindowsScreenTextureLease::target_lifetime) + pub(crate) fn native_screen_cache_lease(&self) -> Option { + #[cfg(target_os = "windows")] + { + self.windows_screen_lease + .as_ref() + .map(|lease| NativeScreenCacheLease::Windows(lease.target_lifetime.clone())) + } + #[cfg(target_os = "macos")] + { + self.macos_screen_lease + .as_ref() + .cloned() + .map(NativeScreenCacheLease::Macos) + } } } @@ -363,7 +445,16 @@ impl ProducerQueue { self.replace_latest(ProducerSubmission { frame, fresh: true }) } - #[cfg(any(test, all(feature = "wgpu", target_os = "windows")))] + #[cfg(any( + test, + all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ) + ))] pub(crate) const fn has_latest(&self) -> bool { self.latest.is_some() } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index 27c757c0b..4e38a0176 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -1,15 +1,29 @@ -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] use std::alloc::Layout; #[cfg(test)] use std::cell::Cell; use std::collections::HashMap; use std::fmt; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] use std::num::{NonZeroU32, NonZeroU64}; use std::sync::Arc; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use std::sync::Mutex; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] use std::sync::Weak; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicU64, Ordering}; @@ -25,10 +39,25 @@ use hypercolor_core::input::screen::{ ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPhysicalGpuDeviceIdentity, ScreenPlanGeneration, ScreenPublicationKind, ScreenReductionFilter, ScreenResourceApi, }; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use hypercolor_core::input::screen::{ + MacosNativeTargetManifest, PlatformGpuApi, ResolvedScreenPublicationDescriptor, + ScreenBranchPayload, ScreenBranchPublication, ScreenCaptureBackend, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativePreparationPayload, + ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPhysicalGpuDeviceIdentity, + ScreenPublicationKind, ScreenResourceApi, +}; use hypercolor_core::spatial::PreparedZonePlan; use hypercolor_core::types::canvas::{ BYTES_PER_PIXEL, Canvas, PublishedSurface, SurfaceStateCounts, }; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use hypercolor_macos_capture::MacosCaptureFrame; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use hypercolor_macos_gpu_interop::{ + ImportedMacosScreenFrame, MacosScreenBridge as MacosInteropScreenBridge, + MacosScreenStorageIdentity, +}; use hypercolor_types::scene::ZoneId; #[cfg(target_os = "windows")] use hypercolor_windows_capture::{ @@ -48,6 +77,8 @@ use super::{ use crate::render_thread::gpu_device::{ GpuBackendPreference, GpuRenderDevice, texture_format_name, }; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use crate::render_thread::producer_queue::MacosScreenTextureLease; #[cfg(target_os = "windows")] use crate::render_thread::producer_queue::WindowsScreenTextureLease; use crate::render_thread::producer_queue::{ @@ -124,7 +155,10 @@ const MAX_CACHED_PREVIEW_SURFACES: usize = 3; const IMMUTABLE_SCENE_GENERATIONS_IN_FLIGHT: usize = 2; static NEXT_GPU_TEXTURE_STORAGE_ID: AtomicU64 = AtomicU64::new(1); static NEXT_GPU_SURFACE_SET_GENERATION: AtomicU64 = AtomicU64::new(1); -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] static NEXT_SCREEN_TARGET_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -340,6 +374,163 @@ struct PreparedWindowsScreenTarget { storage_id: u64, } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +struct MacosScreenBridge { + interop: MacosInteropScreenBridge, + storage_ids: Mutex>, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +struct MacosScreenTargetPreparer { + bridge: Weak, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +#[derive(Clone)] +pub(crate) struct PreparedMacosScreenTarget { + resource_generation: u64, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +impl MacosScreenBridge { + fn import_frame( + &self, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result<(ImportedMacosScreenFrame, u64)> { + let imported = self + .interop + .import_bgra_frame(device, resource_generation, frame) + .context("failed to import the native macOS screen publication")?; + let identity = imported.storage_identity(); + let mut storage_ids = self + .storage_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let storage_id = match storage_ids.entry(identity) { + std::collections::hash_map::Entry::Occupied(entry) => *entry.get(), + std::collections::hash_map::Entry::Vacant(entry) => { + let storage_id = next_gpu_texture_storage_id()?; + entry.insert(storage_id); + storage_id + } + }; + Ok((imported, storage_id)) + } + + fn clear_storage_ids(&self) { + self.storage_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn next_gpu_texture_storage_id() -> Result { + NEXT_GPU_TEXTURE_STORAGE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .map_err(|_| anyhow::anyhow!("GPU texture storage identity space is exhausted")) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn prepared_macos_screen_target_metadata_bytes() -> Result { + checked_macos_arc_allocation_bytes::()? + .checked_add(checked_macos_arc_allocation_bytes::< + ResolvedScreenPublicationDescriptor, + >()?) + .context("macOS prepared target metadata accounting overflow") +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn checked_macos_arc_allocation_bytes() -> Result { + let (layout, _) = Layout::new::<[AtomicUsize; 2]>() + .extend(Layout::new::()) + .context("macOS Arc allocation layout overflow")?; + u64::try_from(layout.pad_to_align().size()).context("macOS Arc allocation exceeds u64") +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +impl ScreenNativeTargetPreparer for MacosScreenTargetPreparer { + fn quote_retained_bytes( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> Result { + let manifest = platform + .downcast_ref::() + .context("macOS screen target received an unknown preparation manifest")?; + validate_macos_target_manifest(descriptor, manifest)?; + self.bridge + .upgrade() + .context("macOS screen renderer was retired during target admission")?; + prepared_macos_screen_target_metadata_bytes() + } + + fn prepare( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> Result { + let manifest = platform + .downcast_ref::() + .context("macOS screen target received an unknown preparation manifest")?; + validate_macos_target_manifest(descriptor, manifest)?; + self.bridge + .upgrade() + .context("macOS screen renderer was retired during target preparation")?; + Ok(ScreenNativeTargetPreparation::new( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(PreparedMacosScreenTarget { + resource_generation: manifest.resource_generation(), + }), + ), + prepared_macos_screen_target_metadata_bytes()?, + )) + } +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn validate_macos_target_manifest( + descriptor: &ResolvedScreenPublicationDescriptor, + manifest: &MacosNativeTargetManifest, +) -> Result<()> { + anyhow::ensure!( + descriptor.kind() == ScreenPublicationKind::Surface, + "macOS native target requires a Surface descriptor" + ); + let source = descriptor.source(); + let resources = source.resources(); + anyhow::ensure!( + resources.backend() == &ScreenCaptureBackend::MacosScreenCaptureKit + && resources.api() == &ScreenResourceApi::PlatformGpu(PlatformGpuApi::Metal), + "macOS target manifest was paired with a non-Metal source" + ); + anyhow::ensure!( + matches!( + resources.physical_gpu_device(), + Some(ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(registry_id)) + if *registry_id == manifest.metal_registry_id() + ), + "macOS target manifest Metal device does not match the resolved source" + ); + anyhow::ensure!( + descriptor.source_epoch().session_generation == manifest.capture_session_generation() + && resources.device_generation() == manifest.capture_session_generation(), + "macOS target manifest capture session does not match the resolved source" + ); + anyhow::ensure!( + resources.resource_generation() == manifest.resource_generation(), + "macOS target manifest resource generation does not match the resolved source" + ); + Ok(()) +} + #[cfg(target_os = "windows")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum NativeScreenCopyFailurePolicy { @@ -374,6 +565,11 @@ pub(crate) fn native_screen_copy_error_invalidates_frame(error: &anyhow::Error) }) } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +pub(crate) const fn native_screen_copy_error_invalidates_frame(_error: &anyhow::Error) -> bool { + false +} + #[cfg(target_os = "windows")] fn screen_storage_requires_cache_turnover(current: Option, next: u64) -> bool { current != Some(next) @@ -414,6 +610,11 @@ pub(crate) fn is_retryable_native_screen_copy_error(error: &anyhow::Error) -> bo }) } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +pub(crate) const fn is_retryable_native_screen_copy_error(_error: &anyhow::Error) -> bool { + false +} + #[cfg(target_os = "windows")] impl ScreenNativeTargetPreparer for WindowsScreenTargetPreparer { fn quote_retained_bytes( @@ -655,6 +856,56 @@ fn create_screen_target( Some(target) } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn create_screen_bridge( + device: &wgpu::Device, + max_texture_dimension: u32, +) -> ( + Option>, + Option, +) { + let interop = match MacosInteropScreenBridge::new(device) { + Ok(bridge) => bridge, + Err(error) => { + tracing::debug!(%error, "renderer does not expose a Metal screen-import target"); + return (None, None); + } + }; + let bridge = Arc::new(MacosScreenBridge { + interop, + storage_ids: Mutex::new(HashMap::new()), + }); + let target = create_screen_target(&bridge, max_texture_dimension); + (Some(bridge), target) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn create_screen_target( + bridge: &Arc, + max_texture_dimension: u32, +) -> Option { + let Ok(target_id) = + NEXT_SCREEN_TARGET_ID.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + else { + tracing::warn!("screen target identity space is exhausted"); + return None; + }; + Some(ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(target_id).expect("screen target identities start at one"), + ), + PlatformGpuApi::Metal, + ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(bridge.interop.metal_registry_id()), + NonZeroU32::new(max_texture_dimension) + .expect("wgpu devices expose a non-zero texture dimension limit"), + Arc::new(MacosScreenTargetPreparer { + bridge: Arc::downgrade(bridge), + }), + )) +} + pub(crate) struct GpuSparkleFlinger { _render_device: GpuRenderDevice, device: wgpu::Device, @@ -692,6 +943,10 @@ pub(crate) struct GpuSparkleFlinger { screen_target: Option, #[cfg(target_os = "windows")] screen_storage_id: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + screen_bridge: Option>, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + screen_target: Option, #[cfg(test)] superseded_frame_count: usize, #[cfg(test)] @@ -1052,6 +1307,9 @@ impl GpuSparkleFlinger { #[cfg(target_os = "windows")] let (screen_bridge, screen_target) = create_screen_bridge(&device, &queue, probe.max_texture_dimension_2d); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let (screen_bridge, screen_target) = + create_screen_bridge(&device, probe.max_texture_dimension_2d); Ok(Self { _render_device: render_device, @@ -1090,6 +1348,10 @@ impl GpuSparkleFlinger { screen_target, #[cfg(target_os = "windows")] screen_storage_id: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + screen_bridge, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + screen_target, #[cfg(test)] superseded_frame_count: 0, #[cfg(test)] @@ -1210,7 +1472,10 @@ impl GpuSparkleFlinger { &self.probe.backend } - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] pub(crate) fn screen_native_execution_target(&self) -> Option<&ScreenNativeExecutionTarget> { if !self.canvas_gpu_admitted { return None; @@ -1368,11 +1633,17 @@ impl GpuSparkleFlinger { self.ready_preview_surface = None; self.cached_sample_result = None; self.spatial_sampler.clear_bind_groups(); - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] self.release_native_screen_caches(); } - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] pub(crate) fn release_native_screen_caches(&mut self) { if let Some(surfaces) = &mut self.surfaces { surfaces @@ -1390,7 +1661,16 @@ impl GpuSparkleFlinger { .source_copy_bind_groups .release_native_screen_entries(); } - self.screen_storage_id = None; + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + { + if let Some(bridge) = &self.screen_bridge { + bridge.clear_storage_ids(); + } + } + #[cfg(target_os = "windows")] + { + self.screen_storage_id = None; + } } #[cfg(target_os = "windows")] @@ -1476,6 +1756,67 @@ impl GpuSparkleFlinger { })) } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + pub(crate) fn copy_screen_publication( + &mut self, + publication: &Arc, + ) -> Result> { + let Some(bridge) = self.screen_bridge.clone() else { + return Ok(None); + }; + let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { + return Ok(None); + }; + let surface = payload.surface(); + let capture_owner = surface + .owner::() + .context("native macOS screen publication has an unknown capture owner")?; + let target_owner = surface + .retained_owner::() + .context("native macOS screen publication has no prepared renderer target")?; + let target_lifetime = surface + .resource_lifetime() + .cloned() + .context("native macOS screen publication has no renderer allocation lifetime")?; + anyhow::ensure!( + surface.capture_resource_lifetime().is_some(), + "native macOS screen publication has no capture allocation lifetime" + ); + let capture = capture_owner + .downgrade() + .upgrade() + .context("native macOS capture owner retired before import")?; + let (imported, storage_id) = + bridge.import_frame(&self.device, target_owner.resource_generation, capture)?; + let extent = surface.extent(); + anyhow::ensure!( + imported.capture().storage_extent.width == extent.width() + && imported.capture().storage_extent.height == extent.height(), + "native macOS imported extent does not match the published surface" + ); + let width = extent.width(); + let height = extent.height(); + let content_generation = imported.content_sequence(); + let texture = imported.texture().as_ref().clone(); + let view = imported.view().as_ref().clone(); + Ok(Some(GpuTextureFrame { + width, + height, + storage_id, + content_generation, + origin: GpuTextureFrameOrigin::ProducerTexture, + texture, + view, + immutable_lease: None, + macos_screen_lease: Some(MacosScreenTextureLease::new( + imported, + capture_owner, + target_owner, + target_lifetime, + )), + })) + } + pub(crate) fn can_sample_zone_plan(&mut self, prepared_zones: &[PreparedZonePlan]) -> bool { let dimensions = self .surfaces @@ -1535,6 +1876,8 @@ impl GpuSparkleFlinger { immutable_lease: None, #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, })) } @@ -1997,6 +2340,8 @@ impl GpuSparkleFlinger { immutable_lease: Some(Arc::clone(&snapshot.lease)), #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, }) } @@ -2020,6 +2365,8 @@ impl GpuSparkleFlinger { immutable_lease: None, #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, }) } @@ -2077,6 +2424,8 @@ impl GpuSparkleFlinger { immutable_lease: Some(Arc::clone(&snapshot.lease)), #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, }) } @@ -2189,6 +2538,8 @@ impl GpuSparkleFlinger { immutable_lease: None, #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, }) } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs index 517a392ce..966f1d3d9 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs @@ -4,8 +4,6 @@ use std::sync::mpsc::{self, TryRecvError}; use std::time::Duration; use anyhow::{Context, Result}; -#[cfg(target_os = "windows")] -use hypercolor_core::input::screen::ScreenResourceLifetime; use hypercolor_core::types::canvas::{ PublishedSurface, RenderSurfacePool, SurfaceDescriptor, SurfaceStateCounts, }; @@ -37,6 +35,11 @@ use super::{ ScreenUploadContentKey, padded_bytes_per_row, texture_extent, }; use crate::performance::CompositorBackendKind; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] +use crate::render_thread::producer_queue::NativeScreenCacheLease; use crate::render_thread::producer_queue::{ GpuTextureFrame, GpuTextureFrameLease, GpuTextureFrameOrigin, ProducerFrame, }; @@ -1290,8 +1293,11 @@ fn compose_layer_into_gpu( use_front_as_current, current_view, output_view, - #[cfg(target_os = "windows")] - frame.screen_target_lifetime(), + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + frame.native_screen_cache_lease(), ) } }; @@ -1479,8 +1485,11 @@ struct CachedComposeSourceBindGroup { source_view: wgpu::TextureView, bind_group: wgpu::BindGroup, source_lease: Option>, - #[cfg(target_os = "windows")] - screen_target_lifetime: Option, + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease: Option, } const COMPOSE_SOURCE_BIND_GROUP_CACHE_CAP: usize = 4; @@ -1534,8 +1543,11 @@ impl ComposeSourceBindGroupCache { "SparkleFlinger admitted projected-source bind group", ), source_lease: Some(source_lease.clone()), - #[cfg(target_os = "windows")] - screen_target_lifetime: None, + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease: None, } }; entries.insert(key, entry); @@ -1627,7 +1639,11 @@ impl ComposeSourceBindGroupCache { front_as_current: bool, current_view: &wgpu::TextureView, output_view: &wgpu::TextureView, - #[cfg(target_os = "windows")] screen_target_lifetime: Option<&ScreenResourceLifetime>, + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease: Option, ) -> wgpu::BindGroup { let key = ComposeSourceBindGroupKey { target_generation, @@ -1662,8 +1678,11 @@ impl ComposeSourceBindGroupCache { source_view: source_view.clone(), bind_group: bind_group.clone(), source_lease: None, - #[cfg(target_os = "windows")] - screen_target_lifetime: screen_target_lifetime.cloned(), + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease, }); bind_group } @@ -1677,14 +1696,17 @@ impl ComposeSourceBindGroupCache { .retain(|entry| entry.key.source_storage_id != source_storage_id); } - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] pub(super) fn release_native_screen_entries(&mut self) { self.projected_entries - .retain(|_, entry| entry.screen_target_lifetime.is_none()); + .retain(|_, entry| entry.native_screen_lease.is_none()); self.retired_projected_entries - .retain(|_, entry| entry.screen_target_lifetime.is_none()); + .retain(|_, entry| entry.native_screen_lease.is_none()); self.transient_entries - .retain(|entry| entry.screen_target_lifetime.is_none()); + .retain(|entry| entry.native_screen_lease.is_none()); } #[cfg(test)] diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/screen_upload.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/screen_upload.rs index 63e515a90..4f186ef4a 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/screen_upload.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/screen_upload.rs @@ -414,6 +414,8 @@ fn gpu_texture_frame(texture: &ScreenUploadTexture, content_generation: u64) -> immutable_lease: None, #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, } } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs index 90a012315..361df09c3 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs @@ -1,7 +1,5 @@ use std::borrow::Cow; -#[cfg(target_os = "windows")] -use hypercolor_core::input::screen::ScreenResourceLifetime; use hypercolor_core::types::canvas::{BYTES_PER_PIXEL, PublishedSurfaceStorageIdentity}; use wgpu::util::DeviceExt; @@ -14,6 +12,11 @@ use super::{ GpuCompositorSurfaceSet, GpuCompositorTexture, GpuDisplaySourceTexture, PendingUploadBuffers, SOURCE_COPY_PARAM_BYTES, texture_extent, }; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") +))] +use crate::render_thread::producer_queue::NativeScreenCacheLease; use crate::render_thread::producer_queue::{GpuTextureFrame, ProducerFrame}; use crate::render_thread::sparkleflinger::gpu::telemetry::record_gpu_source_upload_skipped; @@ -57,8 +60,11 @@ struct CachedSourceCopyBindGroup { source_view: wgpu::TextureView, output_view: wgpu::TextureView, bind_group: wgpu::BindGroup, - #[cfg(target_os = "windows")] - screen_target_lifetime: Option, + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease: Option, } const SOURCE_COPY_BIND_GROUP_CACHE_CAP: usize = 8; @@ -70,7 +76,11 @@ impl SourceCopyBindGroupCache { pipeline: &GpuCompositorPipeline, source_view: &wgpu::TextureView, output_view: &wgpu::TextureView, - #[cfg(target_os = "windows")] screen_target_lifetime: Option<&ScreenResourceLifetime>, + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease: Option, ) -> wgpu::BindGroup { if let Some(cached) = self .entries @@ -91,16 +101,22 @@ impl SourceCopyBindGroupCache { source_view: source_view.clone(), output_view: output_view.clone(), bind_group: bind_group.clone(), - #[cfg(target_os = "windows")] - screen_target_lifetime: screen_target_lifetime.cloned(), + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + native_screen_lease, }); bind_group } - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] pub(super) fn release_native_screen_entries(&mut self) { self.entries - .retain(|entry| entry.screen_target_lifetime.is_none()); + .retain(|entry| entry.native_screen_lease.is_none()); } } @@ -249,6 +265,11 @@ impl GpuSourceFrame<'_> { } } + fn requires_shader_copy_to(&self, output: &wgpu::Texture) -> bool { + self.needs_shader_copy() + || self.texture().format().remove_srgb_suffix() != output.format().remove_srgb_suffix() + } + pub(super) const fn needs_display_source_copy(&self) -> bool { match self { #[cfg(feature = "servo-gpu-import")] @@ -287,12 +308,15 @@ impl GpuSourceFrame<'_> { } } - #[cfg(target_os = "windows")] - pub(super) fn screen_target_lifetime(&self) -> Option<&ScreenResourceLifetime> { + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + pub(super) fn native_screen_cache_lease(&self) -> Option { match self { #[cfg(feature = "servo-gpu-import")] Self::Imported(_) => None, - Self::Texture(frame) => frame.screen_target_lifetime(), + Self::Texture(frame) => frame.native_screen_cache_lease(), } } } @@ -356,7 +380,7 @@ pub(super) fn copy_gpu_source_frame_into_texture( frame: &GpuSourceFrame<'_>, output: &GpuCompositorTexture, ) { - if frame.needs_shader_copy() { + if frame.requires_shader_copy_to(&output.texture) { let params_offset = encode_source_copy_params_upload( device, queue, @@ -374,8 +398,11 @@ pub(super) fn copy_gpu_source_frame_into_texture( pipeline, frame.view(), &output.view, - #[cfg(target_os = "windows")] - frame.screen_target_lifetime(), + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ))] + frame.native_screen_cache_lease(), ); dispatch_source_copy_pass( encoder, diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs index dbb3ec6c2..6a881abf8 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs @@ -1,15 +1,24 @@ #[cfg(any( all(feature = "servo-gpu-import", target_os = "linux"), - all(feature = "servo-gpu-import", target_os = "macos") + all(feature = "servo-gpu-import", target_os = "macos"), + all(feature = "screen-capture", target_os = "macos") ))] use std::sync::Arc; use std::sync::mpsc; use hypercolor_core::blend_math::encode_srgb_channel; +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use hypercolor_core::input::screen::{PlatformGpuApi, ScreenPhysicalGpuDeviceIdentity}; use hypercolor_core::spatial::SpatialEngine; use hypercolor_core::types::canvas::{ Canvas, PublishedSurface, RenderSurfacePool, Rgba, SurfaceDescriptor, }; +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, MacosPixelRect, + MacosPointRect, MacosScale, MacosTransferFunction, +}; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::device::{DeviceId, DisplayFrameFormat}; use hypercolor_types::event::ZoneColors; @@ -1593,6 +1602,105 @@ fn native_screen_manifest_generation_is_an_exact_fence() { assert!(validate_windows_plan_generation(7, 8).is_err()); } +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn metal_compositor_registers_and_composes_native_capture() { + let Some(mut compositor) = gpu_test_compositor() else { + return; + }; + let target = compositor + .screen_native_execution_target() + .expect("Metal compositor should expose a native screen target"); + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor should retain its screen bridge"), + ); + assert_eq!(target.accepted_api(), &PlatformGpuApi::Metal); + assert_eq!( + target.physical_gpu_device(), + &ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(bridge.interop.metal_registry_id()) + ); + assert_eq!( + target.max_texture_dimension().get(), + compositor.probe.max_texture_dimension_2d + ); + + let pixels = [17, 43, 91, 255].repeat(12); + let capture = Arc::new(macos_capture_frame(&pixels)); + let (imported, storage_id) = bridge + .import_frame(&compositor.device, 11, Arc::clone(&capture)) + .expect("native capture should import through the daemon bridge"); + let (_, repeated_storage_id) = bridge + .import_frame(&compositor.device, 11, capture) + .expect("the same native storage should import again"); + assert_eq!(storage_id, repeated_storage_id); + + let plan = CompositionPlan::single( + 4, + 3, + CompositionLayer::replace(ProducerFrame::GpuTexture(GpuTextureFrame { + width: 4, + height: 3, + storage_id, + content_generation: imported.content_sequence(), + origin: GpuTextureFrameOrigin::ProducerTexture, + texture: imported.texture().as_ref().clone(), + view: imported.view().as_ref().clone(), + immutable_lease: None, + macos_screen_lease: None, + })), + ); + compositor + .compose(&plan, false, full_preview_request(&plan)) + .expect("native capture should compose without CPU materialization"); + let preview = resolve_preview_surface_blocking(&mut compositor); + assert!( + preview + .rgba_bytes() + .chunks_exact(4) + .all(|pixel| pixel == [91, 43, 17, 255]) + ); +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +fn macos_capture_frame(pixels: &[u8]) -> MacosCaptureFrame { + let extent = MacosPixelExtent::new(4, 3).expect("fixture extent should be valid"); + let (surface, plane) = MacosCaptureSurface::new_native_bgra_fixture(extent, pixels) + .expect("native BGRA fixture should be valid"); + MacosCaptureFrame { + epoch: 5, + sequence: 0, + display_time: 13, + storage_extent: extent, + planes: Arc::from([plane]), + pixel_format: MacosCapturePixelFormat::Bgra8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0) + .expect("fixture display scale should be valid"), + content_scale: MacosScale::new(1.0).expect("fixture content scale should be valid"), + content_rect_points: MacosPointRect::new(0.0, 0.0, 4.0, 3.0) + .expect("fixture content points should be valid"), + content_rect_pixels: MacosPixelRect::new(0, 0, 4, 3) + .expect("fixture content pixels should be valid"), + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([]), + cursor_composed: true, + surface, + } +} + #[cfg(all(feature = "servo-gpu-import", target_os = "macos"))] #[test] fn gpu_macos_imported_frame_composes_without_cpu_readback() { @@ -1795,6 +1903,8 @@ fn gpu_compositor_rejects_every_cached_surface_texture_before_reactivation() { immutable_lease: None, #[cfg(target_os = "windows")] windows_screen_lease: None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + macos_screen_lease: None, } }; [ diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs index 8b0bbdf23..f7389a8ad 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs @@ -28,7 +28,13 @@ impl ProjectedLookupAllocationFixture { use anyhow::{Result, bail}; #[cfg(feature = "wgpu")] use hypercolor_core::bus::DisplayYuv420Frame; -#[cfg(all(feature = "wgpu", target_os = "windows"))] +#[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) +))] use hypercolor_core::input::screen::ScreenBranchPublication; use hypercolor_core::input::screen::ScreenNativeExecutionTarget; use hypercolor_core::spatial::PreparedZonePlan; @@ -781,7 +787,13 @@ impl SparkleFlinger { } pub(crate) fn screen_native_execution_target(&self) -> Option<&ScreenNativeExecutionTarget> { - #[cfg(all(feature = "wgpu", target_os = "windows"))] + #[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ))] if let SparkleFlingerBackend::Gpu { gpu, .. } = &self.backend { return gpu.screen_native_execution_target(); } @@ -789,13 +801,25 @@ impl SparkleFlinger { } pub(crate) fn release_native_screen_caches(&mut self) { - #[cfg(all(feature = "wgpu", target_os = "windows"))] + #[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ))] if let SparkleFlingerBackend::Gpu { gpu, .. } = &mut self.backend { gpu.release_native_screen_caches(); } } - #[cfg(all(feature = "wgpu", target_os = "windows"))] + #[cfg(all( + feature = "wgpu", + any( + target_os = "windows", + all(target_os = "macos", feature = "screen-capture") + ) + ))] pub(crate) fn copy_screen_publication( &mut self, publication: &std::sync::Arc, From 7796de1d49cd7857c71acb03481c5437a9edb1ec Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:28:44 -0700 Subject: [PATCH 056/144] docs(macos): establish spec authority boundaries Make spec 76 authoritative for macOS capture, host input, TCC ownership, and release acceptance. Record the still-open signed Intel parity gate and add both audited native crates to the canonical architecture inventory. Co-Authored-By: Nova (OpenAI Codex) --- AGENTS.md | 6 +++++- docs/specs/14-screen-capture.md | 3 +++ docs/specs/57-macos-servo-gpu-surface-interop.md | 14 +++++++++----- docs/specs/71-interactive-input-pipeline.md | 2 ++ docs/specs/72-windows-host-input.md | 3 ++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4437106c6..1b3482e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,9 @@ crates/ hypercolor-core/ # Engine: render loop, device backends, Servo effect renderer, event bus, spatial sampler, input pipeline, scene/session management hypercolor-hal/ # Hardware abstraction: USB/HID/SMBus protocol encoding and transport for the local driver families hypercolor-linux-gpu-interop/ # Linux GL/Vulkan texture import boundary for Servo frames + hypercolor-macos-capture/ # macOS ScreenCaptureKit acquisition and retained frame ownership hypercolor-macos-gpu-interop/ # macOS IOSurface/Metal texture import boundary + hypercolor-macos-input/ # macOS CGEventTap keyboard and pointer capture hypercolor-windows-gpu-interop/ # Windows D3D11/Vulkan texture import boundary hypercolor-windows-pawnio/ # Windows SMBus access via the PawnIO kernel driver, with a broker service; stubbed on other platforms hypercolor-windows-capture/ # Windows DXGI Desktop Duplication screen capture @@ -91,7 +93,9 @@ graph TD T --> CORE[hypercolor-core] HAL --> CORE LGI[hypercolor-linux-gpu-interop] --> CORE + MC[hypercolor-macos-capture] --> CORE MGI[hypercolor-macos-gpu-interop] --> CORE + MI[hypercolor-macos-input] --> CORE WGI[hypercolor-windows-gpu-interop] --> CORE WPI[hypercolor-windows-pawnio] --> CORE WC[hypercolor-windows-capture] --> CORE & WGI @@ -246,7 +250,7 @@ without the runtime cliffs of unoptimized Servo. - **Edition 2024**, Rust 1.94+ - **Tests:** integration and public-API coverage lives in `tests/` directories, named `{feature}_tests.rs`. Small private-internals unit tests may use `#[cfg(test)]` modules; avoid large inline test bodies. -- **`unsafe_code` is forbidden** workspace-wide by default. The audited opt-outs are `linux-gpu-interop`, `macos-gpu-interop`, `windows-gpu-interop`, `windows-pawnio`, `windows-capture`, `windows-input`, `windows-helper`, `platform-fs`, and `hypercolor-app` (Win32 power-event FFI); each denies `clippy::undocumented_unsafe_blocks` +- **`unsafe_code` is forbidden** workspace-wide by default. The audited opt-outs are `linux-gpu-interop`, `macos-capture`, `macos-gpu-interop`, `macos-input`, `windows-gpu-interop`, `windows-pawnio`, `windows-capture`, `windows-input`, `windows-helper`, `platform-fs`, and `hypercolor-app` (Win32 power-event FFI); each denies `clippy::undocumented_unsafe_blocks` - **Clippy pedantic** at deny level; see `Cargo.toml` for allowed exceptions - **`unwrap()` is forbidden**: use `?`, `.ok()`, `expect("reason")`, or handle errors properly - **`thiserror`** for library errors, **`anyhow`** for application errors diff --git a/docs/specs/14-screen-capture.md b/docs/specs/14-screen-capture.md index 48a08c906..5ee21b231 100644 --- a/docs/specs/14-screen-capture.md +++ b/docs/specs/14-screen-capture.md @@ -5,6 +5,9 @@ **Status:** Draft **Design doc:** [08-screen-capture.md](../design/08-screen-capture.md) **Performance doc:** [13-performance.md](../design/13-performance.md) +**macOS authority:** [Spec 76](76-macos-screen-capture-and-host-input.md) +supersedes this draft for macOS capture, permission, publication, and release +requirements. --- diff --git a/docs/specs/57-macos-servo-gpu-surface-interop.md b/docs/specs/57-macos-servo-gpu-surface-interop.md index 8df223ecd..ba693f3a1 100644 --- a/docs/specs/57-macos-servo-gpu-surface-interop.md +++ b/docs/specs/57-macos-servo-gpu-surface-interop.md @@ -1,11 +1,14 @@ # 57 - macOS Servo GPU Surface Interop -**Status:** Implemented; macOS live validation captured; `just dev` defaults to -`auto` +**Status:** Implemented on Apple Silicon. The original hardcoded shared-storage +importer was Apple-Silicon-only. Spec 76 owns family-aware storage selection, +and signed Intel CPU-oracle parity remains required before Intel parity is +discharged. `just dev` defaults to `auto`. **Author:** Nova **Date:** 2026-05-08 **Crates:** `hypercolor-core`, `hypercolor-daemon`, optional interop crate -**Related:** Specs 48, 56, 59; +**Related:** Specs 48, 56, 59, and +[76](76-macos-screen-capture-and-host-input.md), the macOS authority; `docs/design/34-servo-perf-and-crash-isolation.md`, `docs/design/45-graphics-pipeline-unification-plan.md` @@ -353,8 +356,9 @@ macOS-specific diagnostics should report: - fallback reason During development, default to `off` or a hidden opt-in. Default to `auto` only -after soak and parity pass on Apple Silicon and at least one Intel Mac if we -still support that target. +after soak and parity pass on Apple Silicon. Intel parity is discharged only +after Spec 76's family-aware W4 storage selection passes its signed Intel +CPU-oracle acceptance. ## 10. Implementation Waves diff --git a/docs/specs/71-interactive-input-pipeline.md b/docs/specs/71-interactive-input-pipeline.md index ed7debb1d..989275b33 100644 --- a/docs/specs/71-interactive-input-pipeline.md +++ b/docs/specs/71-interactive-input-pipeline.md @@ -3,6 +3,8 @@ Status: PROPOSED (cross-model reviewed: Codex gpt-5.6-sol adversarial pass, 2 blockers + 13 majors folded in) Depends on: none. Related: spec 69 (faces share the payload/adapter machinery). +Spec 76 is the macOS authority for native host input, TCC ownership, and the +final `device_query` retirement. ## Problem diff --git a/docs/specs/72-windows-host-input.md b/docs/specs/72-windows-host-input.md index 3da53bd96..2866e07e6 100644 --- a/docs/specs/72-windows-host-input.md +++ b/docs/specs/72-windows-host-input.md @@ -6,7 +6,8 @@ **Crates:** `hypercolor-windows-input` (new), `hypercolor-core`, `hypercolor-daemon`, `hypercolor-ui` **Related:** Spec 71 (interactive input pipeline) — this is W6's Windows half. Spec 58 sets the Windows interop-crate precedent; `hypercolor-windows-capture` -sets the shape. +sets the shape. [Spec 76](76-macos-screen-capture-and-host-input.md) is the macOS +authority for native host input and the final `device_query` retirement. ## Problem From 23490bdfc4d98f0da2658948ea82ba4c37fdefc0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:38:47 -0700 Subject: [PATCH 057/144] feat(ui): decode macOS daemon ownership status Accept the additive system-level owner snapshot without coupling the web UI to daemon internals. Optional fields and unknown additions remain tolerant so older and newer daemons interoperate during rollout. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-ui/src/api/system.rs | 46 +++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-ui/src/api/system.rs b/crates/hypercolor-ui/src/api/system.rs index 89efd6cb1..ad7d76c1a 100644 --- a/crates/hypercolor-ui/src/api/system.rs +++ b/crates/hypercolor-ui/src/api/system.rs @@ -36,6 +36,10 @@ pub struct SystemStatus { /// open/denied counts. Defaults tolerate daemons predating the field. #[serde(default)] pub input: InputStatus, + /// Authoritative local daemon topology on macOS. Absent on other hosts + /// and on daemons predating the ownership arbiter. + #[serde(default)] + pub macos_daemon_ownership: Option, } /// Host keyboard/mouse capture health from the daemon status payload. @@ -78,6 +82,15 @@ pub struct MacosDaemonOwnerConflictStatus { pub observed_at_ms: Option, } +/// Authoritative daemon-owner snapshot published independently of sources. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct MacosDaemonOwnershipStatus { + pub active_owner: Option, + pub owner_epoch: Option, + pub conflict: Option, +} + /// Persistability and redacted content style of a macOS screen selection. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -224,9 +237,40 @@ mod tests { use super::{ InputSourcePlatformStatus, InputSourceStatus, MacosDaemonOwnerConflictStatus, - MacosSelectionStatus, MacosTahoeSelectionStatus, + MacosDaemonOwnershipStatus, MacosSelectionStatus, MacosTahoeSelectionStatus, }; + #[test] + fn macos_daemon_ownership_decodes_tolerantly() { + let ownership: MacosDaemonOwnershipStatus = serde_json::from_value(json!({ + "active_owner": "launchd_service", + "owner_epoch": 42, + "conflict": { + "active": "launchd_service", + "contender": "homebrew_service", + "observed_at_ms": 1_725_000_000_789_u64, + "future_conflict_field": true + }, + "future_owner_field": { "available": true } + })) + .expect("macOS daemon ownership should decode"); + + assert_eq!(ownership.active_owner.as_deref(), Some("launchd_service")); + assert_eq!(ownership.owner_epoch, Some(42)); + assert_eq!( + ownership.conflict, + Some(MacosDaemonOwnerConflictStatus { + active: Some("launchd_service".to_owned()), + contender: Some("homebrew_service".to_owned()), + observed_at_ms: Some(1_725_000_000_789), + }) + ); + + let partial: MacosDaemonOwnershipStatus = serde_json::from_value(json!({})) + .expect("partial macOS daemon ownership should decode"); + assert_eq!(partial, MacosDaemonOwnershipStatus::default()); + } + #[test] fn input_source_status_decodes_macos_input_platform_tolerantly() { let status: InputSourceStatus = serde_json::from_value(json!({ From 2cf5b38c62e751d8acd70eec0307d038b25a0f7b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:46:12 -0700 Subject: [PATCH 058/144] feat(protocol): publish macOS daemon ownership events Add the bounded, state-only ownership event to the shared bus and WebSocket manifest. Generated Python constants now include every JSON payload schema, while owner selection remains absent from all network control surfaces. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-daemon/src/api/ws/tests.rs | 12 +++++++ crates/hypercolor-types/src/event.rs | 26 +++++++++++++++ crates/hypercolor-types/tests/event_tests.rs | 35 ++++++++++++++++++-- protocol/websocket-v1.json | 13 ++++++++ python/scripts/generate_ws_protocol.py | 23 +++++++++++++ python/src/hypercolor/ws_protocol.py | 20 +++++++++++ python/tests/test_websocket.py | 8 +++++ 7 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/hypercolor-daemon/src/api/ws/tests.rs b/crates/hypercolor-daemon/src/api/ws/tests.rs index 2e8d70c57..6d6c10e3e 100644 --- a/crates/hypercolor-daemon/src/api/ws/tests.rs +++ b/crates/hypercolor-daemon/src/api/ws/tests.rs @@ -3881,6 +3881,18 @@ fn websocket_manifest_matches_protocol_constants() { manifest["json_payloads"]["timed_input_event_v1"]["schema_version"], hypercolor_leptos_ext::ws::INPUT_EVENT_PAYLOAD_SCHEMA ); + let ownership = &manifest["json_payloads"]["macos_daemon_ownership_changed_v1"]; + assert_eq!(ownership["schema_version"], 1); + assert_eq!(ownership["channel"], "events"); + assert_eq!(ownership["event"], "macos_daemon_ownership_changed"); + assert_eq!( + ownership["required_fields"], + serde_json::json!(["active_owner", "owner_epoch"]) + ); + assert_eq!( + ownership["optional_fields"]["conflict"], + serde_json::Value::Null + ); let binary_tags = manifest["binary_messages"] .as_array() diff --git a/crates/hypercolor-types/src/event.rs b/crates/hypercolor-types/src/event.rs index 43a618620..244a6be19 100644 --- a/crates/hypercolor-types/src/event.rs +++ b/crates/hypercolor-types/src/event.rs @@ -404,6 +404,24 @@ pub enum EventControlValue { String(String), } +/// Process topology that owns the active macOS daemon. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosDaemonOwnerEvent { + AppSidecar, + LaunchdService, + HomebrewService, + Standalone, +} + +/// Losing macOS daemon topology observed beside the active owner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MacosDaemonOwnerConflictEvent { + pub active: MacosDaemonOwnerEvent, + pub contender: MacosDaemonOwnerEvent, + pub observed_at_ms: u64, +} + /// Per-stage frame timing in microseconds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrameTiming { @@ -939,6 +957,13 @@ pub enum HypercolorEvent { reason: String, }, + /// The authoritative macOS daemon owner or contender changed. + MacosDaemonOwnershipChanged { + active_owner: MacosDaemonOwnerEvent, + owner_epoch: u64, + conflict: Option, + }, + /// Global brightness changed. BrightnessChanged { old: u8, new_value: u8 }, @@ -1131,6 +1156,7 @@ impl HypercolorEvent { | Self::ShutdownRequested { .. } | Self::DaemonStarted { .. } | Self::DaemonShutdown { .. } + | Self::MacosDaemonOwnershipChanged { .. } | Self::BrightnessChanged { .. } | Self::Paused | Self::Resumed diff --git a/crates/hypercolor-types/tests/event_tests.rs b/crates/hypercolor-types/tests/event_tests.rs index 515eba1be..ec0d26074 100644 --- a/crates/hypercolor-types/tests/event_tests.rs +++ b/crates/hypercolor-types/tests/event_tests.rs @@ -8,8 +8,9 @@ use hypercolor_types::event::{ AssetChangeKind, ChangeTrigger, ContextType, DisconnectReason, EffectDegradationState, EffectRef, EffectStopReason, EventCategory, EventControlValue, EventPriority, FrameData, FrameTiming, HypercolorEvent, InputButtonState, InputEvent, LayerHealth, LayerStackChangeKind, - PointerScrollPhase, PointerScrollUnit, SceneChangeReason, Severity, TimedInputEvent, - TransitionRef, ZoneChangeKind, ZoneColors, ZoneRef, + MacosDaemonOwnerConflictEvent, MacosDaemonOwnerEvent, PointerScrollPhase, PointerScrollUnit, + SceneChangeReason, Severity, TimedInputEvent, TransitionRef, ZoneChangeKind, ZoneColors, + ZoneRef, }; use hypercolor_types::layer::SceneLayerId; use hypercolor_types::scene::{SceneId, SceneKind, SceneMutationMode, ZoneId, ZoneRole}; @@ -306,6 +307,11 @@ fn system_events_have_system_category() { HypercolorEvent::DaemonShutdown { reason: "user".into(), }, + HypercolorEvent::MacosDaemonOwnershipChanged { + active_owner: MacosDaemonOwnerEvent::AppSidecar, + owner_epoch: 7, + conflict: None, + }, HypercolorEvent::BrightnessChanged { old: 100, new_value: 50, @@ -329,6 +335,31 @@ fn system_events_have_system_category() { } } +#[test] +fn macos_daemon_ownership_event_round_trips_bounded_payload() { + let event = HypercolorEvent::MacosDaemonOwnershipChanged { + active_owner: MacosDaemonOwnerEvent::LaunchdService, + owner_epoch: 42, + conflict: Some(MacosDaemonOwnerConflictEvent { + active: MacosDaemonOwnerEvent::LaunchdService, + contender: MacosDaemonOwnerEvent::HomebrewService, + observed_at_ms: 1_777, + }), + }; + + let json = serde_json::to_value(&event).expect("serialize ownership event"); + assert_eq!(json["type"], "MacosDaemonOwnershipChanged"); + assert_eq!(json["data"]["active_owner"], "launchd_service"); + assert_eq!(json["data"]["owner_epoch"], 42); + assert_eq!(json["data"]["conflict"]["contender"], "homebrew_service"); + assert_eq!( + serde_json::from_value::(json) + .expect("deserialize ownership event") + .category(), + EventCategory::System + ); +} + #[test] fn automation_events_have_automation_category() { let events = vec![ diff --git a/protocol/websocket-v1.json b/protocol/websocket-v1.json index c57d41ffc..4186179ef 100644 --- a/protocol/websocket-v1.json +++ b/protocol/websocket-v1.json @@ -209,6 +209,19 @@ "freshness_issue_code": null }, "description": "Coalesced input-source lifecycle and freshness transition. Contains operational metadata only and never captured input contents." + }, + "macos_daemon_ownership_changed_v1": { + "schema_version": 1, + "channel": "events", + "event": "macos_daemon_ownership_changed", + "required_fields": [ + "active_owner", + "owner_epoch" + ], + "optional_fields": { + "conflict": null + }, + "description": "Authoritative macOS daemon topology snapshot. The event reports ownership state only and cannot request an owner change." } }, "binary_messages": [ diff --git a/python/scripts/generate_ws_protocol.py b/python/scripts/generate_ws_protocol.py index 4bcdb1495..7ca5f4761 100644 --- a/python/scripts/generate_ws_protocol.py +++ b/python/scripts/generate_ws_protocol.py @@ -52,6 +52,7 @@ def load_manifest(path: Path) -> dict[str, Any]: def render(manifest: dict[str, Any]) -> str: channels = [str(channel["name"]) for channel in expect_list(manifest["channels"])] + json_payloads = expect_dict(manifest["json_payloads"]) binary_messages = expect_list(manifest["binary_messages"]) preview_messages = [ message for message in binary_messages if message.get("layout") == "preview_frame" @@ -75,6 +76,12 @@ def render(manifest: dict[str, Any]) -> str: ")", *tuple_assignment("WS_CAPABILITIES", manifest["capabilities"]), "", + "JSON_PAYLOAD_CONTRACTS: Final = MappingProxyType(", + " {", + *render_json_payload_contracts(json_payloads), + " }", + ")", + "", "BINARY_MESSAGE_TAGS: Final = MappingProxyType(", " {", *[ @@ -104,6 +111,22 @@ def render(manifest: dict[str, Any]) -> str: return "\n".join(lines) +def render_json_payload_contracts(payloads: dict[str, Any]) -> list[str]: + lines: list[str] = [] + for name, raw_contract in payloads.items(): + contract = expect_dict(raw_contract) + lines.extend( + [ + f" {quote(name)}: (", + f" {int(contract['schema_version'])},", + f" {quote(str(contract['channel']))},", + f" {quote(str(contract['event']))},", + " ),", + ] + ) + return lines + + def tuple_assignment(name: str, values: Any) -> list[str]: strings = [str(value) for value in expect_list(values)] if len(strings) == 1: diff --git a/python/src/hypercolor/ws_protocol.py b/python/src/hypercolor/ws_protocol.py index dc9b6b597..49db2477f 100644 --- a/python/src/hypercolor/ws_protocol.py +++ b/python/src/hypercolor/ws_protocol.py @@ -49,6 +49,26 @@ "preview_transport_v1:decoded=536870912,encoded=536936448,connection=1073872896,streams=256,tombstones=1024,idle_ms=5000,message=1048576,chunks=4096", ) +JSON_PAYLOAD_CONTRACTS: Final = MappingProxyType( + { + "timed_input_event_v1": ( + 1, + "input_events", + "input_event_received", + ), + "input_source_status_changed_v1": ( + 1, + "events", + "input_source_status_changed", + ), + "macos_daemon_ownership_changed_v1": ( + 1, + "events", + "macos_daemon_ownership_changed", + ), + } +) + BINARY_MESSAGE_TAGS: Final = MappingProxyType( { "led_frame": 0x01, diff --git a/python/tests/test_websocket.py b/python/tests/test_websocket.py index 7f017e07d..11c9647d4 100644 --- a/python/tests/test_websocket.py +++ b/python/tests/test_websocket.py @@ -54,6 +54,14 @@ def test_ws_protocol_constants_match_manifest() -> None: assert manifest["subprotocol"] == ws_protocol.WS_SUBPROTOCOL assert list(ws_protocol.WS_CHANNELS) == [str(channel["name"]) for channel in channels] assert list(ws_protocol.WS_CAPABILITIES) == _expect_list(manifest["capabilities"]) + assert dict(ws_protocol.JSON_PAYLOAD_CONTRACTS) == { + str(name): ( + int(contract["schema_version"]), + str(contract["channel"]), + str(contract["event"]), + ) + for name, contract in _expect_dict(manifest["json_payloads"]).items() + } assert dict(ws_protocol.BINARY_MESSAGE_TAGS) == { str(message["name"]): int(message["tag"]) for message in binary_messages } From 93f82cc6796499543c3723d49215ad59d829459a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:48:35 -0700 Subject: [PATCH 059/144] feat(macos): admit ScreenCaptureKit surface pools Reserve the full eight-slot native queue before stream construction, then rebase its shared byte lease from each observed IOSurface allocation before retaining the pixel buffer. Candidate streams reserve beside pinned old surfaces, and resource pressure stops only the rejected stream generation. Keep the pool claim alive through every cloned native surface so retirement cannot release backing still visible downstream. Add exact rebase, variance, retention-order, overlap, and typed diagnostic coverage. Co-Authored-By: Nova (OpenAI Codex) --- .../src/input/screen/admission.rs | 52 +++ .../hypercolor-core/src/input/screen/macos.rs | 275 +++++++++++++- .../tests/screen_admission_tests.rs | 33 ++ .../src/diagnostics.rs | 21 +- crates/hypercolor-macos-capture/src/frame.rs | 25 +- crates/hypercolor-macos-capture/src/native.rs | 336 +++++++++++++++++- 6 files changed, 722 insertions(+), 20 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/admission.rs b/crates/hypercolor-core/src/input/screen/admission.rs index 2c8867e22..3d4983025 100644 --- a/crates/hypercolor-core/src/input/screen/admission.rs +++ b/crates/hypercolor-core/src/input/screen/admission.rs @@ -608,6 +608,58 @@ impl ScreenByteLease { self.inner.bytes.load(Ordering::Acquire) } + /// Atomically rebase a live reservation to an exact backing size. + /// + /// An increase is admitted before this lease exposes the larger size. A + /// rejected increase preserves both the lease and process-wide totals. + /// + /// # Errors + /// + /// Returns [`ScreenByteAdmissionError::CapacityExceeded`] when an increase + /// cannot fit inside the installed process and backend fences. + pub fn try_reconcile_exact(&self, exact_bytes: u64) -> Result<(), ScreenByteAdmissionError> { + loop { + let current = self.inner.bytes.load(Ordering::Acquire); + if exact_bytes == current { + return Ok(()); + } + if exact_bytes < current { + if self + .inner + .bytes + .compare_exchange_weak( + current, + exact_bytes, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + self.inner.coordinator.release(current - exact_bytes); + return Ok(()); + } + continue; + } + + let additional = exact_bytes - current; + let top_up = ScreenByteAdmissionCoordinator { + inner: Arc::clone(&self.inner.coordinator), + } + .try_acquire(additional)?; + if self + .inner + .bytes + .compare_exchange(current, exact_bytes, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let top_up = top_up.freeze(); + top_up.inner.bytes.store(0, Ordering::Release); + return Ok(()); + } + drop(top_up); + } + } + pub(crate) fn is_same(&self, other: &Self) -> bool { Arc::ptr_eq(&self.inner, &other.inner) } diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 5f4d4f3d1..47ef14a81 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -41,6 +41,8 @@ use super::{ ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; +#[cfg(target_os = "macos")] +use super::{ScreenByteAdmissionError, ScreenByteLease}; use crate::input::status::SourceSessionSlot; use crate::input::traits::{ InputData, InputSource, ProtectedSourceAuthorizationAction, ScreenSourcePickerAction, @@ -53,6 +55,167 @@ use crate::input::{ const WORKER_WAIT: Duration = Duration::from_millis(100); +#[cfg(target_os = "macos")] +struct MacosCapturePoolAdmission { + lease: Arc, + metadata_bytes: u64, + observed: Vec<(u32, u64)>, +} + +#[cfg(target_os = "macos")] +impl MacosCapturePoolAdmission { + fn reserve( + coordinator: &ScreenByteAdmissionCoordinator, + conservative_surface_bytes: u64, + native_metadata_bytes: u64, + ) -> Result { + let tracking_bytes = u64::try_from(std::mem::size_of::<(u32, u64)>()) + .ok() + .and_then(|bytes| { + bytes.checked_mul( + u64::try_from(hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH).ok()?, + ) + }) + .and_then(|bytes| bytes.checked_add(u64::try_from(std::mem::size_of::()).ok()?)) + .and_then(|bytes| { + bytes.checked_add(u64::try_from(std::mem::size_of::()).ok()?) + }) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + let metadata_bytes = native_metadata_bytes + .checked_add(tracking_bytes) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + let surface_bytes = conservative_surface_bytes + .checked_mul( + u64::try_from(hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH) + .map_err(|_| hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?, + ) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + let total_bytes = surface_bytes + .checked_add(metadata_bytes) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + let reservation = coordinator + .try_acquire(total_bytes) + .map_err(map_macos_pool_admission_error)?; + let mut observed = Vec::new(); + observed + .try_reserve_exact(hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH) + .map_err( + |_| hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { + requested_bytes: tracking_bytes, + available_bytes: 0, + }, + )?; + Ok(Self { + lease: Arc::new(reservation.freeze()), + metadata_bytes, + observed, + }) + } + + fn observe( + &mut self, + iosurface_id: u32, + allocation_bytes: u64, + ) -> Result, hypercolor_macos_capture::MacosCaptureError> { + if iosurface_id == 0 || allocation_bytes == 0 { + return Err(hypercolor_macos_capture::MacosCaptureError::InvalidSurface); + } + let existing = self + .observed + .iter() + .position(|(observed_id, _)| *observed_id == iosurface_id); + if existing.is_none() + && self.observed.len() == hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH + { + return Err( + hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { + requested_bytes: allocation_bytes, + available_bytes: 0, + }, + ); + } + let observed_count = self.observed.len() + usize::from(existing.is_none()); + let mut observed_sum = 0_u64; + let mut observed_max = allocation_bytes; + for (index, (_, observed_bytes)) in self.observed.iter().enumerate() { + let bytes = if Some(index) == existing { + allocation_bytes + } else { + *observed_bytes + }; + observed_sum = observed_sum + .checked_add(bytes) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + observed_max = observed_max.max(bytes); + } + if existing.is_none() { + observed_sum = observed_sum + .checked_add(allocation_bytes) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + } + let unseen_count = hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH - observed_count; + let projected_unseen = observed_max + .checked_mul( + u64::try_from(unseen_count) + .map_err(|_| hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?, + ) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + let exact_bytes = self + .metadata_bytes + .checked_add(observed_sum) + .and_then(|bytes| bytes.checked_add(projected_unseen)) + .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; + self.lease + .try_reconcile_exact(exact_bytes) + .map_err(map_macos_pool_admission_error)?; + if let Some(index) = existing { + self.observed[index].1 = allocation_bytes; + } else { + self.observed.push((iosurface_id, allocation_bytes)); + } + Ok(Arc::clone(&self.lease)) + } + + #[cfg(test)] + fn exact_observed_pool_bytes(&self) -> u64 { + self.observed.iter().map(|(_, bytes)| bytes).sum() + } + + #[cfg(test)] + fn metadata_bytes(&self) -> u64 { + self.metadata_bytes + } + + #[cfg(test)] + fn reservation_variance(&self) -> u64 { + self.lease + .bytes() + .saturating_sub(self.metadata_bytes) + .saturating_sub(self.exact_observed_pool_bytes()) + } +} + +#[cfg(target_os = "macos")] +fn map_macos_pool_admission_error( + error: ScreenByteAdmissionError, +) -> hypercolor_macos_capture::MacosCaptureError { + let (requested_bytes, available_bytes) = match error { + ScreenByteAdmissionError::CapacityExceeded { + requested_bytes, + available_bytes, + } => (requested_bytes, available_bytes), + ScreenByteAdmissionError::CapacityShrinkRejected { + requested_capacity, + reserved_bytes, + } => (reserved_bytes, requested_capacity), + ScreenByteAdmissionError::RevisionExhausted => (u64::MAX, 0), + }; + hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { + requested_bytes, + available_bytes, + } +} + /// Descriptor-keyed source data passed to the daemon-owned Metal target. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MacosNativeTargetManifest { @@ -486,7 +649,22 @@ impl MacosScreenCaptureInput { false, )?; let selector = MacosCaptureSelector::parse(&config.source)?; - let session = MacosScreenCaptureSession::new(request, selector)?; + let pool_coordinator = admission.clone(); + let session = MacosScreenCaptureSession::new_with_pool_admission( + request, + selector, + move |conservative_surface_bytes, native_metadata_bytes| { + let pool = Arc::new(Mutex::new(MacosCapturePoolAdmission::reserve( + &pool_coordinator, + conservative_surface_bytes, + native_metadata_bytes, + )?)); + Ok(move |iosurface_id, allocation_bytes| { + let lease = lock(&pool).observe(iosurface_id, allocation_bytes)?; + Ok(lease as Arc) + }) + }, + )?; let clock = MacosDisplayClock::system()?; Ok(Self::with_control( config, @@ -2059,6 +2237,101 @@ mod tests { const BGRA8: u32 = 0x4247_5241; + #[cfg(target_os = "macos")] + #[test] + fn capture_pool_rebases_before_exposing_an_observed_surface() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) + .expect("conservative queue quote should fit"); + let initial = pool.lease.bytes(); + assert!(initial >= 8 * 100 + 32); + + let first = pool + .observe(1, 120) + .expect("first exact pool observation should fit"); + assert_eq!(first.bytes(), pool.metadata_bytes() + 8 * 120); + assert_eq!(coordinator.snapshot().reserved_bytes(), first.bytes()); + assert_eq!(pool.exact_observed_pool_bytes(), 120); + assert_eq!(pool.reservation_variance(), 7 * 120); + } + + #[cfg(target_os = "macos")] + #[test] + fn capture_pool_collapses_to_exact_sum_after_all_slots_are_observed() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 128, 64) + .expect("conservative queue quote should fit"); + let allocations = [112_u64, 128, 144, 160, 176, 192, 208, 224]; + for (index, allocation) in allocations.into_iter().enumerate() { + pool.observe( + u32::try_from(index + 1).expect("fixture id fits"), + allocation, + ) + .expect("exact slot observation should fit"); + } + let exact_sum: u64 = allocations.into_iter().sum(); + assert_eq!(pool.exact_observed_pool_bytes(), exact_sum); + assert_eq!(pool.reservation_variance(), 0); + assert_eq!(pool.lease.bytes(), pool.metadata_bytes() + exact_sum); + } + + #[cfg(target_os = "macos")] + #[test] + fn capture_pool_rejects_larger_surface_without_recording_or_rebasing() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_200, 1_200)); + let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) + .expect("conservative queue quote should fit"); + let reserved_before = coordinator.snapshot().reserved_bytes(); + + assert!(matches!( + pool.observe(1, 200), + Err(hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { .. }) + )); + assert_eq!(pool.exact_observed_pool_bytes(), 0); + assert_eq!(pool.lease.bytes(), reserved_before); + assert_eq!(coordinator.snapshot().reserved_bytes(), reserved_before); + } + + #[cfg(target_os = "macos")] + #[test] + fn retained_surface_lifetime_keeps_the_pool_admitted_after_stream_drop() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) + .expect("conservative queue quote should fit"); + let retained = pool + .observe(1, 120) + .expect("first exact pool observation should fit"); + let admitted_bytes = retained.bytes(); + + drop(pool); + assert_eq!(coordinator.snapshot().reserved_bytes(), admitted_bytes); + drop(retained); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); + } + + #[cfg(target_os = "macos")] + #[test] + fn candidate_pool_reserves_alongside_a_pinned_old_generation() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(2_000, 2_000)); + let mut old = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) + .expect("old stream quote should fit"); + let pinned = old + .observe(1, 120) + .expect("old stream observation should fit"); + drop(old); + + assert!(matches!( + MacosCapturePoolAdmission::reserve(&coordinator, 100, 32), + Err(hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { .. }) + )); + assert_eq!(coordinator.snapshot().reserved_bytes(), pinned.bytes()); + } + #[derive(Debug)] struct TestPreparedTarget; diff --git a/crates/hypercolor-core/tests/screen_admission_tests.rs b/crates/hypercolor-core/tests/screen_admission_tests.rs index de26d1f8f..0b417de5a 100644 --- a/crates/hypercolor-core/tests/screen_admission_tests.rs +++ b/crates/hypercolor-core/tests/screen_admission_tests.rs @@ -28,6 +28,39 @@ fn admission_reservation_reconciles_only_before_freeze() { assert_eq!(coordinator.snapshot().reserved_bytes(), 0); } +#[test] +fn live_lease_rebases_up_and_down_without_exposing_unadmitted_bytes() { + let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(100, 90)); + let lease = coordinator + .try_acquire(40) + .expect("initial pool quote should fit") + .freeze(); + + lease + .try_reconcile_exact(80) + .expect("observed pool should fit"); + assert_eq!(lease.bytes(), 80); + assert_eq!(coordinator.snapshot().reserved_bytes(), 80); + + assert_eq!( + lease.try_reconcile_exact(95), + Err(ScreenByteAdmissionError::CapacityExceeded { + requested_bytes: 15, + available_bytes: 10, + }) + ); + assert_eq!(lease.bytes(), 80); + assert_eq!(coordinator.snapshot().reserved_bytes(), 80); + + lease + .try_reconcile_exact(56) + .expect("exact pool observation may release variance"); + assert_eq!(lease.bytes(), 56); + assert_eq!(coordinator.snapshot().reserved_bytes(), 56); + drop(lease); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); +} + #[test] fn capacity_shrink_rejects_without_mutating_live_fence() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(100, 90)); diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index cd8cfa0b4..32f748e68 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -13,10 +13,11 @@ pub enum MacosFrameDropReason { ColorMetadata = 5, Surface = 6, Validation = 7, + Resource = 8, } impl MacosFrameDropReason { - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 9] = [ Self::InvalidSample, Self::DataNotReady, Self::UnexpectedOutput, @@ -25,6 +26,7 @@ impl MacosFrameDropReason { Self::ColorMetadata, Self::Surface, Self::Validation, + Self::Resource, ]; pub(crate) const fn from_error(error: &MacosCaptureError) -> Self { @@ -78,6 +80,7 @@ impl MacosFrameDropReason { | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted | MacosCaptureError::Geometry(_) => Self::Validation, + MacosCaptureError::ScreenResourceExhausted { .. } => Self::Resource, } } } @@ -144,3 +147,19 @@ impl CallbackCounters { } } } + +#[cfg(test)] +mod tests { + use super::{MacosCaptureError, MacosFrameDropReason}; + + #[test] + fn resource_exhaustion_has_a_distinct_drop_reason() { + assert_eq!( + MacosFrameDropReason::from_error(&MacosCaptureError::ScreenResourceExhausted { + requested_bytes: 64, + available_bytes: 32, + }), + MacosFrameDropReason::Resource + ); + } +} diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index dd5b781a1..247858117 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -110,7 +110,7 @@ impl MacosCapturePixelFormat { } } - fn plane_layout(self, storage: MacosPixelExtent) -> Vec<(MacosPixelExtent, u64)> { + pub(crate) fn plane_layout(self, storage: MacosPixelExtent) -> Vec<(MacosPixelExtent, u64)> { match self { Self::Bgra8 | Self::Argb2101010 => vec![(storage, 4)], Self::Rgba16Float => vec![(storage, 8)], @@ -225,6 +225,7 @@ pub struct MacosCaptureSurface { pub iosurface_id: u32, pub allocation_bytes: u64, owner: Arc, + _admission_lifetime: Option>, } /// Borrowed native handles for handing a retained capture surface to audited @@ -328,7 +329,7 @@ impl MacosCaptureSurface { lock.unlock()?; let length_bytes = u64::try_from(CVPixelBufferGetDataSize(&pixel_buffer)) .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; - let surface = Self::from_pixel_buffer(pixel_buffer)?; + let surface = Self::from_pixel_buffer(pixel_buffer, None)?; Ok(( surface, MacosCapturePlane { @@ -356,6 +357,7 @@ impl MacosCaptureSurface { fixture_id, planes: None, }), + _admission_lifetime: None, }) } @@ -382,12 +384,14 @@ impl MacosCaptureSurface { fixture_id, planes: Some(planes.into()), }), + _admission_lifetime: None, }) } #[cfg(target_os = "macos")] pub(crate) fn from_pixel_buffer( pixel_buffer: CFRetained, + admission_lifetime: Option>, ) -> Result { let iosurface = CVPixelBufferGetIOSurface(Some(&pixel_buffer)) .ok_or(MacosCaptureError::MissingIoSurface)?; @@ -401,6 +405,7 @@ impl MacosCaptureSurface { iosurface_id, allocation_bytes, owner: Arc::new(MacosRetainedPixelBuffer::Native { pixel_buffer }), + _admission_lifetime: admission_lifetime, }) } @@ -852,6 +857,15 @@ fn validate_planes( Ok(planes) } +pub(crate) fn validate_capture_planes( + storage: MacosPixelExtent, + format: MacosCapturePixelFormat, + raw_planes: Vec, + allocation_bytes: u64, +) -> Result, MacosCaptureError> { + validate_planes(storage, format, raw_planes, allocation_bytes) +} + fn validate_geometry( storage: MacosPixelExtent, attachments: &MacosRawFrameAttachments, @@ -1036,6 +1050,13 @@ pub enum MacosCaptureError { CpuDestinationTooSmall { required: usize, actual: usize }, #[error("complete-frame sequence exhausted")] SequenceExhausted, + #[error( + "macOS screen resources need {requested_bytes} bytes; shared capacity has {available_bytes} bytes available" + )] + ScreenResourceExhausted { + requested_bytes: u64, + available_bytes: u64, + }, #[error(transparent)] Geometry(#[from] MacosGeometryError), } diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 03aa80b0f..62602878d 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -32,6 +32,7 @@ use objc2_core_video::{ kCVImageBufferYCbCrMatrix_ITU_R_2020, kCVImageBufferYCbCrMatrixKey, }; use objc2_foundation::{NSArray, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSValue}; +use objc2_io_surface::IOSurfaceRef; use objc2_screen_capture_kit::{ SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, SCContentSharingPickerConfiguration, SCContentSharingPickerMode, @@ -54,6 +55,15 @@ use crate::{ MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, }; +type PoolBackingLifetime = Arc; +type PoolObservation = + Arc Result + Send + Sync>; +type PoolReservationFactory = + Arc Result + Send + Sync>; + +const MACOS_IOSURFACE_ROW_ALIGNMENT: u64 = 256; +const MACOS_IOSURFACE_ALLOCATION_ALIGNMENT: u64 = 16 * 1024; + #[derive(Debug)] struct SessionShared { mailbox: MacosFrameMailbox, @@ -172,10 +182,10 @@ impl SessionShared { } } -#[derive(Debug)] struct RetainedNativeSample { attachments: MacosRawFrameAttachments, pixel_buffer: Option>, + admission_lifetime: Option, cursor_composed: bool, } @@ -186,6 +196,7 @@ unsafe impl Send for RetainedNativeSample {} fn retain_sample( sample: &CMSampleBuffer, cursor_composed: bool, + pool: &PoolObservation, ) -> Result { // SAFETY: ScreenCaptureKit supplied a live CMSampleBuffer reference for // the duration of this callback. @@ -197,16 +208,96 @@ fn retain_sample( return Err(MacosCaptureError::SampleDataNotReady); } let attachments = FrameAttachments::from_sample(sample)?.decode(); - // SAFETY: The valid, ready sample remains live while Core Media returns a - // retained image-buffer owner. Lifecycle samples may have no image buffer. - let pixel_buffer = unsafe { sample.image_buffer() }; + let status = match attachments.status.clone() { + MacosAttachment::Value(status) => MacosFrameStatus::try_from(status)?, + MacosAttachment::Missing => return Err(MacosCaptureError::MissingAttachment("status")), + MacosAttachment::Malformed => { + return Err(MacosCaptureError::MalformedAttachment("status")); + } + }; + let (pixel_buffer, admission_lifetime) = if status == MacosFrameStatus::Complete { + let pixel_buffer = borrowed_pixel_buffer(sample)?; + let storage_extent = extent( + CVPixelBufferGetWidth(pixel_buffer), + CVPixelBufferGetHeight(pixel_buffer), + )?; + let pixel_format_fourcc = CVPixelBufferGetPixelFormatType(pixel_buffer); + let pixel_format = MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?; + let planes = planes(pixel_buffer, storage_extent)?; + let (iosurface_id, allocation_bytes) = borrowed_surface_identity(pixel_buffer)?; + crate::frame::validate_capture_planes( + storage_extent, + pixel_format, + planes, + allocation_bytes, + )?; + with_admitted_surface(pool, iosurface_id, allocation_bytes, |admission_lifetime| { + // SAFETY: admission succeeded while the callback still owns the + // borrowed image buffer, so this takes the retained owner handed off. + let pixel_buffer = unsafe { CFRetained::retain(NonNull::from(pixel_buffer)) }; + (Some(pixel_buffer), Some(admission_lifetime)) + })? + } else { + (None, None) + }; Ok(RetainedNativeSample { attachments, pixel_buffer, + admission_lifetime, cursor_composed, }) } +fn with_admitted_surface( + pool: &PoolObservation, + iosurface_id: u32, + allocation_bytes: u64, + retain: impl FnOnce(PoolBackingLifetime) -> T, +) -> Result { + let admission_lifetime = pool(iosurface_id, allocation_bytes)?; + Ok(retain(admission_lifetime)) +} + +fn borrowed_pixel_buffer(sample: &CMSampleBuffer) -> Result<&CVPixelBuffer, MacosCaptureError> { + #[link(name = "CoreMedia", kind = "framework")] + unsafe extern "C-unwind" { + #[link_name = "CMSampleBufferGetImageBuffer"] + fn sample_buffer_get_image_buffer( + sample: &CMSampleBuffer, + ) -> Option>; + } + + // SAFETY: the sample is valid and ready, and ScreenCaptureKit keeps the + // borrowed image buffer alive for this callback invocation. + unsafe { sample_buffer_get_image_buffer(sample).map(|pixel_buffer| pixel_buffer.as_ref()) } + .ok_or(MacosCaptureError::MissingFramePayload) +} + +fn borrowed_surface_identity( + pixel_buffer: &CVPixelBuffer, +) -> Result<(u32, u64), MacosCaptureError> { + #[link(name = "CoreVideo", kind = "framework")] + unsafe extern "C-unwind" { + #[link_name = "CVPixelBufferGetIOSurface"] + fn pixel_buffer_get_io_surface( + pixel_buffer: Option<&CVPixelBuffer>, + ) -> Option>; + } + + // SAFETY: the borrowed pixel buffer remains live for this callback, and + // Core Video returns its non-owning IOSurface reference. + let surface = + unsafe { pixel_buffer_get_io_surface(Some(pixel_buffer)).map(|surface| surface.as_ref()) } + .ok_or(MacosCaptureError::MissingIoSurface)?; + let iosurface_id = surface.id(); + let allocation_bytes = + u64::try_from(surface.alloc_size()).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + if iosurface_id == 0 || allocation_bytes == 0 { + return Err(MacosCaptureError::InvalidSurface); + } + Ok((iosurface_id, allocation_bytes)) +} + fn publish_decoded_result( result: Result, epoch: u64, @@ -229,9 +320,9 @@ fn publish_decoded_result( } } -#[derive(Debug)] struct CaptureOutputIvars { samples: LatestSampleInput>, + pool: PoolObservation, shared: Arc, streams: Weak, epoch: u64, @@ -266,10 +357,26 @@ define_class!( return; } let sample = if output_type == SCStreamOutputType::Screen { - retain_sample(sample_buffer, self.ivars().cursor_composed) + retain_sample( + sample_buffer, + self.ivars().cursor_composed, + &self.ivars().pool, + ) } else { Err(MacosCaptureError::UnexpectedStreamOutputType(output_type.0)) }; + let sample = match sample { + Err(error @ MacosCaptureError::ScreenResourceExhausted { .. }) => { + handle_pool_admission_error( + &self.ivars().streams, + self.ivars().epoch, + Arc::clone(&self.ivars().shared), + error, + ); + return; + } + sample => sample, + }; if self.ivars().samples.publish(sample) == SamplePublishOutcome::Superseded { self.ivars() .shared @@ -321,6 +428,7 @@ impl CaptureOutput { fn new( epoch: u64, samples: LatestSampleInput>, + pool: PoolObservation, shared: Arc, streams: Weak, cursor_composed: bool, @@ -328,6 +436,7 @@ impl CaptureOutput { ) -> Retained { let this = Self::alloc().set_ivars(CaptureOutputIvars { samples, + pool, shared, streams, epoch, @@ -368,8 +477,12 @@ impl NativeStream { epoch: u64, shared: Arc, streams: Weak, + reserve_pool: &PoolReservationFactory, ) -> Result { - let (configuration, display_filter) = stream_configuration(filter, request)?; + let (configuration, display_filter, extent, pixel_format) = + stream_configuration(filter, request)?; + let quote = conservative_pool_quote(extent, pixel_format)?; + let pool = reserve_pool(quote.per_surface_bytes, quote.stream_metadata_bytes)?; let selection = selection_from_filter(filter)?; // SAFETY: The picker callback supplies a live filter. Retaining it // preserves the immutable selection through stream retirement. @@ -394,6 +507,7 @@ impl NativeStream { let output = CaptureOutput::new( epoch, samples, + pool, shared, streams, request.cursor_composed, @@ -517,6 +631,7 @@ impl StreamSlot { self: &Arc, filter: &SCContentFilter, request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, epoch: u64, ) -> Result<(), MacosCaptureError> { let candidate = NativeStream::prepare( @@ -525,6 +640,7 @@ impl StreamSlot { epoch, Arc::clone(&self.shared), Arc::downgrade(self), + reserve_pool, )?; let stream = candidate.stream.clone(); let replaced = lock(&self.state).candidate.replace(candidate); @@ -711,10 +827,48 @@ fn handle_stream_error( } } +fn handle_pool_admission_error( + streams: &Weak, + epoch: u64, + shared: Arc, + error: MacosCaptureError, +) { + shared.counters.record_drop(&error); + let Some(streams) = streams.upgrade() else { + return; + }; + let (role, retired) = streams.remove(epoch); + let preserve_current = role == StreamRole::Candidate && streams.has_current(); + if preserve_current { + shared.set_status(MacosProtectedSourceState::Live); + shared.publish_recoverable_error(error); + } else if role != StreamRole::Stale { + shared.set_status(MacosProtectedSourceState::Failed); + shared.publish_error(error); + } + let Some(retired) = retired else { + return; + }; + let stop_shared = Arc::clone(&shared); + if let Err(spawn_error) = std::thread::Builder::new() + .name("hypercolor-macos-screen-resource-stop".to_owned()) + .spawn(move || { + if let Err(error) = retired.stop() { + stop_shared.publish_recoverable_error(error); + } + }) + { + shared.publish_recoverable_error(MacosCaptureError::CaptureWorkerStartFailed( + spawn_error.to_string(), + )); + } +} + struct PickerObserverIvars { shared: Arc, streams: Arc, request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, } define_class!( @@ -757,6 +911,7 @@ define_class!( &self.ivars().streams, &self.ivars().shared, self.ivars().request, + &self.ivars().reserve_pool, filter, ); } @@ -790,12 +945,14 @@ impl PickerObserver { mtm: MainThreadMarker, request: MacosStreamRequest, shared: Arc, + reserve_pool: PoolReservationFactory, ) -> Retained { let streams = StreamSlot::new(Arc::clone(&shared)); let this = mtm.alloc::().set_ivars(PickerObserverIvars { shared, streams, request, + reserve_pool, }); // SAFETY: NSObject has no additional initialization requirements for // this main-thread observer subclass. @@ -807,6 +964,7 @@ impl PickerObserver { &self.ivars().streams, &self.ivars().shared, self.ivars().request, + &self.ivars().reserve_pool, filter, ); } @@ -856,10 +1014,11 @@ fn accept_filter( streams: &Arc, shared: &Arc, request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, filter: &SCContentFilter, ) { if shared.capture_active() { - stage_filter(streams, shared, request, filter); + stage_filter(streams, shared, request, reserve_pool, filter); } else if let Err(error) = streams.store_selection(filter) { handle_filter_error(streams, shared, error); } else { @@ -871,11 +1030,12 @@ fn stage_filter( streams: &Arc, shared: &Arc, request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, filter: &SCContentFilter, ) { let result = streams .allocate_epoch() - .and_then(|epoch| streams.stage_candidate(filter, request, epoch)); + .and_then(|epoch| streams.stage_candidate(filter, request, reserve_pool, epoch)); if let Err(error) = result { handle_filter_error(streams, shared, error); } @@ -918,13 +1078,32 @@ impl MacosScreenCaptureSession { request: MacosStreamRequest, selector: MacosCaptureSelector, ) -> Result { + Self::new_with_pool_admission(request, selector, |_, _| { + Ok(|_, _| Ok(Arc::new(()) as PoolBackingLifetime)) + }) + } + + pub fn new_with_pool_admission( + request: MacosStreamRequest, + selector: MacosCaptureSelector, + reserve_pool: F, + ) -> Result + where + F: Fn(u64, u64) -> Result + Send + Sync + 'static, + A: Fn(u32, u64) -> Result, MacosCaptureError> + Send + Sync + 'static, + { request.cadence.timescale()?; - dispatch2::run_on_main(move |mtm| Self::new_on_main(request, selector, mtm)) + let reserve_pool = Arc::new(move |surface_bytes, metadata_bytes| { + let observer = reserve_pool(surface_bytes, metadata_bytes)?; + Ok(Arc::new(observer) as PoolObservation) + }) as PoolReservationFactory; + dispatch2::run_on_main(move |mtm| Self::new_on_main(request, selector, reserve_pool, mtm)) } fn new_on_main( request: MacosStreamRequest, selector: MacosCaptureSelector, + reserve_pool: PoolReservationFactory, mtm: MainThreadMarker, ) -> Result { let authorized = CGPreflightScreenCaptureAccess(); @@ -934,7 +1113,7 @@ impl MacosScreenCaptureSession { MacosProtectedSourceState::NeedsUserAction }; let shared = Arc::new(SessionShared::new(status, selector)); - let observer = PickerObserver::new(mtm, request, Arc::clone(&shared)); + let observer = PickerObserver::new(mtm, request, Arc::clone(&shared), reserve_pool); let streams = Arc::clone(&observer.ivars().streams); // SAFETY: These are main-thread ScreenCaptureKit setup calls. The // observer remains retained by this session until it is removed. @@ -1047,6 +1226,8 @@ impl MacosScreenCaptureSession { Arc::clone(&self.streams), Arc::clone(&self.shared), self.request, + self.main + .get_on_main(|main| Arc::clone(&main.observer.ivars().reserve_pool)), selector, ) } @@ -1056,6 +1237,7 @@ fn resolve_display_selector( streams: Arc, shared: Arc, request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, selector: MacosCaptureSelector, ) -> Result<(), MacosCaptureError> { let resolution_epoch = shared.begin_resolution()?; @@ -1081,7 +1263,9 @@ fn resolve_display_selector( return; } match result { - Ok(filter) => accept_filter(&streams, &shared, request, &filter), + Ok(filter) => { + accept_filter(&streams, &shared, request, &reserve_pool, &filter); + } Err(error) => handle_filter_error(&streams, &shared, error), } }, @@ -1220,7 +1404,15 @@ impl Drop for MainThreadSession { fn stream_configuration( filter: &SCContentFilter, request: MacosStreamRequest, -) -> Result<(Retained, bool), MacosCaptureError> { +) -> Result< + ( + Retained, + bool, + MacosPixelExtent, + MacosCapturePixelFormat, + ), + MacosCaptureError, +> { // SAFETY: Picker callbacks supply a live SCContentFilter for the duration // of configuration, and returned collection values are retained. let (content_rect, point_pixel_scale, display_filter) = unsafe { @@ -1270,7 +1462,76 @@ fn stream_configuration( configuration.setPixelFormat(0x4247_5241); configuration }; - Ok((configuration, display_filter)) + Ok(( + configuration, + display_filter, + extent, + MacosCapturePixelFormat::Bgra8, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MacosStreamPoolQuote { + per_surface_bytes: u64, + stream_metadata_bytes: u64, +} + +fn conservative_pool_quote( + extent: MacosPixelExtent, + format: MacosCapturePixelFormat, +) -> Result { + let plane_bytes = match format { + MacosCapturePixelFormat::Bgra8 | MacosCapturePixelFormat::Argb2101010 => { + conservative_plane_bytes(extent, 4)? + } + MacosCapturePixelFormat::Rgba16Float => conservative_plane_bytes(extent, 8)?, + MacosCapturePixelFormat::Yuv420VideoRange | MacosCapturePixelFormat::Yuv420FullRange => { + let chroma = MacosPixelExtent { + width: extent.width.div_ceil(2), + height: extent.height.div_ceil(2), + }; + conservative_plane_bytes(extent, 1)? + .checked_add(conservative_plane_bytes(chroma, 2)?) + .ok_or(MacosCaptureError::ArithmeticOverflow)? + } + MacosCapturePixelFormat::Yuv44410BiPlanar => conservative_plane_bytes(extent, 2)? + .checked_add(conservative_plane_bytes(extent, 4)?) + .ok_or(MacosCaptureError::ArithmeticOverflow)?, + }; + let per_surface_bytes = align_up(plane_bytes, MACOS_IOSURFACE_ALLOCATION_ALIGNMENT)?; + let stream_metadata_bytes = [ + std::mem::size_of::(), + std::mem::size_of::(), + std::mem::size_of::() * MACOS_STREAM_QUEUE_DEPTH, + ] + .into_iter() + .try_fold(0_u64, |total, bytes| { + total.checked_add(u64::try_from(bytes).ok()?) + }) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + Ok(MacosStreamPoolQuote { + per_surface_bytes, + stream_metadata_bytes, + }) +} + +fn conservative_plane_bytes( + extent: MacosPixelExtent, + bytes_per_pixel: u64, +) -> Result { + let row_bytes = u64::from(extent.width) + .checked_mul(bytes_per_pixel) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + align_up(row_bytes, MACOS_IOSURFACE_ROW_ALIGNMENT)? + .checked_mul(u64::from(extent.height)) + .ok_or(MacosCaptureError::ArithmeticOverflow) +} + +fn align_up(value: u64, alignment: u64) -> Result { + value + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(MacosCaptureError::ArithmeticOverflow) } fn classify_stream_error(error: &NSError) -> MacosProtectedSourceState { @@ -1327,7 +1588,11 @@ fn decode_sample( let pixel_buffer = sample .pixel_buffer .ok_or(MacosCaptureError::MissingFramePayload)?; - let frame = decode_complete_frame(pixel_buffer, sample.cursor_composed)?; + let frame = decode_complete_frame( + pixel_buffer, + sample.admission_lifetime, + sample.cursor_composed, + )?; decoder.decode(MacosRawCaptureSample { frame: Some(frame), attachments: sample.attachments, @@ -1336,6 +1601,7 @@ fn decode_sample( fn decode_complete_frame( pixel_buffer: CFRetained, + admission_lifetime: Option, cursor_composed: bool, ) -> Result { let storage_extent = extent( @@ -1346,7 +1612,7 @@ fn decode_complete_frame( let pixel_format = MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?; let planes = planes(&pixel_buffer, storage_extent)?; let color = colorimetry(&pixel_buffer, pixel_format_fourcc, pixel_format)?; - let surface = MacosCaptureSurface::from_pixel_buffer(pixel_buffer)?; + let surface = MacosCaptureSurface::from_pixel_buffer(pixel_buffer, admission_lifetime)?; Ok(MacosRawCompleteFrame { storage_extent, @@ -1692,3 +1958,41 @@ fn exact_u32(value: f64) -> Option { } Some(value as u32) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::{ + MacosCaptureError, MacosCapturePixelFormat, MacosPixelExtent, PoolBackingLifetime, + PoolObservation, conservative_pool_quote, with_admitted_surface, + }; + + #[test] + fn conservative_bgra_pool_quote_covers_aligned_native_storage() { + let extent = MacosPixelExtent::new(3_840, 2_160).expect("4K extent is valid"); + let quote = conservative_pool_quote(extent, MacosCapturePixelFormat::Bgra8) + .expect("4K quote should fit"); + assert!(quote.per_surface_bytes >= 3_840 * 2_160 * 4); + assert_eq!(quote.per_surface_bytes % (16 * 1024), 0); + assert!(quote.stream_metadata_bytes > 0); + } + + #[test] + fn rejected_surface_never_reaches_the_retain_operation() { + let pool = Arc::new(|_, _| -> Result { + Err(MacosCaptureError::ScreenResourceExhausted { + requested_bytes: 128, + available_bytes: 64, + }) + }) as PoolObservation; + let retained = AtomicBool::new(false); + + assert!(matches!( + with_admitted_surface(&pool, 7, 128, |_| retained.store(true, Ordering::Release)), + Err(MacosCaptureError::ScreenResourceExhausted { .. }) + )); + assert!(!retained.load(Ordering::Acquire)); + } +} From 681d24785ca6d95bc552d7f79c7d4b4928b90a63 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 21:59:32 -0700 Subject: [PATCH 060/144] feat(macos): expose current process audit identity Keep Mach task inspection inside the audited macOS input interop crate and publish only a bounded plain-Rust token representation. Daemon ownership can now record the real process audit token without introducing unsafe code into the daemon. Co-Authored-By: Nova (GPT-5) --- Cargo.lock | 1 + Cargo.toml | 1 + crates/hypercolor-macos-input/Cargo.toml | 1 + crates/hypercolor-macos-input/src/lib.rs | 2 + crates/hypercolor-macos-input/src/process.rs | 41 +++++++++++++++++++ crates/hypercolor-macos-input/src/shared.rs | 2 + .../tests/process_identity_tests.rs | 25 +++++++++++ 7 files changed, 73 insertions(+) create mode 100644 crates/hypercolor-macos-input/src/process.rs create mode 100644 crates/hypercolor-macos-input/tests/process_identity_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9023b699a..3bd631027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5369,6 +5369,7 @@ name = "hypercolor-macos-input" version = "0.3.1" dependencies = [ "crossbeam-queue", + "mach2 0.5.0", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", diff --git a/Cargo.toml b/Cargo.toml index 3342a8681..8cb4d3134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,6 +174,7 @@ zerocopy = { version = "0.8", features = ["derive"] } # Network / device backends if-addrs = "0.13.4" mdns-sd = "0.11" +mach2 = "0.5" tonic = "0.12" prost = "0.13" nusb = { version = "0.2.2", features = ["tokio"] } diff --git a/crates/hypercolor-macos-input/Cargo.toml b/crates/hypercolor-macos-input/Cargo.toml index a61a528cf..114c88b3c 100644 --- a/crates/hypercolor-macos-input/Cargo.toml +++ b/crates/hypercolor-macos-input/Cargo.toml @@ -29,3 +29,4 @@ objc2-core-graphics = { workspace = true, features = [ "CGEvent", "CGEventTypes", ] } +mach2 = { workspace = true } diff --git a/crates/hypercolor-macos-input/src/lib.rs b/crates/hypercolor-macos-input/src/lib.rs index b6b9aeb67..f145f7015 100644 --- a/crates/hypercolor-macos-input/src/lib.rs +++ b/crates/hypercolor-macos-input/src/lib.rs @@ -5,6 +5,7 @@ //! remains portable and deterministic in `hypercolor-core`. mod decode; +mod process; mod queue; mod shared; @@ -12,6 +13,7 @@ pub use decode::{ NX_SUBTYPE_AUX_CONTROL_BUTTONS, decode_button_event, decode_media_key, decode_momentum_phase, decode_scroll_phase, event_masks, }; +pub use process::current_process_audit_token_identity; pub use shared::{ EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, MacosInputEvent, MacosInputGapReason, MacosInputResult, MacosMediaKey, MacosModifierFlags, diff --git a/crates/hypercolor-macos-input/src/process.rs b/crates/hypercolor-macos-input/src/process.rs new file mode 100644 index 000000000..cce902aa6 --- /dev/null +++ b/crates/hypercolor-macos-input/src/process.rs @@ -0,0 +1,41 @@ +#[cfg(target_os = "macos")] +use mach2::message::audit_token_t; +#[cfg(target_os = "macos")] +use mach2::task::task_info; +#[cfg(target_os = "macos")] +use mach2::task_info::{TASK_AUDIT_TOKEN, TASK_AUDIT_TOKEN_COUNT, task_info_t}; +#[cfg(target_os = "macos")] +use mach2::traps::mach_task_self; + +use crate::{MacosInputError, MacosInputResult}; + +/// Return the current process audit token as eight fixed-width hexadecimal words. +pub fn current_process_audit_token_identity() -> MacosInputResult { + #[cfg(not(target_os = "macos"))] + { + Err(MacosInputError::UnsupportedPlatform) + } + + #[cfg(target_os = "macos")] + { + let mut token = audit_token_t::default(); + let mut count = TASK_AUDIT_TOKEN_COUNT; + // SAFETY: task_info writes exactly TASK_AUDIT_TOKEN_COUNT natural_t + // values into a correctly aligned audit_token_t owned by this call. + let result = unsafe { + task_info( + mach_task_self(), + TASK_AUDIT_TOKEN, + std::ptr::from_mut(&mut token).cast::() as task_info_t, + &mut count, + ) + }; + if result != mach2::kern_return::KERN_SUCCESS { + return Err(MacosInputError::AuditToken(result)); + } + if count != TASK_AUDIT_TOKEN_COUNT { + return Err(MacosInputError::AuditToken(result)); + } + Ok(token.val.map(|word| format!("{word:08x}")).join(":")) + } +} diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs index b29dcc5d8..9dc50bfbc 100644 --- a/crates/hypercolor-macos-input/src/shared.rs +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -275,6 +275,8 @@ pub enum MacosInputError { TapCreation(&'static str), #[error("failed to create the {0} event-tap run-loop source")] RunLoopSource(&'static str), + #[error("failed to read the current process audit token: Mach error {0}")] + AuditToken(i32), } pub type MacosInputResult = Result; diff --git a/crates/hypercolor-macos-input/tests/process_identity_tests.rs b/crates/hypercolor-macos-input/tests/process_identity_tests.rs new file mode 100644 index 000000000..6aa0289b0 --- /dev/null +++ b/crates/hypercolor-macos-input/tests/process_identity_tests.rs @@ -0,0 +1,25 @@ +#[cfg(not(target_os = "macos"))] +use hypercolor_macos_input::MacosInputError; +use hypercolor_macos_input::current_process_audit_token_identity; + +#[test] +fn audit_token_identity_is_platform_explicit_and_bounded() { + #[cfg(target_os = "macos")] + { + let identity = current_process_audit_token_identity() + .expect("current macOS process exposes an audit token"); + let words = identity.split(':').collect::>(); + assert_eq!(words.len(), 8); + assert!( + words.iter().all(|word| { + word.len() == 8 && word.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + ); + } + + #[cfg(not(target_os = "macos"))] + assert_eq!( + current_process_audit_token_identity(), + Err(MacosInputError::UnsupportedPlatform) + ); +} From edf82180ba12c5b4997c975ab0ea05b751f15599 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 22:12:37 -0700 Subject: [PATCH 061/144] feat(macos): persist daemon owner handovers Persist per-user daemon identity and monotonic epochs behind one stable coordination lock. Durable handover journals encode closed operations and explicit forward and rollback recovery phases. Atomic same-directory replacement syncs file contents and parent metadata. Strict decoding rejects malformed, oversized, or unknown state before mutation, while conflicts coalesce on the Spec 76 identity tuple. Co-Authored-By: Nova (OpenAI Codex) --- crates/hypercolor-daemon/src/lib.rs | 1 + crates/hypercolor-daemon/src/macos_owner.rs | 1167 +++++++++++++++++ .../tests/macos_owner_tests.rs | 594 +++++++++ 3 files changed, 1762 insertions(+) create mode 100644 crates/hypercolor-daemon/src/macos_owner.rs create mode 100644 crates/hypercolor-daemon/tests/macos_owner_tests.rs diff --git a/crates/hypercolor-daemon/src/lib.rs b/crates/hypercolor-daemon/src/lib.rs index a82fcd37a..9bbbd1d94 100644 --- a/crates/hypercolor-daemon/src/lib.rs +++ b/crates/hypercolor-daemon/src/lib.rs @@ -21,6 +21,7 @@ pub mod layout_auto_exclusions; pub mod layout_store; pub mod library; pub mod logical_devices; +pub mod macos_owner; pub mod mcp; pub mod mdns; pub mod network; diff --git a/crates/hypercolor-daemon/src/macos_owner.rs b/crates/hypercolor-daemon/src/macos_owner.rs new file mode 100644 index 000000000..0eba7b176 --- /dev/null +++ b/crates/hypercolor-daemon/src/macos_owner.rs @@ -0,0 +1,1167 @@ +//! Durable macOS daemon ownership and handover state. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +/// Current owner-record schema version. +pub const MACOS_OWNER_RECORD_SCHEMA_VERSION: u32 = 1; +/// Current handover-journal schema version. +pub const MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION: u32 = 1; +/// Stable owner-record file name within the per-user data directory. +pub const MACOS_OWNER_RECORD_FILE_NAME: &str = "macos-daemon-owner.json"; +/// Stable handover-journal file name within the per-user data directory. +pub const MACOS_HANDOVER_JOURNAL_FILE_NAME: &str = "macos-daemon-handover.json"; +/// Stable coordination-lock file name shared by both durable artifacts. +pub const MACOS_OWNER_COORDINATION_LOCK_FILE_NAME: &str = "macos-daemon-owner.lock"; +/// Maximum UTF-8 byte length for an audit-token identity. +pub const MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES: usize = 256; +/// Maximum UTF-8 byte length for a diagnostic executable path. +pub const MAX_MACOS_EXECUTABLE_PATH_BYTES: usize = 4_096; +/// Maximum UTF-8 byte length for a designated-requirement hash. +pub const MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES: usize = 256; +/// Maximum byte length accepted for either durable JSON artifact. +pub const MAX_MACOS_OWNER_ARTIFACT_BYTES: usize = 256 * 1_024; +/// Maximum number of closed rollback operations in one journal. +pub const MAX_MACOS_HANDOVER_OPERATIONS: usize = 64; +const MAX_TEMPORARY_CREATE_ATTEMPTS: usize = 64; + +static TEMPORARY_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// A daemon topology that can own protected macOS capabilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosDaemonOwner { + /// Daemon supervised by the packaged app. + AppSidecar, + /// Daemon managed by Hypercolor's direct per-user launchd service. + DirectLaunchd, + /// Daemon managed by Homebrew services. + Homebrew, + /// Daemon started directly from a terminal. + Standalone, +} + +/// An external daemon topology selected by the local app. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosExternalOwnerMode { + /// Connect to Hypercolor's direct per-user launchd service. + DirectLaunchd, + /// Connect to the Homebrew-managed service. + Homebrew, +} + +/// Bounded diagnostic identity for the process that attempted ownership. +/// +/// The executable path is diagnostic data only. It is never an executable, +/// command, or recovery authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerIdentity { + /// Stable representation of the process audit token. + pub audit_token_identity: String, + /// Absolute path observed for the process executable. + pub executable_path: PathBuf, + /// Hash of the process designated requirement. + pub designated_requirement_hash: String, + /// Process identifier observed with this identity. + pub pid: u32, +} + +impl MacosOwnerIdentity { + /// Validate and construct a diagnostic process identity. + pub fn new( + audit_token_identity: impl Into, + executable_path: impl Into, + designated_requirement_hash: impl Into, + pid: u32, + ) -> Result { + let identity = Self { + audit_token_identity: audit_token_identity.into(), + executable_path: executable_path.into(), + designated_requirement_hash: designated_requirement_hash.into(), + pid, + }; + validate_owner_identity(&identity)?; + Ok(identity) + } +} + +impl<'de> Deserialize<'de> for MacosOwnerIdentity { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct RawIdentity { + audit_token_identity: String, + executable_path: PathBuf, + designated_requirement_hash: String, + pid: u32, + } + + let raw = RawIdentity::deserialize(deserializer)?; + Self::new( + raw.audit_token_identity, + raw.executable_path, + raw.designated_requirement_hash, + raw.pid, + ) + .map_err(serde::de::Error::custom) + } +} + +/// Bounded conflict status for a contender that failed to acquire the guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerConflict { + /// Owner holding the guard when the conflict was observed. + pub active_owner: MacosDaemonOwner, + /// Active owner's acquisition epoch. + pub active_epoch: u64, + /// Topology of the losing contender. + pub contender_owner: MacosDaemonOwner, + /// Millisecond timestamp supplied by the observer. + pub observed_at_ms: u64, +} + +/// Durable conflict record including the contender's diagnostic identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerConflictRecord { + /// Owner holding the guard when the conflict was observed. + pub active_owner: MacosDaemonOwner, + /// Active owner's acquisition epoch. + pub active_epoch: u64, + /// Topology of the losing contender. + pub contender_owner: MacosDaemonOwner, + /// Diagnostic identity of the losing contender. + pub contender_identity: MacosOwnerIdentity, + /// Millisecond timestamp supplied by the observer. + pub observed_at_ms: u64, +} + +impl MacosOwnerConflictRecord { + fn has_same_identity(&self, other: &Self) -> bool { + self.active_owner == other.active_owner + && self.active_epoch == other.active_epoch + && self.contender_owner == other.contender_owner + && self.contender_identity.executable_path == other.contender_identity.executable_path + && self.contender_identity.designated_requirement_hash + == other.contender_identity.designated_requirement_hash + } + + const fn snapshot(&self) -> MacosOwnerConflict { + MacosOwnerConflict { + active_owner: self.active_owner, + active_epoch: self.active_epoch, + contender_owner: self.contender_owner, + observed_at_ms: self.observed_at_ms, + } + } +} + +/// Bounded status snapshot derived from the durable owner record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerSnapshot { + /// Current daemon owner. + pub active_owner: MacosDaemonOwner, + /// Current owner's acquisition epoch. + pub owner_epoch: u64, + /// Latest distinct owner conflict, when present. + pub conflict: Option, +} + +/// Versioned durable owner state for one macOS user. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerRecord { + /// Durable schema version. + pub schema_version: u32, + /// Current daemon owner. + pub active_owner: MacosDaemonOwner, + /// Diagnostic identity of the current owner process. + pub active_identity: MacosOwnerIdentity, + /// Monotonically increasing owner acquisition epoch. + pub owner_epoch: u64, + /// Latest distinct losing contender, when present. + pub conflict: Option, + /// Persisted app preference for an externally managed daemon. + pub selected_external_owner: Option, +} + +impl MacosOwnerRecord { + /// Construct an initial owner record at epoch one. + pub const fn new( + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, + selected_external_owner: Option, + ) -> Self { + Self { + schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, + active_owner, + active_identity, + owner_epoch: 1, + conflict: None, + selected_external_owner, + } + } + + /// Return the bounded status surface for this record. + pub fn snapshot(&self) -> MacosOwnerSnapshot { + MacosOwnerSnapshot { + active_owner: self.active_owner, + owner_epoch: self.owner_epoch, + conflict: self + .conflict + .as_ref() + .map(MacosOwnerConflictRecord::snapshot), + } + } +} + +/// Result of publishing a contender against the current owner epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosConflictUpdate { + /// A distinct contender state was durably recorded. + Recorded(MacosOwnerSnapshot), + /// The contender matched the existing conflict identity. + Coalesced(MacosOwnerSnapshot), +} + +impl MacosConflictUpdate { + /// Return the owner snapshot associated with this update. + pub const fn snapshot(self) -> MacosOwnerSnapshot { + match self { + Self::Recorded(snapshot) | Self::Coalesced(snapshot) => snapshot, + } + } +} + +/// Installed-state snapshot captured before a daemon handover. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosAutostartStates { + /// Whether app-sidecar autostart was enabled. + pub app_sidecar: bool, + /// Whether the direct launchd service was enabled. + pub direct_launchd: bool, + /// Whether the Homebrew service was enabled. + pub homebrew: bool, +} + +impl MacosAutostartStates { + /// Construct an installed-state snapshot. + pub const fn new(app_sidecar: bool, direct_launchd: bool, homebrew: bool) -> Self { + Self { + app_sidecar, + direct_launchd, + homebrew, + } + } +} + +/// A validated path-free handover or rollback operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum MacosHandoverOperation { + /// Set app-sidecar autostart state. + SetAppSidecarAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the app-supervised sidecar. + FlushAndStopAppSidecar {}, + /// Start the app-supervised sidecar. + StartAppSidecar {}, + /// Set direct-launchd autostart state. + SetDirectLaunchdAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the direct launchd service. + FlushAndStopDirectLaunchd {}, + /// Start the direct launchd service. + StartDirectLaunchd {}, + /// Set Homebrew-service autostart state. + SetHomebrewAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the Homebrew service. + FlushAndStopHomebrew {}, + /// Start the Homebrew service. + StartHomebrew {}, + /// Await user-directed termination of a standalone owner. + AwaitStandaloneExit { + /// Authoritative process identifier shown to the user. + pid: u32, + }, +} + +/// Durable handover phase used to resume or reverse interrupted work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosHandoverPhase { + /// Journal exists and no external mutation has begun. + Prepared, + /// Nonselected autostarts have been disabled. + AutostartsConfigured, + /// Stop of the outgoing managed owner has been requested. + StopRequested, + /// The outgoing managed owner has stopped. + OutgoingOwnerStopped, + /// The coordinator is waiting for the instance guard to release. + AwaitingGuardRelease, + /// The instance guard is free. + GuardReleased, + /// Startup of the requested owner has been requested. + StartRequested, + /// The requested owner has started. + RequestedOwnerStarted, + /// The requested owner is ready for the ownership commit. + CommitPending, + /// The requested owner committed the handover. + Committed, + /// Forward progress failed and rollback must begin or resume. + RollbackPending, + /// Prior autostart state has been restored. + RollbackAutostartsRestored, + /// Stop of a partially started requested owner was requested. + RollbackStopRequested, + /// The partially started requested owner has stopped. + RollbackOwnerStopped, + /// Rollback is waiting for the instance guard to release. + RollbackAwaitingGuardRelease, + /// The instance guard is free for the prior owner. + RollbackGuardReleased, + /// Restart of the prior managed owner was requested. + RollbackStartRequested, + /// The prior managed owner has restarted. + PriorOwnerStarted, + /// The prior owner is ready for the rollback commit. + RollbackCommitPending, + /// The prior owner committed rollback completion. + RolledBack, +} + +impl MacosHandoverPhase { + /// Every stable journal phase, in forward then rollback order. + pub const ALL: [Self; 20] = [ + Self::Prepared, + Self::AutostartsConfigured, + Self::StopRequested, + Self::OutgoingOwnerStopped, + Self::AwaitingGuardRelease, + Self::GuardReleased, + Self::StartRequested, + Self::RequestedOwnerStarted, + Self::CommitPending, + Self::Committed, + Self::RollbackPending, + Self::RollbackAutostartsRestored, + Self::RollbackStopRequested, + Self::RollbackOwnerStopped, + Self::RollbackAwaitingGuardRelease, + Self::RollbackGuardReleased, + Self::RollbackStartRequested, + Self::PriorOwnerStarted, + Self::RollbackCommitPending, + Self::RolledBack, + ]; + + /// Whether this phase closes the transaction. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Committed | Self::RolledBack) + } +} + +/// Stable, path-free identifier for one handover transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct MacosHandoverTransactionId(String); + +impl MacosHandoverTransactionId { + /// Validate and construct a handover transaction identifier. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if is_valid_transaction_id(&value) { + Ok(Self(value)) + } else { + Err(MacosOwnerStoreError::InvalidTransactionId) + } + } + + /// Borrow the validated identifier. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for MacosHandoverTransactionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// Versioned durable journal for a local daemon-owner handover. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosHandoverJournal { + /// Durable schema version. + pub schema_version: u32, + /// Monotonic mutation count within this journal transaction. + pub journal_revision: u64, + /// Stable transaction identifier. + pub transaction_id: MacosHandoverTransactionId, + /// Desired owner after a successful handover. + pub requested_owner: MacosDaemonOwner, + /// Owner to restore if the handover rolls back. + pub prior_owner: MacosDaemonOwner, + /// Installed states to restore during rollback. + pub prior_autostart_states: MacosAutostartStates, + /// Closed operations recovery is permitted to execute. + pub allowed_rollback_operations: Vec, + /// Last durably completed transaction phase. + pub phase: MacosHandoverPhase, + /// Owner epoch observed before mutation began. + pub active_epoch: u64, + /// Contender epoch associated with the request, when one exists. + pub contender_epoch: Option, + /// Standalone process whose user-directed exit is pending. + pub pending_standalone_pid: Option, +} + +impl MacosHandoverJournal { + /// Construct a prepared journal. The store assigns its first revision. + pub fn new( + transaction_id: MacosHandoverTransactionId, + requested_owner: MacosDaemonOwner, + prior_owner: MacosDaemonOwner, + prior_autostart_states: MacosAutostartStates, + allowed_rollback_operations: Vec, + active_epoch: u64, + contender_epoch: Option, + pending_standalone_pid: Option, + ) -> Self { + Self { + schema_version: MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, + journal_revision: 0, + transaction_id, + requested_owner, + prior_owner, + prior_autostart_states, + allowed_rollback_operations, + phase: MacosHandoverPhase::Prepared, + active_epoch, + contender_epoch, + pending_standalone_pid, + } + } +} + +/// Typed durable owner-store failure. +#[derive(Debug, thiserror::Error)] +pub enum MacosOwnerStoreError { + /// The explicit data directory could not be created. + #[error("failed to create macOS owner data directory {path}: {source}")] + CreateDirectory { + /// Data directory. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The stable coordination lock could not be opened. + #[error("failed to open macOS owner coordination lock {path}: {source}")] + OpenCoordinationLock { + /// Lock path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The stable coordination lock could not be acquired. + #[error("failed to acquire macOS owner coordination lock {path}: {source}")] + AcquireCoordinationLock { + /// Lock path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A durable artifact could not be read. + #[error("failed to read macOS {artifact} at {path}: {source}")] + Read { + /// Artifact kind. + artifact: &'static str, + /// Artifact path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A durable artifact could not be decoded. + #[error("failed to decode macOS {artifact}: {source}")] + Decode { + /// Artifact kind. + artifact: &'static str, + /// JSON failure. + #[source] + source: serde_json::Error, + }, + /// A durable artifact has an unsupported schema version. + #[error("unsupported macOS {artifact} schema version {found}; expected {expected}")] + UnsupportedVersion { + /// Artifact kind. + artifact: &'static str, + /// Version found on disk. + found: u32, + /// Version supported by this build. + expected: u32, + }, + /// A durable artifact violates a semantic invariant. + #[error("invalid macOS {artifact}: {detail}")] + InvalidArtifact { + /// Artifact kind. + artifact: &'static str, + /// Stable validation detail. + detail: &'static str, + }, + /// JSON serialization failed before any bytes were replaced. + #[error("failed to serialize macOS {artifact}: {source}")] + Encode { + /// Artifact kind. + artifact: &'static str, + /// JSON failure. + #[source] + source: serde_json::Error, + }, + /// A same-directory temporary file could not be created. + #[error("failed to create temporary file beside {path}: {source}")] + CreateTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A complete temporary artifact could not be written. + #[error("failed to write temporary file for {path}: {source}")] + WriteTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// Temporary artifact contents could not be synced. + #[error("failed to sync temporary file for {path}: {source}")] + SyncTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The durable destination could not be atomically replaced. + #[error("failed to atomically replace {path}: {source}")] + Replace { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The parent directory could not be synced after replacement. + #[cfg(unix)] + #[error("failed to sync parent directory {path}: {source}")] + SyncDirectory { + /// Parent directory. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// No owner record exists for the requested mutation. + #[error("macOS owner record does not exist")] + MissingOwnerRecord, + /// The owner acquisition epoch cannot advance further. + #[error("macOS owner epoch overflow")] + OwnerEpochOverflow, + /// A nonterminal handover journal must be recovered first. + #[error("macOS handover {transaction_id} is still pending")] + HandoverAlreadyPending { + /// Existing transaction identifier. + transaction_id: String, + }, + /// No handover journal exists for the requested mutation. + #[error("macOS handover journal does not exist")] + MissingHandoverJournal, + /// A caller attempted to advance a different transaction. + #[error("macOS handover transaction does not match the durable journal")] + HandoverTransactionMismatch, + /// The handover journal revision cannot advance further. + #[error("macOS handover journal revision overflow")] + JournalRevisionOverflow, + /// A transaction identifier is not a bounded path-free token. + #[error("macOS handover transaction ID must be 1-64 ASCII letters, digits, '_' or '-'")] + InvalidTransactionId, + /// An owner identity field is empty, oversized, or structurally invalid. + #[error("invalid macOS owner identity field {field}: {detail}")] + InvalidOwnerIdentity { + /// Invalid identity field. + field: &'static str, + /// Stable validation detail. + detail: &'static str, + }, + /// A durable artifact exceeds the bounded decoder input size. + #[error("macOS {artifact} exceeds the {maximum_bytes}-byte limit")] + ArtifactTooLarge { + /// Artifact kind. + artifact: &'static str, + /// Maximum accepted byte length. + maximum_bytes: usize, + }, + /// A completed or rolled-back transaction cannot be advanced. + #[error("terminal macOS handover {transaction_id} cannot advance")] + TerminalHandover { + /// Completed transaction identifier. + transaction_id: String, + }, +} + +/// Durable owner state rooted in an explicit per-user data directory. +#[derive(Debug, Clone)] +pub struct MacosOwnerStore { + data_dir: PathBuf, +} + +impl MacosOwnerStore { + /// Construct a store without reading or creating any files. + pub fn new(data_dir: impl Into) -> Self { + Self { + data_dir: data_dir.into(), + } + } + + /// Return the owner-record path. + pub fn owner_record_path(&self) -> PathBuf { + self.data_dir.join(MACOS_OWNER_RECORD_FILE_NAME) + } + + /// Return the handover-journal path. + pub fn handover_journal_path(&self) -> PathBuf { + self.data_dir.join(MACOS_HANDOVER_JOURNAL_FILE_NAME) + } + + /// Return the stable lock path shared by every writer. + pub fn coordination_lock_path(&self) -> PathBuf { + self.data_dir.join(MACOS_OWNER_COORDINATION_LOCK_FILE_NAME) + } + + /// Load and validate the current owner record. + pub fn load_owner_record(&self) -> Result, MacosOwnerStoreError> { + read_owner_record(&self.owner_record_path()) + } + + /// Publish a newly acquired owner and advance the durable owner epoch. + pub fn publish_owner( + &self, + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, + selected_external_owner: Option, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let record = match read_owner_record(&path)? { + Some(previous) => MacosOwnerRecord { + owner_epoch: previous + .owner_epoch + .checked_add(1) + .ok_or(MacosOwnerStoreError::OwnerEpochOverflow)?, + schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, + active_owner, + active_identity, + conflict: None, + selected_external_owner, + }, + None => MacosOwnerRecord::new(active_owner, active_identity, selected_external_owner), + }; + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + Ok(record) + } + + /// Record a distinct contender or coalesce one already observed this epoch. + pub fn record_conflict( + &self, + contender_owner: MacosDaemonOwner, + contender_identity: MacosOwnerIdentity, + observed_at_ms: u64, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + let conflict = MacosOwnerConflictRecord { + active_owner: record.active_owner, + active_epoch: record.owner_epoch, + contender_owner, + contender_identity, + observed_at_ms, + }; + if record + .conflict + .as_ref() + .is_some_and(|existing| existing.has_same_identity(&conflict)) + { + return Ok(MacosConflictUpdate::Coalesced(record.snapshot())); + } + record.conflict = Some(conflict); + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + Ok(MacosConflictUpdate::Recorded(record.snapshot())) + } + + /// Clear the current conflict without changing the owner epoch. + pub fn clear_conflict(&self) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if record.conflict.take().is_some() { + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + } + Ok(record) + } + + /// Persist or clear the selected external-owner mode. + pub fn set_external_owner_mode( + &self, + selected_external_owner: Option, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if record.selected_external_owner != selected_external_owner { + record.selected_external_owner = selected_external_owner; + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + } + Ok(record) + } + + /// Load and validate the current handover journal. + pub fn load_handover_journal( + &self, + ) -> Result, MacosOwnerStoreError> { + read_handover_journal(&self.handover_journal_path()) + } + + /// Begin a handover unless a nonterminal journal requires recovery. + pub fn begin_handover( + &self, + mut journal: MacosHandoverJournal, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.handover_journal_path(); + if let Some(existing) = read_handover_journal(&path)? + && !existing.phase.is_terminal() + { + return Err(MacosOwnerStoreError::HandoverAlreadyPending { + transaction_id: existing.transaction_id.0, + }); + } + validate_handover_journal(&journal)?; + journal.schema_version = MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION; + journal.journal_revision = 1; + journal.phase = MacosHandoverPhase::Prepared; + write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; + Ok(journal) + } + + /// Durably advance one handover phase under one read-modify-write lock hold. + pub fn advance_handover( + &self, + transaction_id: &MacosHandoverTransactionId, + phase: MacosHandoverPhase, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.handover_journal_path(); + let mut journal = + read_handover_journal(&path)?.ok_or(MacosOwnerStoreError::MissingHandoverJournal)?; + if journal.transaction_id != *transaction_id { + return Err(MacosOwnerStoreError::HandoverTransactionMismatch); + } + if journal.phase.is_terminal() { + return Err(MacosOwnerStoreError::TerminalHandover { + transaction_id: journal.transaction_id.0, + }); + } + journal.journal_revision = journal + .journal_revision + .checked_add(1) + .ok_or(MacosOwnerStoreError::JournalRevisionOverflow)?; + journal.phase = phase; + write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; + Ok(journal) + } + + fn acquire_coordination_lock(&self) -> Result { + fs::create_dir_all(&self.data_dir).map_err(|source| { + MacosOwnerStoreError::CreateDirectory { + path: self.data_dir.clone(), + source, + } + })?; + let path = self.coordination_lock_path(); + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = + options + .open(&path) + .map_err(|source| MacosOwnerStoreError::OpenCoordinationLock { + path: path.clone(), + source, + })?; + file.lock() + .map_err(|source| MacosOwnerStoreError::AcquireCoordinationLock { path, source })?; + Ok(CoordinationLock { file }) + } +} + +struct CoordinationLock { + file: File, +} + +impl Drop for CoordinationLock { + fn drop(&mut self) { + drop(self.file.unlock()); + } +} + +fn read_owner_record(path: &Path) -> Result, MacosOwnerStoreError> { + let Some(bytes) = read_optional(path, "owner record")? else { + return Ok(None); + }; + let record = serde_json::from_slice::(&bytes).map_err(|source| { + MacosOwnerStoreError::Decode { + artifact: "owner record", + source, + } + })?; + validate_owner_record(&record)?; + Ok(Some(record)) +} + +fn read_handover_journal( + path: &Path, +) -> Result, MacosOwnerStoreError> { + let Some(bytes) = read_optional(path, "handover journal")? else { + return Ok(None); + }; + let journal = serde_json::from_slice::(&bytes).map_err(|source| { + MacosOwnerStoreError::Decode { + artifact: "handover journal", + source, + } + })?; + validate_handover_journal(&journal)?; + Ok(Some(journal)) +} + +fn read_optional( + path: &Path, + artifact: &'static str, +) -> Result>, MacosOwnerStoreError> { + match File::open(path) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take((MAX_MACOS_OWNER_ARTIFACT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|source| MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + })?; + if bytes.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { + return Err(MacosOwnerStoreError::ArtifactTooLarge { + artifact, + maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, + }); + } + Ok(Some(bytes)) + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + }), + } +} + +fn validate_owner_record(record: &MacosOwnerRecord) -> Result<(), MacosOwnerStoreError> { + validate_version( + "owner record", + record.schema_version, + MACOS_OWNER_RECORD_SCHEMA_VERSION, + )?; + if record.owner_epoch == 0 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "owner record", + detail: "owner_epoch must be positive", + }); + } + validate_owner_identity(&record.active_identity)?; + if let Some(conflict) = &record.conflict + && (conflict.active_owner != record.active_owner + || conflict.active_epoch != record.owner_epoch) + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "owner record", + detail: "conflict must identify the active owner epoch", + }); + } + if let Some(conflict) = &record.conflict { + validate_owner_identity(&conflict.contender_identity)?; + } + Ok(()) +} + +fn validate_owner_identity(identity: &MacosOwnerIdentity) -> Result<(), MacosOwnerStoreError> { + validate_bounded_identity_text( + "audit_token_identity", + &identity.audit_token_identity, + MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES, + )?; + let executable_path = + identity + .executable_path + .to_str() + .ok_or(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + detail: "must be valid UTF-8", + })?; + validate_bounded_identity_text( + "executable_path", + executable_path, + MAX_MACOS_EXECUTABLE_PATH_BYTES, + )?; + if !identity.executable_path.is_absolute() { + return Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + detail: "must be absolute", + }); + } + validate_bounded_identity_text( + "designated_requirement_hash", + &identity.designated_requirement_hash, + MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES, + )?; + if identity.pid == 0 { + return Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "pid", + detail: "must be positive", + }); + } + Ok(()) +} + +fn validate_bounded_identity_text( + field: &'static str, + value: &str, + maximum_bytes: usize, +) -> Result<(), MacosOwnerStoreError> { + if value.is_empty() { + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field, + detail: "must not be empty", + }) + } else if value.len() > maximum_bytes { + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field, + detail: "exceeds its byte limit", + }) + } else { + Ok(()) + } +} + +fn validate_handover_journal(journal: &MacosHandoverJournal) -> Result<(), MacosOwnerStoreError> { + validate_version( + "handover journal", + journal.schema_version, + MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, + )?; + if !is_valid_transaction_id(journal.transaction_id.as_str()) { + return Err(MacosOwnerStoreError::InvalidTransactionId); + } + if journal.active_epoch == 0 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "active_epoch must be positive", + }); + } + if journal.allowed_rollback_operations.len() > MAX_MACOS_HANDOVER_OPERATIONS { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "allowed_rollback_operations exceeds its item limit", + }); + } + if journal.pending_standalone_pid == Some(0) + || journal.allowed_rollback_operations.iter().any(|operation| { + matches!( + operation, + MacosHandoverOperation::AwaitStandaloneExit { pid: 0 } + ) + }) + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "standalone PID must be positive", + }); + } + Ok(()) +} + +fn validate_version( + artifact: &'static str, + found: u32, + expected: u32, +) -> Result<(), MacosOwnerStoreError> { + if found == expected { + Ok(()) + } else { + Err(MacosOwnerStoreError::UnsupportedVersion { + artifact, + found, + expected, + }) + } +} + +fn is_valid_transaction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn write_json_atomic( + data_dir: &Path, + path: &Path, + artifact: &'static str, + value: &T, +) -> Result<(), MacosOwnerStoreError> +where + T: Serialize + ?Sized, +{ + let mut payload = serde_json::to_vec_pretty(value) + .map_err(|source| MacosOwnerStoreError::Encode { artifact, source })?; + payload.push(b'\n'); + if payload.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { + return Err(MacosOwnerStoreError::ArtifactTooLarge { + artifact, + maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, + }); + } + let (mut temporary, temporary_path) = create_temporary_file(data_dir, path)?; + let result = (|| { + temporary + .write_all(&payload) + .map_err(|source| MacosOwnerStoreError::WriteTemporary { + path: path.to_path_buf(), + source, + })?; + temporary + .sync_all() + .map_err(|source| MacosOwnerStoreError::SyncTemporary { + path: path.to_path_buf(), + source, + })?; + drop(temporary); + hypercolor_platform_fs::replace_file(&temporary_path, path).map_err(|source| { + MacosOwnerStoreError::Replace { + path: path.to_path_buf(), + source, + } + })?; + sync_parent_directory(data_dir) + })(); + if result.is_err() { + drop(fs::remove_file(&temporary_path)); + } + result +} + +fn create_temporary_file( + data_dir: &Path, + path: &Path, +) -> Result<(File, PathBuf), MacosOwnerStoreError> { + for _ in 0..MAX_TEMPORARY_CREATE_ATTEMPTS { + let sequence = TEMPORARY_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary_path = data_dir.join(format!( + ".{}.{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("macos-owner"), + std::process::id(), + sequence + )); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&temporary_path) { + Ok(file) => return Ok((file, temporary_path)), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(source) => { + return Err(MacosOwnerStoreError::CreateTemporary { + path: path.to_path_buf(), + source, + }); + } + } + } + Err(MacosOwnerStoreError::CreateTemporary { + path: path.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "temporary file collision limit reached", + ), + }) +} + +#[cfg(unix)] +fn sync_parent_directory(data_dir: &Path) -> Result<(), MacosOwnerStoreError> { + File::open(data_dir) + .and_then(|directory| directory.sync_all()) + .map_err(|source| MacosOwnerStoreError::SyncDirectory { + path: data_dir.to_path_buf(), + source, + }) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_data_dir: &Path) -> Result<(), MacosOwnerStoreError> { + Ok(()) +} diff --git a/crates/hypercolor-daemon/tests/macos_owner_tests.rs b/crates/hypercolor-daemon/tests/macos_owner_tests.rs new file mode 100644 index 000000000..5b936d809 --- /dev/null +++ b/crates/hypercolor-daemon/tests/macos_owner_tests.rs @@ -0,0 +1,594 @@ +use std::fs::{self, OpenOptions}; +use std::sync::{Arc, Barrier}; +use std::thread; + +use hypercolor_daemon::macos_owner::{ + MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, MACOS_OWNER_RECORD_SCHEMA_VERSION, + MAX_MACOS_HANDOVER_OPERATIONS, MAX_MACOS_OWNER_ARTIFACT_BYTES, MacosAutostartStates, + MacosConflictUpdate, MacosDaemonOwner, MacosExternalOwnerMode, MacosHandoverJournal, + MacosHandoverOperation, MacosHandoverPhase, MacosHandoverTransactionId, MacosOwnerIdentity, + MacosOwnerStore, MacosOwnerStoreError, +}; +use serde_json::{Value, json}; + +fn transaction_id(value: &str) -> MacosHandoverTransactionId { + MacosHandoverTransactionId::new(value).expect("fixture transaction ID should be valid") +} + +fn identity(label: &str, pid: u32) -> MacosOwnerIdentity { + MacosOwnerIdentity::new( + format!("audit-token-{label}"), + format!("/Applications/{label}/hypercolor-daemon"), + format!("sha256-{label}"), + pid, + ) + .expect("fixture owner identity should be valid") +} + +fn all_operations() -> Vec { + vec![ + MacosHandoverOperation::SetAppSidecarAutostart { enabled: false }, + MacosHandoverOperation::FlushAndStopAppSidecar {}, + MacosHandoverOperation::StartAppSidecar {}, + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled: true }, + MacosHandoverOperation::FlushAndStopDirectLaunchd {}, + MacosHandoverOperation::StartDirectLaunchd {}, + MacosHandoverOperation::SetHomebrewAutostart { enabled: false }, + MacosHandoverOperation::FlushAndStopHomebrew {}, + MacosHandoverOperation::StartHomebrew {}, + MacosHandoverOperation::AwaitStandaloneExit { pid: 4242 }, + ] +} + +fn journal(id: &str) -> MacosHandoverJournal { + MacosHandoverJournal::new( + transaction_id(id), + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Standalone, + MacosAutostartStates::new(true, false, false), + all_operations(), + 7, + Some(8), + Some(4242), + ) +} + +#[test] +fn owner_publication_advances_monotonic_epochs() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + + let first = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("first owner should publish"); + let second = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity("launchd", 102), + Some(MacosExternalOwnerMode::DirectLaunchd), + ) + .expect("second owner should publish"); + let third = store + .publish_owner( + MacosDaemonOwner::Homebrew, + identity("homebrew", 103), + Some(MacosExternalOwnerMode::Homebrew), + ) + .expect("third owner should publish"); + + assert_eq!( + [first.owner_epoch, second.owner_epoch, third.owner_epoch], + [1, 2, 3] + ); + assert_eq!(third.schema_version, MACOS_OWNER_RECORD_SCHEMA_VERSION); + assert_eq!( + store + .load_owner_record() + .expect("owner record should load") + .expect("owner record should exist"), + third + ); +} + +#[test] +fn identical_conflicts_coalesce_with_the_original_observation() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("owner should publish"); + + let first = store + .record_conflict( + MacosDaemonOwner::DirectLaunchd, + identity("launchd-contender", 201), + 100, + ) + .expect("first conflict should publish"); + let duplicate = store + .record_conflict( + MacosDaemonOwner::DirectLaunchd, + identity("launchd-contender", 201), + 999, + ) + .expect("duplicate conflict should coalesce"); + let restarted_contender = store + .record_conflict( + MacosDaemonOwner::DirectLaunchd, + MacosOwnerIdentity::new( + "audit-token-after-restart", + "/Applications/launchd-contender/hypercolor-daemon", + "sha256-launchd-contender", + 303, + ) + .expect("restarted contender identity should be valid"), + 1_000, + ) + .expect("same executable and requirement should coalesce"); + let changed_identity = store + .record_conflict( + MacosDaemonOwner::DirectLaunchd, + identity("different-launchd-contender", 202), + 2_000, + ) + .expect("new contender identity should publish"); + + assert!(matches!(first, MacosConflictUpdate::Recorded(_))); + assert!(matches!(duplicate, MacosConflictUpdate::Coalesced(_))); + assert!(matches!( + restarted_contender, + MacosConflictUpdate::Coalesced(_) + )); + assert!(matches!(changed_identity, MacosConflictUpdate::Recorded(_))); + assert_eq!(first.snapshot(), duplicate.snapshot()); + assert_eq!(first.snapshot(), restarted_contender.snapshot()); + assert_eq!( + duplicate + .snapshot() + .conflict + .expect("conflict should remain present") + .observed_at_ms, + 100 + ); + assert_eq!( + changed_identity + .snapshot() + .conflict + .expect("changed conflict should remain present") + .observed_at_ms, + 2_000 + ); +} + +#[test] +fn owner_identity_rejects_empty_oversized_relative_and_zero_pid_fields() { + let valid_path = "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon"; + assert!(matches!( + MacosOwnerIdentity::new("", valid_path, "sha256-valid", 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "audit_token_identity", + .. + }) + )); + assert!(matches!( + MacosOwnerIdentity::new("audit", "relative/daemon", "sha256-valid", 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + .. + }) + )); + assert!(matches!( + MacosOwnerIdentity::new("audit", valid_path, "", 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "designated_requirement_hash", + .. + }) + )); + assert!(matches!( + MacosOwnerIdentity::new("audit", valid_path, "sha256-valid", 0), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { field: "pid", .. }) + )); + assert!(matches!( + MacosOwnerIdentity::new("a".repeat(257), valid_path, "sha256-valid", 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "audit_token_identity", + .. + }) + )); + assert!(matches!( + MacosOwnerIdentity::new("audit", format!("/{}", "p".repeat(4_096)), "hash", 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + .. + }) + )); + assert!(matches!( + MacosOwnerIdentity::new("audit", valid_path, "h".repeat(257), 1), + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "designated_requirement_hash", + .. + }) + )); +} + +#[test] +fn record_and_journal_writers_interleave_without_lost_updates() { + const THREADS_PER_ARTIFACT: usize = 3; + const WRITES_PER_THREAD: usize = 24; + + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = Arc::new(MacosOwnerStore::new(directory.path())); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("initial owner should publish"); + let id = transaction_id("concurrent-handover"); + store + .begin_handover(journal(id.as_str())) + .expect("journal should begin"); + let barrier = Arc::new(Barrier::new(THREADS_PER_ARTIFACT * 2)); + let mut handles = Vec::new(); + + for _ in 0..THREADS_PER_ARTIFACT { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + for _ in 0..WRITES_PER_THREAD { + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("concurrent owner publication should succeed"); + } + })); + } + for thread_index in 0..THREADS_PER_ARTIFACT { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + let id = id.clone(); + handles.push(thread::spawn(move || { + barrier.wait(); + for write_index in 0..WRITES_PER_THREAD { + let phase = if (thread_index + write_index) % 2 == 0 { + MacosHandoverPhase::StopRequested + } else { + MacosHandoverPhase::RollbackPending + }; + store + .advance_handover(&id, phase) + .expect("concurrent journal publication should succeed"); + } + })); + } + for handle in handles { + handle.join().expect("writer thread should finish"); + } + + let expected_updates = (THREADS_PER_ARTIFACT * WRITES_PER_THREAD) as u64; + assert_eq!( + store + .load_owner_record() + .expect("owner record should load") + .expect("owner record should exist") + .owner_epoch, + expected_updates + 1 + ); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .journal_revision, + expected_updates + 1 + ); +} + +#[test] +fn every_handover_phase_round_trips() { + for (index, phase) in MacosHandoverPhase::ALL.into_iter().enumerate() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + let id = transaction_id(&format!("phase-round-trip-{index}")); + let initial = store + .begin_handover(journal(id.as_str())) + .expect("journal should begin"); + assert_eq!( + initial.schema_version, + MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION + ); + let advanced = store + .advance_handover(&id, phase) + .expect("phase should persist"); + let loaded = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist"); + assert_eq!(advanced, loaded); + assert_eq!(loaded.phase, phase); + } +} + +#[test] +fn terminal_handover_cannot_be_revived() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + let id = transaction_id("terminal-handover"); + store + .begin_handover(journal(id.as_str())) + .expect("journal should begin"); + store + .advance_handover(&id, MacosHandoverPhase::Committed) + .expect("handover should commit"); + + assert!(matches!( + store.advance_handover(&id, MacosHandoverPhase::StartRequested), + Err(MacosOwnerStoreError::TerminalHandover { .. }) + )); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::Committed + ); +} + +#[test] +fn malformed_and_unknown_artifacts_reject_without_replacement() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("owner should publish"); + let valid_owner = fs::read(store.owner_record_path()).expect("owner bytes should exist"); + + let malformed = b"{ malformed owner record\n"; + fs::write(store.owner_record_path(), malformed).expect("fixture corruption should write"); + assert!(matches!( + store.publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 103), None), + Err(MacosOwnerStoreError::Decode { + artifact: "owner record", + .. + }) + )); + assert_eq!( + fs::read(store.owner_record_path()).expect("malformed bytes should remain"), + malformed + ); + + let mut unknown_version: Value = + serde_json::from_slice(&valid_owner).expect("valid owner should decode as JSON"); + unknown_version["schema_version"] = json!(99); + let unknown_version = serde_json::to_vec_pretty(&unknown_version) + .expect("unknown-version fixture should serialize"); + fs::write(store.owner_record_path(), &unknown_version) + .expect("unknown-version fixture should write"); + assert!(matches!( + store.set_external_owner_mode(Some(MacosExternalOwnerMode::Homebrew)), + Err(MacosOwnerStoreError::UnsupportedVersion { + artifact: "owner record", + found: 99, + .. + }) + )); + assert_eq!( + fs::read(store.owner_record_path()).expect("unknown-version bytes should remain"), + unknown_version + ); + + let mut invalid_identity: Value = + serde_json::from_slice(&valid_owner).expect("valid owner should decode as JSON"); + invalid_identity["active_identity"]["executable_path"] = json!("relative/daemon"); + let invalid_identity = serde_json::to_vec_pretty(&invalid_identity) + .expect("invalid-identity fixture should serialize"); + fs::write(store.owner_record_path(), &invalid_identity) + .expect("invalid-identity fixture should write"); + assert!(matches!( + store.set_external_owner_mode(Some(MacosExternalOwnerMode::Homebrew)), + Err(MacosOwnerStoreError::Decode { + artifact: "owner record", + .. + }) + )); + assert_eq!( + fs::read(store.owner_record_path()).expect("invalid-identity bytes should remain"), + invalid_identity + ); + + store + .begin_handover(journal("unknown-operation")) + .expect("journal should begin"); + let mut unknown_operation: Value = serde_json::from_slice( + &fs::read(store.handover_journal_path()).expect("journal bytes should exist"), + ) + .expect("valid journal should decode as JSON"); + unknown_operation["allowed_rollback_operations"] = + json!([{ "kind": "run_command", "command": "forbidden" }]); + let unknown_operation = serde_json::to_vec_pretty(&unknown_operation) + .expect("unknown-operation fixture should serialize"); + fs::write(store.handover_journal_path(), &unknown_operation) + .expect("unknown-operation fixture should write"); + assert!(matches!( + store.advance_handover( + &transaction_id("unknown-operation"), + MacosHandoverPhase::StopRequested + ), + Err(MacosOwnerStoreError::Decode { + artifact: "handover journal", + .. + }) + )); + assert_eq!( + fs::read(store.handover_journal_path()).expect("unknown-operation bytes should remain"), + unknown_operation + ); + + let mut known_operation_with_payload: Value = + serde_json::to_value(journal("known-operation")).expect("journal fixture should serialize"); + known_operation_with_payload["journal_revision"] = json!(1); + known_operation_with_payload["allowed_rollback_operations"] = json!([{ + "kind": "flush_and_stop_app_sidecar", + "command": "/bin/sh", + "argv": ["-c", "forbidden"], + "executable_path": "/tmp/forbidden" + }]); + let known_operation_with_payload = serde_json::to_vec_pretty(&known_operation_with_payload) + .expect("known-operation payload fixture should serialize"); + fs::write(store.handover_journal_path(), &known_operation_with_payload) + .expect("known-operation payload fixture should write"); + assert!(matches!( + store.advance_handover( + &transaction_id("known-operation"), + MacosHandoverPhase::StopRequested + ), + Err(MacosOwnerStoreError::Decode { + artifact: "handover journal", + .. + }) + )); + assert_eq!( + fs::read(store.handover_journal_path()) + .expect("known-operation payload bytes should remain"), + known_operation_with_payload + ); +} + +#[test] +fn oversized_artifacts_and_operation_lists_reject_without_mutation() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + let oversized = vec![b'x'; MAX_MACOS_OWNER_ARTIFACT_BYTES + 1]; + fs::write(store.owner_record_path(), &oversized).expect("oversized fixture should write"); + + assert!(matches!( + store.publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None), + Err(MacosOwnerStoreError::ArtifactTooLarge { + artifact: "owner record", + .. + }) + )); + assert_eq!( + fs::read(store.owner_record_path()).expect("oversized bytes should remain"), + oversized + ); + + let mut excessive_operations = journal("excessive-operations"); + excessive_operations.allowed_rollback_operations = + vec![MacosHandoverOperation::StartAppSidecar {}; MAX_MACOS_HANDOVER_OPERATIONS + 1]; + assert!(matches!( + store.begin_handover(excessive_operations), + Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + .. + }) + )); + assert!( + !store.handover_journal_path().exists(), + "invalid journal must not create durable bytes" + ); +} + +#[test] +fn failed_mutation_releases_the_stable_coordination_lock() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("owner should publish"); + fs::write(store.owner_record_path(), b"not json").expect("fixture corruption should write"); + + assert!(matches!( + store.record_conflict( + MacosDaemonOwner::DirectLaunchd, + identity("launchd-contender", 201), + 1 + ), + Err(MacosOwnerStoreError::Decode { .. }) + )); + let lock = OpenOptions::new() + .read(true) + .write(true) + .open(store.coordination_lock_path()) + .expect("stable coordination lock should exist"); + lock.try_lock() + .expect("failed mutation should release its lock"); + lock.unlock().expect("test lock should release"); +} + +#[test] +fn diagnostic_owner_path_is_bounded_while_the_journal_stays_path_free() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + let owner = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity("launchd", 102), + Some(MacosExternalOwnerMode::DirectLaunchd), + ) + .expect("owner should publish"); + let journal = store + .begin_handover(journal("path-free-shape")) + .expect("journal should begin"); + + let owner_value = serde_json::to_value(owner).expect("owner record should serialize"); + assert_eq!( + owner_value["active_identity"]["executable_path"], + "/Applications/launchd/hypercolor-daemon" + ); + assert_path_free(&serde_json::to_value(journal).expect("handover journal should serialize")); +} + +#[cfg(unix)] +#[test] +fn durable_owner_artifacts_are_user_read_write_only() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .expect("owner should publish"); + store + .begin_handover(journal("mode-check")) + .expect("journal should begin"); + + for path in [ + store.owner_record_path(), + store.handover_journal_path(), + store.coordination_lock_path(), + ] { + let mode = fs::metadata(path) + .expect("durable artifact metadata should load") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } +} + +fn assert_path_free(value: &Value) { + match value { + Value::Object(fields) => { + for (key, value) in fields { + let normalized = key.to_ascii_lowercase(); + assert!(!normalized.contains("path"), "path key leaked: {key}"); + assert!(!normalized.contains("command"), "command key leaked: {key}"); + assert!( + !normalized.contains("argument"), + "argument key leaked: {key}" + ); + assert!(!normalized.contains("argv"), "argv key leaked: {key}"); + assert!( + !normalized.contains("executable"), + "executable key leaked: {key}" + ); + assert_path_free(value); + } + } + Value::Array(values) => values.iter().for_each(assert_path_free), + Value::String(value) => { + assert!(!value.contains('/'), "path-like value leaked: {value}"); + assert!(!value.contains('\\'), "path-like value leaked: {value}"); + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} From 77f3a40448b13b42805d8479a21418ef4227bc8a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 22:24:33 -0700 Subject: [PATCH 062/144] feat(macos): validate native HDR stream delivery Select ScreenCaptureKit's canonical-display HDR stream preset while keeping SDR on the existing BGRA path and rejecting Intel HDR before capture starts. Treat configuration as requested evidence until the first complete frame confirms dynamic range, format, range, and color metadata. Preserve optional luminance metadata from Core Video and IOSurface attachments, and expose callable Tahoe capability probes without OS-version branching. Co-Authored-By: Nova (OpenAI Codex) --- .../src/diagnostics.rs | 8 +- crates/hypercolor-macos-capture/src/frame.rs | 45 +- crates/hypercolor-macos-capture/src/lib.rs | 8 + crates/hypercolor-macos-capture/src/native.rs | 421 +++++++++++++++-- .../hypercolor-macos-capture/src/session.rs | 25 +- .../src/stream_contract.rs | 424 ++++++++++++++++++ .../tests/capture_contract_tests.rs | 339 +++++++++++++- 7 files changed, 1212 insertions(+), 58 deletions(-) create mode 100644 crates/hypercolor-macos-capture/src/stream_contract.rs diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 32f748e68..163417df7 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -38,11 +38,13 @@ impl MacosFrameDropReason { | MacosCaptureError::MissingAttachment(_) | MacosCaptureError::MalformedAttachment(_) | MacosCaptureError::UnknownFrameStatus(_) => Self::Attachment, - MacosCaptureError::UnsupportedPixelFormat(_) => Self::UnsupportedFormat, + MacosCaptureError::UnsupportedPixelFormat(_) + | MacosCaptureError::UnsupportedConfiguredDynamicRange(_) => Self::UnsupportedFormat, MacosCaptureError::ColorMetadataMismatch | MacosCaptureError::MissingYuvColorMetadata | MacosCaptureError::MissingColorAttachment(_) - | MacosCaptureError::UnsupportedColorAttachment(_) => Self::ColorMetadata, + | MacosCaptureError::UnsupportedColorAttachment(_) + | MacosCaptureError::MalformedLuminanceAttachment(_) => Self::ColorMetadata, MacosCaptureError::MissingFramePayload | MacosCaptureError::InvalidSurface | MacosCaptureError::MissingIoSurface @@ -79,6 +81,8 @@ impl MacosFrameDropReason { | MacosCaptureError::InvalidCpuDestinationStride { .. } | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted + | MacosCaptureError::StreamDeliveryRejected(_) + | MacosCaptureError::CapabilityProbeFailed(_) | MacosCaptureError::Geometry(_) => Self::Validation, MacosCaptureError::ScreenResourceExhausted { .. } => Self::Resource, } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 247858117..70f7766a4 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -22,6 +22,7 @@ use crate::geometry::{ MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, }; +use crate::{MacosDeliveredFrameMetadata, MacosStreamDeliveryRejection}; pub const MACOS_STREAM_QUEUE_DEPTH: usize = 8; @@ -225,6 +226,7 @@ pub struct MacosCaptureSurface { pub iosurface_id: u32, pub allocation_bytes: u64, owner: Arc, + delivery_metadata: Option, _admission_lifetime: Option>, } @@ -329,7 +331,7 @@ impl MacosCaptureSurface { lock.unlock()?; let length_bytes = u64::try_from(CVPixelBufferGetDataSize(&pixel_buffer)) .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; - let surface = Self::from_pixel_buffer(pixel_buffer, None)?; + let surface = Self::from_pixel_buffer_with_delivery_metadata(pixel_buffer, None, None)?; Ok(( surface, MacosCapturePlane { @@ -357,6 +359,7 @@ impl MacosCaptureSurface { fixture_id, planes: None, }), + delivery_metadata: None, _admission_lifetime: None, }) } @@ -384,14 +387,31 @@ impl MacosCaptureSurface { fixture_id, planes: Some(planes.into()), }), + delivery_metadata: None, _admission_lifetime: None, }) } + #[cfg(feature = "capture-fixtures")] + pub fn with_delivery_metadata( + mut self, + delivery_metadata: MacosDeliveredFrameMetadata, + ) -> Result { + MacosDeliveredFrameMetadata::new( + delivery_metadata.pixel_format, + delivery_metadata.color, + delivery_metadata.source_reference_white_nits, + delivery_metadata.content_headroom, + )?; + self.delivery_metadata = Some(delivery_metadata); + Ok(self) + } + #[cfg(target_os = "macos")] - pub(crate) fn from_pixel_buffer( + pub(crate) fn from_pixel_buffer_with_delivery_metadata( pixel_buffer: CFRetained, admission_lifetime: Option>, + delivery_metadata: Option, ) -> Result { let iosurface = CVPixelBufferGetIOSurface(Some(&pixel_buffer)) .ok_or(MacosCaptureError::MissingIoSurface)?; @@ -405,10 +425,16 @@ impl MacosCaptureSurface { iosurface_id, allocation_bytes, owner: Arc::new(MacosRetainedPixelBuffer::Native { pixel_buffer }), + delivery_metadata, _admission_lifetime: admission_lifetime, }) } + #[must_use] + pub const fn delivery_metadata(&self) -> Option { + self.delivery_metadata + } + pub fn retained_owner_count(&self) -> usize { Arc::strong_count(&self.owner) } @@ -671,6 +697,13 @@ pub struct MacosCaptureFrame { pub surface: MacosCaptureSurface, } +impl MacosCaptureFrame { + #[must_use] + pub const fn delivered_metadata(&self) -> Option { + self.surface.delivery_metadata() + } +} + #[derive(Debug, Clone, PartialEq)] pub enum MacosAttachment { Missing, @@ -968,6 +1001,8 @@ pub enum MacosCaptureError { UnknownFrameStatus(i64), #[error("unsupported Core Video pixel format {0:#010x}")] UnsupportedPixelFormat(u32), + #[error("unsupported ScreenCaptureKit configured dynamic range {0}")] + UnsupportedConfiguredDynamicRange(isize), #[error("complete frame has no image payload")] MissingFramePayload, #[error("pixel plane count mismatch: expected {expected}, got {actual}")] @@ -998,6 +1033,12 @@ pub enum MacosCaptureError { AllocationTooSmall { required: u64, actual: u64 }, #[error("pixel format and color metadata disagree")] ColorMetadataMismatch, + #[error(transparent)] + StreamDeliveryRejected(#[from] MacosStreamDeliveryRejection), + #[error("macOS capture capability probe failed: {0}")] + CapabilityProbeFailed(&'static str), + #[error("malformed HDR luminance attachment: {0}")] + MalformedLuminanceAttachment(&'static str), #[error("YUV frames require matrix and chroma-location metadata")] MissingYuvColorMetadata, #[error("missing Core Video color attachment: {0}")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 479b2f255..609024c71 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -12,6 +12,7 @@ mod mailbox; #[cfg(target_os = "macos")] mod native; mod session; +mod stream_contract; mod worker; #[cfg(target_os = "macos")] @@ -37,3 +38,10 @@ pub use session::{ MacosCaptureCadence, MacosCaptureContentStyle, MacosCaptureSelection, MacosCaptureSelector, MacosStreamRequest, }; +pub use stream_contract::{ + MacosCaptureCapabilities, MacosCaptureDynamicRange, MacosConfiguredStream, + MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosRuntimeCapability, + MacosStreamDeliveryRejection, MacosStreamDeliveryState, MacosStreamDeliveryValidator, + MacosStreamPreset, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, + MacosValidatedStreamDelivery, +}; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 62602878d..97b433277 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -1,3 +1,4 @@ +use std::ffi::{CStr, c_char, c_void}; use std::fmt; use std::ptr::{self, NonNull}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -6,10 +7,13 @@ use std::sync::{Arc, Mutex, MutexGuard, Weak}; use block2::RcBlock; use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained, MainThreadBound}; use objc2::rc::Retained; -use objc2::runtime::{AnyObject, ProtocolObject}; -use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; +use objc2::runtime::{AnyClass, AnyObject, ProtocolObject}; +use objc2::{ + AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel, +}; use objc2_core_foundation::{ - CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType, CFUUID, CGPoint, CGRect, CGSize, + CFArray, CFDictionary, CFGetTypeID, CFNumber, CFRetained, CFString, CFType, CFUUID, CGPoint, + CGRect, CGSize, }; use objc2_core_graphics::{ CGDirectDisplayID, CGMainDisplayID, CGPreflightScreenCaptureAccess, @@ -19,40 +23,46 @@ use objc2_core_media::{CMSampleBuffer, CMTime}; use objc2_core_video::{ CVBuffer, CVPixelBuffer, CVPixelBufferGetBytesPerRow, CVPixelBufferGetBytesPerRowOfPlane, CVPixelBufferGetDataSize, CVPixelBufferGetHeight, CVPixelBufferGetHeightOfPlane, - CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, CVPixelBufferGetWidth, - CVPixelBufferGetWidthOfPlane, kCVImageBufferChromaLocation_Center, + CVPixelBufferGetIOSurface, CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, + CVPixelBufferGetWidth, CVPixelBufferGetWidthOfPlane, kCVImageBufferChromaLocation_Center, kCVImageBufferChromaLocation_Left, kCVImageBufferChromaLocation_TopLeft, kCVImageBufferChromaLocationTopFieldKey, kCVImageBufferColorPrimaries_ITU_R_709_2, kCVImageBufferColorPrimaries_ITU_R_2020, kCVImageBufferColorPrimaries_P3_D65, - kCVImageBufferColorPrimariesKey, kCVImageBufferTransferFunction_ITU_R_709_2, - kCVImageBufferTransferFunction_ITU_R_2020, kCVImageBufferTransferFunction_ITU_R_2100_HLG, - kCVImageBufferTransferFunction_Linear, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, - kCVImageBufferTransferFunction_sRGB, kCVImageBufferTransferFunctionKey, - kCVImageBufferYCbCrMatrix_ITU_R_601_4, kCVImageBufferYCbCrMatrix_ITU_R_709_2, - kCVImageBufferYCbCrMatrix_ITU_R_2020, kCVImageBufferYCbCrMatrixKey, + kCVImageBufferColorPrimariesKey, kCVImageBufferContentLightLevelInfoKey, + kCVImageBufferTransferFunction_ITU_R_709_2, kCVImageBufferTransferFunction_ITU_R_2020, + kCVImageBufferTransferFunction_ITU_R_2100_HLG, kCVImageBufferTransferFunction_Linear, + kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, kCVImageBufferTransferFunction_sRGB, + kCVImageBufferTransferFunctionKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4, + kCVImageBufferYCbCrMatrix_ITU_R_709_2, kCVImageBufferYCbCrMatrix_ITU_R_2020, + kCVImageBufferYCbCrMatrixKey, }; use objc2_foundation::{NSArray, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSValue}; -use objc2_io_surface::IOSurfaceRef; +use objc2_io_surface::{IOSurfaceRef, kIOSurfaceContentHeadroom}; use objc2_screen_capture_kit::{ - SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, + SCCaptureDynamicRange, SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, SCContentSharingPickerConfiguration, SCContentSharingPickerMode, SCContentSharingPickerObserver, SCShareableContent, SCStream, SCStreamConfiguration, - SCStreamDelegate, SCStreamErrorCode, SCStreamErrorDomain, SCStreamFrameInfoBoundingRect, - SCStreamFrameInfoContentRect, SCStreamFrameInfoContentScale, SCStreamFrameInfoDirtyRects, - SCStreamFrameInfoDisplayTime, SCStreamFrameInfoScaleFactor, SCStreamFrameInfoScreenRect, - SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, SCWindow, + SCStreamConfigurationPreset, SCStreamDelegate, SCStreamErrorCode, SCStreamErrorDomain, + SCStreamFrameInfoBoundingRect, SCStreamFrameInfoContentRect, SCStreamFrameInfoContentScale, + SCStreamFrameInfoDirtyRects, SCStreamFrameInfoDisplayTime, SCStreamFrameInfoScaleFactor, + SCStreamFrameInfoScreenRect, SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, + SCWindow, }; use crate::diagnostics::CallbackCounters; use crate::worker::{LatestSampleInput, LatestSampleWorker, SamplePublishOutcome}; use crate::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, - MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureError, MacosCapturePixelFormat, - MacosCaptureSelection, MacosCaptureSelector, MacosCaptureSurface, MacosChromaLocation, - MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosFrameMailbox, - MacosFrameStatus, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, - MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, - MacosScale, MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, + MacosCaptureCapabilities, MacosCaptureColorimetry, MacosCaptureContentStyle, + MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSelection, + MacosCaptureSelector, MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, + MacosColorRange, MacosConfiguredStream, MacosDeliveredFrameMetadata, MacosFrameDecoder, + MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosHostArchitecture, MacosPixelExtent, + MacosPixelRect, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, + MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosRuntimeCapability, + MacosScale, MacosStreamDeliveryRejection, MacosStreamDeliveryState, + MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeRuntimeProbes, + MacosTransferFunction, MacosYuvMatrix, }; type PoolBackingLifetime = Arc; @@ -302,7 +312,7 @@ fn publish_decoded_result( result: Result, epoch: u64, streams: &Weak, - shared: &SessionShared, + shared: &Arc, ) { match result { Ok(MacosFrameEvent::Frame(frame)) => { @@ -316,6 +326,9 @@ fn publish_decoded_result( } Ok(event) if shared.current_epoch() == epoch => shared.publish(event), Ok(_) => {} + Err(error @ MacosCaptureError::StreamDeliveryRejected(_)) => { + handle_fatal_stream_error(streams, epoch, Arc::clone(shared), error); + } Err(error) => shared.counters.record_drop(&error), } } @@ -367,7 +380,7 @@ define_class!( }; let sample = match sample { Err(error @ MacosCaptureError::ScreenResourceExhausted { .. }) => { - handle_pool_admission_error( + handle_fatal_stream_error( &self.ivars().streams, self.ivars().epoch, Arc::clone(&self.ivars().shared), @@ -479,9 +492,9 @@ impl NativeStream { streams: Weak, reserve_pool: &PoolReservationFactory, ) -> Result { - let (configuration, display_filter, extent, pixel_format) = + let (configuration, display_filter, extent, configured_stream) = stream_configuration(filter, request)?; - let quote = conservative_pool_quote(extent, pixel_format)?; + let quote = conservative_pool_quote(extent, configured_stream.configured_pixel_format)?; let pool = reserve_pool(quote.per_surface_bytes, quote.stream_metadata_bytes)?; let selection = selection_from_filter(filter)?; // SAFETY: The picker callback supplies a live filter. Retaining it @@ -491,12 +504,15 @@ impl NativeStream { .ok_or(MacosCaptureError::RetainNativeFilterFailed)? }; let mut decoder = MacosFrameDecoder::new(epoch); + let mut delivery_validator = MacosStreamDeliveryValidator::new(configured_stream); + delivery_validator.validate_configuration()?; let worker_shared = Arc::clone(&shared); let worker_streams = streams.clone(); let worker = LatestSampleWorker::spawn( "hypercolor-macos-screen-capture", - move |sample: Result| { - sample.and_then(|sample| decode_sample(&mut decoder, sample)) + move |sample: Result| match sample { + Ok(sample) => decode_sample(&mut decoder, &mut delivery_validator, sample), + Err(error) => Err(reject_first_delivery(&mut delivery_validator, error)), }, move |result| { publish_decoded_result(result, epoch, &worker_streams, &worker_shared); @@ -827,7 +843,7 @@ fn handle_stream_error( } } -fn handle_pool_admission_error( +fn handle_fatal_stream_error( streams: &Weak, epoch: u64, shared: Arc, @@ -851,7 +867,7 @@ fn handle_pool_admission_error( }; let stop_shared = Arc::clone(&shared); if let Err(spawn_error) = std::thread::Builder::new() - .name("hypercolor-macos-screen-resource-stop".to_owned()) + .name("hypercolor-macos-screen-rejection-stop".to_owned()) .spawn(move || { if let Err(error) = retired.stop() { stop_shared.publish_recoverable_error(error); @@ -1074,6 +1090,10 @@ pub struct MacosScreenCaptureSession { } impl MacosScreenCaptureSession { + pub fn capabilities() -> Result { + native_capture_capabilities() + } + pub fn new( request: MacosStreamRequest, selector: MacosCaptureSelector, @@ -1093,6 +1113,7 @@ impl MacosScreenCaptureSession { A: Fn(u32, u64) -> Result, MacosCaptureError> + Send + Sync + 'static, { request.cadence.timescale()?; + native_capture_capabilities()?.validate_dynamic_range(request.dynamic_range)?; let reserve_pool = Arc::new(move |surface_bytes, metadata_bytes| { let observer = reserve_pool(surface_bytes, metadata_bytes)?; Ok(Arc::new(observer) as PoolObservation) @@ -1401,6 +1422,94 @@ impl Drop for MainThreadSession { } } +fn native_capture_capabilities() -> Result { + let host_architecture = match sysctl_i32(c"hw.optional.arm64")? { + Some(1) => MacosHostArchitecture::AppleSilicon, + Some(_) => MacosHostArchitecture::Intel, + None => { + return Err(MacosCaptureError::CapabilityProbeFailed( + "hw.optional.arm64", + )); + } + }; + let translated_process = sysctl_i32(c"sysctl.proc_translated")?.is_some_and(|value| value == 1); + let screenshot_configuration = AnyClass::get(c"SCScreenshotConfiguration"); + let screenshot_manager = AnyClass::get(c"SCScreenshotManager"); + let probes = MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: capability(dynamic_symbol_present( + c"CGContextGetContentToneMappingInfo", + )), + screenshot_configuration_class: capability(screenshot_configuration.is_some()), + screenshot_dynamic_range_selector: capability( + screenshot_configuration.is_some_and(|class| class.responds_to(sel!(setDynamicRange:))), + ), + screenshot_capture_selector: capability(screenshot_manager.is_some_and(|class| { + class.metaclass().responds_to(sel!( + captureScreenshotWithFilter:configuration:completionHandler: + )) + })), + }; + Ok(MacosCaptureCapabilities::from_runtime( + host_architecture, + translated_process, + probes, + )) +} + +const fn capability(present: bool) -> MacosRuntimeCapability { + if present { + MacosRuntimeCapability::Present + } else { + MacosRuntimeCapability::Absent + } +} + +fn sysctl_i32(name: &CStr) -> Result, MacosCaptureError> { + #[link(name = "System", kind = "dylib")] + unsafe extern "C-unwind" { + fn sysctlbyname( + name: *const c_char, + old_value: *mut c_void, + old_length: *mut usize, + new_value: *mut c_void, + new_length: usize, + ) -> i32; + } + + let mut value = 0_i32; + let mut length = std::mem::size_of::(); + // SAFETY: Both output pointers reference initialized writable storage, the + // name is nul-terminated, and this query performs no mutation. + let status = unsafe { + sysctlbyname( + name.as_ptr(), + ptr::from_mut(&mut value).cast(), + &mut length, + ptr::null_mut(), + 0, + ) + }; + if status == 0 && length == std::mem::size_of::() { + Ok(Some(value)) + } else if status != 0 { + Ok(None) + } else { + Err(MacosCaptureError::CapabilityProbeFailed("sysctl size")) + } +} + +fn dynamic_symbol_present(symbol: &CStr) -> bool { + #[link(name = "System", kind = "dylib")] + unsafe extern "C-unwind" { + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + } + + let default_handle = ptr::without_provenance_mut::(usize::MAX - 1); + // SAFETY: RTLD_DEFAULT is the Darwin sentinel pointer with address -2, + // and the supplied symbol name is nul-terminated. + !unsafe { dlsym(default_handle, symbol.as_ptr()) }.is_null() +} + fn stream_configuration( filter: &SCContentFilter, request: MacosStreamRequest, @@ -1409,7 +1518,7 @@ fn stream_configuration( Retained, bool, MacosPixelExtent, - MacosCapturePixelFormat, + MacosConfiguredStream, ), MacosCaptureError, > { @@ -1438,10 +1547,18 @@ fn stream_configuration( let minimum_frame_interval = unsafe { cadence_timescale.map_or_else(|| CMTime::new(0, 1), |timescale| CMTime::new(1, timescale)) }; - // SAFETY: Every setter receives validated point or pixel units, and the - // configuration is retained by the caller before stream creation. + // SAFETY: The deployment floor includes the HDR preset API. Every setter + // receives validated point or pixel units, and the caller retains the + // configuration through stream creation. let configuration = unsafe { - let configuration = SCStreamConfiguration::new(); + let configuration = match request.preset() { + MacosStreamPreset::SdrDefault => SCStreamConfiguration::new(), + MacosStreamPreset::CaptureHdrStreamCanonicalDisplay => { + SCStreamConfiguration::streamConfigurationWithPreset( + SCStreamConfigurationPreset::CaptureHDRStreamCanonicalDisplay, + ) + } + }; configuration.setCapturesAudio(false); configuration.setCaptureMicrophone(false); configuration.setCaptureResolution(SCCaptureResolutionType::Best); @@ -1459,15 +1576,47 @@ fn stream_configuration( configuration.setShowMouseClicks(false); configuration.setStreamName(Some(&NSString::from_str("Hypercolor"))); configuration.setQueueDepth(MACOS_STREAM_QUEUE_DEPTH as isize); - configuration.setPixelFormat(0x4247_5241); + if request.dynamic_range == MacosCaptureDynamicRange::Sdr { + configuration.setCaptureDynamicRange(SCCaptureDynamicRange::SDR); + configuration.setPixelFormat(0x4247_5241); + } configuration }; - Ok(( - configuration, - display_filter, - extent, - MacosCapturePixelFormat::Bgra8, - )) + // SAFETY: The retained configuration exposes scalar values initialized by + // its constructor and the setters above. + let configured_stream = unsafe { + let pixel_format_fourcc = configuration.pixelFormat(); + MacosConfiguredStream { + requested_dynamic_range: request.dynamic_range, + requested_preset: request.preset(), + configured_dynamic_range: capture_dynamic_range(configuration.captureDynamicRange())?, + configured_pixel_format: MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?, + configured_color_range: color_range_from_fourcc(pixel_format_fourcc), + } + }; + configured_stream.validate()?; + Ok((configuration, display_filter, extent, configured_stream)) +} + +const fn color_range_from_fourcc(fourcc: u32) -> MacosColorRange { + match fourcc { + 0x3432_3076 | 0x7834_3434 => MacosColorRange::Video, + _ => MacosColorRange::Full, + } +} + +fn capture_dynamic_range( + value: SCCaptureDynamicRange, +) -> Result { + match value { + SCCaptureDynamicRange::SDR => Ok(MacosCaptureDynamicRange::Sdr), + SCCaptureDynamicRange::HDRLocalDisplay | SCCaptureDynamicRange::HDRCanonicalDisplay => { + Ok(MacosCaptureDynamicRange::Hdr) + } + _ => Err(MacosCaptureError::UnsupportedConfiguredDynamicRange( + value.0, + )), + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1569,6 +1718,7 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { fn decode_sample( decoder: &mut MacosFrameDecoder, + delivery_validator: &mut MacosStreamDeliveryValidator, sample: RetainedNativeSample, ) -> Result { let status = match sample.attachments.status { @@ -1587,18 +1737,59 @@ fn decode_sample( let pixel_buffer = sample .pixel_buffer - .ok_or(MacosCaptureError::MissingFramePayload)?; + .ok_or(MacosCaptureError::MissingFramePayload) + .map_err(|error| reject_first_delivery(delivery_validator, error))?; let frame = decode_complete_frame( pixel_buffer, sample.admission_lifetime, sample.cursor_composed, - )?; + ) + .map_err(|error| reject_first_delivery(delivery_validator, error))?; + if matches!( + delivery_validator.state(), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) + ) { + delivery_validator.observe_first_complete(frame.surface.delivery_metadata())?; + } decoder.decode(MacosRawCaptureSample { frame: Some(frame), attachments: sample.attachments, }) } +fn reject_first_delivery( + validator: &mut MacosStreamDeliveryValidator, + error: MacosCaptureError, +) -> MacosCaptureError { + if !matches!( + validator.state(), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) + ) { + return error; + } + let rejection = match &error { + MacosCaptureError::MissingFramePayload => { + Some(MacosStreamDeliveryRejection::MissingFirstCompleteFrame) + } + MacosCaptureError::UnsupportedPixelFormat(_) => { + Some(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("pixel_format")) + } + MacosCaptureError::MissingColorAttachment(field) + | MacosCaptureError::UnsupportedColorAttachment(field) + | MacosCaptureError::MalformedLuminanceAttachment(field) => { + Some(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata(field)) + } + MacosCaptureError::ColorMetadataMismatch | MacosCaptureError::MissingYuvColorMetadata => { + Some(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("colorimetry")) + } + _ => None, + }; + rejection.map_or(error, |rejection| { + validator.reject_delivery(rejection); + MacosCaptureError::StreamDeliveryRejected(rejection) + }) +} + fn decode_complete_frame( pixel_buffer: CFRetained, admission_lifetime: Option, @@ -1612,7 +1803,18 @@ fn decode_complete_frame( let pixel_format = MacosCapturePixelFormat::from_fourcc(pixel_format_fourcc)?; let planes = planes(&pixel_buffer, storage_extent)?; let color = colorimetry(&pixel_buffer, pixel_format_fourcc, pixel_format)?; - let surface = MacosCaptureSurface::from_pixel_buffer(pixel_buffer, admission_lifetime)?; + let (source_reference_white_nits, content_headroom) = hdr_luminance_metadata(&pixel_buffer)?; + let delivery_metadata = MacosDeliveredFrameMetadata::new( + pixel_format, + color, + source_reference_white_nits, + content_headroom, + )?; + let surface = MacosCaptureSurface::from_pixel_buffer_with_delivery_metadata( + pixel_buffer, + admission_lifetime, + Some(delivery_metadata), + )?; Ok(MacosRawCompleteFrame { storage_extent, @@ -1749,6 +1951,82 @@ fn colorimetry( }) } +fn hdr_luminance_metadata( + pixel_buffer: &CVPixelBuffer, +) -> Result<(Option, Option), MacosCaptureError> { + let content_headroom = content_headroom(pixel_buffer)?; + let content_peak_nits = content_peak_nits(pixel_buffer)?; + let source_reference_white_nits = content_peak_nits + .zip(content_headroom) + .map(|(peak, headroom)| peak / headroom) + .filter(|reference| reference.is_finite() && *reference > 0.0); + Ok((source_reference_white_nits, content_headroom)) +} + +fn content_headroom(pixel_buffer: &CVPixelBuffer) -> Result, MacosCaptureError> { + let surface = + CVPixelBufferGetIOSurface(Some(pixel_buffer)).ok_or(MacosCaptureError::MissingIoSurface)?; + // SAFETY: This is a process-lifetime IOSurface key available at the macOS + // 15.2 deployment floor. + let value = surface.value(unsafe { kIOSurfaceContentHeadroom }); + let Some(value) = value else { + return Ok(None); + }; + let headroom = value + .downcast_ref::() + .and_then(CFNumber::as_f64) + .map(|value| value as f32) + .filter(|value| value.is_finite() && *value >= 1.0) + .ok_or(MacosCaptureError::MalformedLuminanceAttachment( + "content_headroom", + ))?; + Ok(Some(headroom)) +} + +fn content_peak_nits(pixel_buffer: &CVBuffer) -> Result, MacosCaptureError> { + // SAFETY: This is a process-lifetime Core Video key, and a null mode + // pointer explicitly requests no attachment-mode output. + let value = + unsafe { pixel_buffer.attachment(kCVImageBufferContentLightLevelInfoKey, ptr::null_mut()) }; + let Some(value) = value else { + return Ok(None); + }; + let bytes = cf_data_bytes(&value).ok_or(MacosCaptureError::MalformedLuminanceAttachment( + "content_light_level_info", + ))?; + if bytes.len() != 4 { + return Err(MacosCaptureError::MalformedLuminanceAttachment( + "content_light_level_info", + )); + } + let max_content_light_level = u16::from_be_bytes([bytes[0], bytes[1]]); + Ok((max_content_light_level != 0).then_some(f32::from(max_content_light_level))) +} + +fn cf_data_bytes(value: &CFType) -> Option<&[u8]> { + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C-unwind" { + fn CFDataGetTypeID() -> usize; + fn CFDataGetLength(data: *const c_void) -> isize; + fn CFDataGetBytePtr(data: *const c_void) -> *const u8; + } + + // SAFETY: The CFType is live for the returned borrow, and the type ID is + // checked before calling CFData accessors. + unsafe { + if CFGetTypeID(Some(value)) != CFDataGetTypeID() { + return None; + } + let data = ptr::from_ref(value).cast::(); + let length = usize::try_from(CFDataGetLength(data)).ok()?; + let bytes = CFDataGetBytePtr(data); + if bytes.is_null() && length != 0 { + return None; + } + Some(std::slice::from_raw_parts(bytes, length)) + } +} + fn yuv_matrix(pixel_buffer: &CVBuffer) -> Result { // SAFETY: These Core Video constants are process-lifetime immutable CFString // references supplied by the linked framework. @@ -1965,10 +2243,46 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use super::{ - MacosCaptureError, MacosCapturePixelFormat, MacosPixelExtent, PoolBackingLifetime, - PoolObservation, conservative_pool_quote, with_admitted_surface, + MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, + MacosConfiguredStream, MacosPixelExtent, MacosStreamPreset, PoolBackingLifetime, + PoolObservation, SCCaptureDynamicRange, SCStreamConfiguration, SCStreamConfigurationPreset, + capture_dynamic_range, color_range_from_fourcc, conservative_pool_quote, + with_admitted_surface, }; + #[test] + fn canonical_hdr_preset_resolves_to_a_valid_hdr_configuration() { + // SAFETY: The deployment floor includes this pure configuration + // constructor, which does not start capture or request TCC access. + let configuration = unsafe { + SCStreamConfiguration::streamConfigurationWithPreset( + SCStreamConfigurationPreset::CaptureHDRStreamCanonicalDisplay, + ) + }; + // SAFETY: Both values are initialized scalar configuration properties. + let configured = unsafe { + let fourcc = configuration.pixelFormat(); + assert_eq!( + configuration.captureDynamicRange(), + SCCaptureDynamicRange::HDRCanonicalDisplay + ); + MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Hdr, + requested_preset: MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + configured_dynamic_range: capture_dynamic_range( + configuration.captureDynamicRange(), + ) + .expect("preset dynamic range should decode"), + configured_pixel_format: MacosCapturePixelFormat::from_fourcc(fourcc) + .expect("preset pixel format should be supported"), + configured_color_range: color_range_from_fourcc(fourcc), + } + }; + configured + .validate() + .expect("canonical HDR preset should resolve to an accepted stream format"); + } + #[test] fn conservative_bgra_pool_quote_covers_aligned_native_storage() { let extent = MacosPixelExtent::new(3_840, 2_160).expect("4K extent is valid"); @@ -1979,6 +2293,19 @@ mod tests { assert!(quote.stream_metadata_bytes > 0); } + #[test] + fn hdr_pool_quotes_cover_rgba16f_and_multiplane_storage() { + let extent = MacosPixelExtent::new(3_840, 2_160).expect("4K extent is valid"); + let rgba = conservative_pool_quote(extent, MacosCapturePixelFormat::Rgba16Float) + .expect("RGBA16F quote should fit"); + let yuv = conservative_pool_quote(extent, MacosCapturePixelFormat::Yuv420VideoRange) + .expect("YUV quote should fit"); + assert!(rgba.per_surface_bytes >= 3_840 * 2_160 * 8); + assert!(yuv.per_surface_bytes >= 3_840 * 2_160 * 3 / 2); + assert_eq!(rgba.per_surface_bytes % (16 * 1024), 0); + assert_eq!(yuv.per_surface_bytes % (16 * 1024), 0); + } + #[test] fn rejected_surface_never_reaches_the_retain_operation() { let pool = Arc::new(|_, _| -> Result { diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs index 480f8f277..595b312ca 100644 --- a/crates/hypercolor-macos-capture/src/session.rs +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::MacosCaptureError; +use crate::{MacosCaptureDynamicRange, MacosCaptureError, MacosStreamPreset}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MacosCaptureSelector { @@ -94,6 +94,7 @@ impl MacosCaptureCadence { pub struct MacosStreamRequest { pub cadence: MacosCaptureCadence, pub cursor_composed: bool, + pub dynamic_range: MacosCaptureDynamicRange, } impl MacosStreamRequest { @@ -105,8 +106,29 @@ impl MacosStreamRequest { Ok(Self { cadence, cursor_composed, + dynamic_range: MacosCaptureDynamicRange::Sdr, }) } + + pub fn new_hdr( + cadence: MacosCaptureCadence, + cursor_composed: bool, + ) -> Result { + cadence.timescale()?; + Ok(Self { + cadence, + cursor_composed, + dynamic_range: MacosCaptureDynamicRange::Hdr, + }) + } + + #[must_use] + pub const fn preset(self) -> MacosStreamPreset { + match self.dynamic_range { + MacosCaptureDynamicRange::Sdr => MacosStreamPreset::SdrDefault, + MacosCaptureDynamicRange::Hdr => MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + } + } } impl Default for MacosStreamRequest { @@ -114,6 +136,7 @@ impl Default for MacosStreamRequest { Self { cadence: MacosCaptureCadence::FramesPerSecond(60), cursor_composed: true, + dynamic_range: MacosCaptureDynamicRange::Sdr, } } } diff --git a/crates/hypercolor-macos-capture/src/stream_contract.rs b/crates/hypercolor-macos-capture/src/stream_contract.rs new file mode 100644 index 000000000..d3860f174 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/stream_contract.rs @@ -0,0 +1,424 @@ +use thiserror::Error; + +use crate::{ + MacosCaptureColorimetry, MacosCapturePixelFormat, MacosColorRange, MacosTransferFunction, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosCaptureDynamicRange { + Sdr, + Hdr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosStreamPreset { + SdrDefault, + CaptureHdrStreamCanonicalDisplay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosHostArchitecture { + AppleSilicon, + Intel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosRuntimeCapability { + Present, + Absent, +} + +impl MacosRuntimeCapability { + #[must_use] + pub const fn is_present(self) -> bool { + matches!(self, Self::Present) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosTahoeRuntimeProbes { + pub content_tone_mapping_info_symbol: MacosRuntimeCapability, + pub screenshot_configuration_class: MacosRuntimeCapability, + pub screenshot_dynamic_range_selector: MacosRuntimeCapability, + pub screenshot_capture_selector: MacosRuntimeCapability, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosTahoeCapabilities { + pub content_tone_mapping_info: MacosRuntimeCapability, + pub dual_range_screenshots: MacosRuntimeCapability, +} + +impl MacosTahoeCapabilities { + #[must_use] + pub const fn from_probes(probes: MacosTahoeRuntimeProbes) -> Self { + let dual_range_screenshots = if probes.screenshot_configuration_class.is_present() + && probes.screenshot_dynamic_range_selector.is_present() + && probes.screenshot_capture_selector.is_present() + { + MacosRuntimeCapability::Present + } else { + MacosRuntimeCapability::Absent + }; + Self { + content_tone_mapping_info: probes.content_tone_mapping_info_symbol, + dual_range_screenshots, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosCaptureCapabilities { + pub host_architecture: MacosHostArchitecture, + pub translated_process: bool, + pub hdr_stream: MacosRuntimeCapability, + pub tahoe: MacosTahoeCapabilities, +} + +impl MacosCaptureCapabilities { + #[must_use] + pub const fn from_runtime( + host_architecture: MacosHostArchitecture, + translated_process: bool, + tahoe_probes: MacosTahoeRuntimeProbes, + ) -> Self { + let hdr_stream = match host_architecture { + MacosHostArchitecture::AppleSilicon => MacosRuntimeCapability::Present, + MacosHostArchitecture::Intel => MacosRuntimeCapability::Absent, + }; + Self { + host_architecture, + translated_process, + hdr_stream, + tahoe: MacosTahoeCapabilities::from_probes(tahoe_probes), + } + } + + pub fn validate_dynamic_range( + self, + dynamic_range: MacosCaptureDynamicRange, + ) -> Result<(), MacosStreamDeliveryRejection> { + if dynamic_range == MacosCaptureDynamicRange::Hdr && !self.hdr_stream.is_present() { + return Err(MacosStreamDeliveryRejection::UnsupportedIntelHdr); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacosConfiguredStream { + pub requested_dynamic_range: MacosCaptureDynamicRange, + pub requested_preset: MacosStreamPreset, + pub configured_dynamic_range: MacosCaptureDynamicRange, + pub configured_pixel_format: MacosCapturePixelFormat, + pub configured_color_range: MacosColorRange, +} + +impl MacosConfiguredStream { + pub fn validate(self) -> Result<(), MacosStreamDeliveryRejection> { + let expected_preset = match self.requested_dynamic_range { + MacosCaptureDynamicRange::Sdr => MacosStreamPreset::SdrDefault, + MacosCaptureDynamicRange::Hdr => MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + }; + if self.requested_preset != expected_preset { + return Err(MacosStreamDeliveryRejection::PresetMismatch { + requested: self.requested_dynamic_range, + preset: self.requested_preset, + }); + } + if self.configured_dynamic_range != self.requested_dynamic_range { + return Err( + MacosStreamDeliveryRejection::ConfiguredDynamicRangeMismatch { + requested: self.requested_dynamic_range, + configured: self.configured_dynamic_range, + }, + ); + } + validate_format_for_dynamic_range( + self.configured_pixel_format, + self.configured_dynamic_range, + )?; + validate_color_range(self.configured_pixel_format, self.configured_color_range) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacosDeliveredFrameMetadata { + pub dynamic_range: MacosCaptureDynamicRange, + pub pixel_format: MacosCapturePixelFormat, + pub color: MacosCaptureColorimetry, + pub source_reference_white_nits: Option, + pub content_headroom: Option, +} + +impl MacosDeliveredFrameMetadata { + pub fn new( + pixel_format: MacosCapturePixelFormat, + color: MacosCaptureColorimetry, + source_reference_white_nits: Option, + content_headroom: Option, + ) -> Result { + color.validate_for(pixel_format).map_err(|_| { + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("colorimetry") + })?; + validate_optional_positive(source_reference_white_nits, "source_reference_white_nits")?; + if content_headroom.is_some_and(|headroom| !headroom.is_finite() || headroom < 1.0) { + return Err( + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("content_headroom"), + ); + } + let dynamic_range = delivered_dynamic_range(pixel_format, color.transfer)?; + Ok(Self { + dynamic_range, + pixel_format, + color, + source_reference_white_nits, + content_headroom, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacosValidatedStreamDelivery { + pub configured: MacosConfiguredStream, + pub delivered: MacosDeliveredFrameMetadata, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MacosStreamDeliveryState { + AwaitingFirstCompleteFrame(MacosConfiguredStream), + Confirmed(MacosValidatedStreamDelivery), + Rejected(MacosStreamDeliveryRejection), +} + +#[derive(Debug, Clone)] +pub struct MacosStreamDeliveryValidator { + state: MacosStreamDeliveryState, +} + +impl MacosStreamDeliveryValidator { + #[must_use] + pub const fn new(configured: MacosConfiguredStream) -> Self { + Self { + state: MacosStreamDeliveryState::AwaitingFirstCompleteFrame(configured), + } + } + + #[must_use] + pub const fn state(&self) -> &MacosStreamDeliveryState { + &self.state + } + + pub fn validate_configuration(&mut self) -> Result<(), MacosStreamDeliveryRejection> { + let MacosStreamDeliveryState::AwaitingFirstCompleteFrame(configured) = self.state else { + return self.current_result().map(|_| ()); + }; + configured + .validate() + .map_err(|rejection| self.reject(rejection)) + } + + pub fn observe_first_complete( + &mut self, + delivered: Option, + ) -> Result { + let MacosStreamDeliveryState::AwaitingFirstCompleteFrame(configured) = self.state else { + return self.current_result(); + }; + configured + .validate() + .map_err(|rejection| self.reject(rejection))?; + let delivered = delivered + .ok_or_else(|| self.reject(MacosStreamDeliveryRejection::MissingFirstCompleteFrame))?; + if delivered.dynamic_range != configured.configured_dynamic_range { + return Err(self.reject( + MacosStreamDeliveryRejection::DeliveredDynamicRangeMismatch { + configured: configured.configured_dynamic_range, + delivered: delivered.dynamic_range, + }, + )); + } + if delivered.pixel_format != configured.configured_pixel_format { + return Err( + self.reject(MacosStreamDeliveryRejection::DeliveredPixelFormatMismatch { + configured: configured.configured_pixel_format, + delivered: delivered.pixel_format, + }), + ); + } + if delivered.color.range != configured.configured_color_range { + return Err( + self.reject(MacosStreamDeliveryRejection::DeliveredColorRangeMismatch { + configured: configured.configured_color_range, + delivered: delivered.color.range, + }), + ); + } + validate_format_for_dynamic_range(delivered.pixel_format, delivered.dynamic_range) + .map_err(|rejection| self.reject(rejection))?; + let validated = MacosValidatedStreamDelivery { + configured, + delivered, + }; + self.state = MacosStreamDeliveryState::Confirmed(validated); + Ok(validated) + } + + pub fn finish_without_complete_frame( + &mut self, + ) -> Result { + match self.state { + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) => { + Err(self.reject(MacosStreamDeliveryRejection::MissingFirstCompleteFrame)) + } + _ => self.current_result(), + } + } + + pub fn reject_delivery( + &mut self, + rejection: MacosStreamDeliveryRejection, + ) -> MacosStreamDeliveryRejection { + self.reject(rejection) + } + + fn current_result(&self) -> Result { + match self.state { + MacosStreamDeliveryState::Confirmed(delivery) => Ok(delivery), + MacosStreamDeliveryState::Rejected(rejection) => Err(rejection), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) => { + Err(MacosStreamDeliveryRejection::MissingFirstCompleteFrame) + } + } + } + + fn reject(&mut self, rejection: MacosStreamDeliveryRejection) -> MacosStreamDeliveryRejection { + self.state = MacosStreamDeliveryState::Rejected(rejection); + rejection + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum MacosStreamDeliveryRejection { + #[error("ScreenCaptureKit HDR capture is unsupported on Intel hosts")] + UnsupportedIntelHdr, + #[error("{preset:?} does not request {requested:?} capture")] + PresetMismatch { + requested: MacosCaptureDynamicRange, + preset: MacosStreamPreset, + }, + #[error("requested {requested:?} capture but configuration resolved {configured:?}")] + ConfiguredDynamicRangeMismatch { + requested: MacosCaptureDynamicRange, + configured: MacosCaptureDynamicRange, + }, + #[error("configured {configured:?} capture but first frame delivered {delivered:?}")] + DeliveredDynamicRangeMismatch { + configured: MacosCaptureDynamicRange, + delivered: MacosCaptureDynamicRange, + }, + #[error("configured {configured:?} pixels but first frame delivered {delivered:?}")] + DeliveredPixelFormatMismatch { + configured: MacosCapturePixelFormat, + delivered: MacosCapturePixelFormat, + }, + #[error("configured {configured:?} range but first frame delivered {delivered:?}")] + DeliveredColorRangeMismatch { + configured: MacosColorRange, + delivered: MacosColorRange, + }, + #[error("{format:?} is not a supported {dynamic_range:?} stream format")] + UnsupportedFormatForDynamicRange { + format: MacosCapturePixelFormat, + dynamic_range: MacosCaptureDynamicRange, + }, + #[error("first complete frame was not delivered")] + MissingFirstCompleteFrame, + #[error("first complete frame lacks valid {0}")] + MissingOrInvalidDeliveryMetadata(&'static str), +} + +fn validate_optional_positive( + value: Option, + field: &'static str, +) -> Result<(), MacosStreamDeliveryRejection> { + if value.is_some_and(|value| !value.is_finite() || value <= 0.0) { + return Err(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata(field)); + } + Ok(()) +} + +fn delivered_dynamic_range( + format: MacosCapturePixelFormat, + transfer: MacosTransferFunction, +) -> Result { + match format { + MacosCapturePixelFormat::Bgra8 => match transfer { + MacosTransferFunction::Pq | MacosTransferFunction::Hlg => { + Err(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("dynamic_range")) + } + _ => Ok(MacosCaptureDynamicRange::Sdr), + }, + MacosCapturePixelFormat::Argb2101010 | MacosCapturePixelFormat::Rgba16Float => { + Ok(MacosCaptureDynamicRange::Hdr) + } + MacosCapturePixelFormat::Yuv420VideoRange + | MacosCapturePixelFormat::Yuv420FullRange + | MacosCapturePixelFormat::Yuv44410BiPlanar => match transfer { + MacosTransferFunction::Pq | MacosTransferFunction::Hlg => { + Ok(MacosCaptureDynamicRange::Hdr) + } + _ => Ok(MacosCaptureDynamicRange::Sdr), + }, + } +} + +fn validate_format_for_dynamic_range( + format: MacosCapturePixelFormat, + dynamic_range: MacosCaptureDynamicRange, +) -> Result<(), MacosStreamDeliveryRejection> { + let supported = match dynamic_range { + MacosCaptureDynamicRange::Sdr => format == MacosCapturePixelFormat::Bgra8, + MacosCaptureDynamicRange::Hdr => matches!( + format, + MacosCapturePixelFormat::Argb2101010 + | MacosCapturePixelFormat::Rgba16Float + | MacosCapturePixelFormat::Yuv420VideoRange + | MacosCapturePixelFormat::Yuv420FullRange + | MacosCapturePixelFormat::Yuv44410BiPlanar + ), + }; + if supported { + Ok(()) + } else { + Err( + MacosStreamDeliveryRejection::UnsupportedFormatForDynamicRange { + format, + dynamic_range, + }, + ) + } +} + +fn validate_color_range( + format: MacosCapturePixelFormat, + range: MacosColorRange, +) -> Result<(), MacosStreamDeliveryRejection> { + let valid = match format { + MacosCapturePixelFormat::Yuv420VideoRange => range == MacosColorRange::Video, + MacosCapturePixelFormat::Bgra8 + | MacosCapturePixelFormat::Argb2101010 + | MacosCapturePixelFormat::Rgba16Float + | MacosCapturePixelFormat::Yuv420FullRange => range == MacosColorRange::Full, + MacosCapturePixelFormat::Yuv44410BiPlanar => true, + }; + if valid { + Ok(()) + } else { + Err( + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata( + "configured_color_range", + ), + ) + } +} diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 843729ea6..c81116d84 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -2,13 +2,16 @@ use std::sync::Arc; use hypercolor_macos_capture::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, - MacosCaptureCallbackDiagnostics, MacosCaptureColorimetry, MacosCaptureError, - MacosCapturePixelFormat, MacosCaptureSelector, MacosCaptureSurface, MacosChromaLocation, - MacosColorPrimaries, MacosColorRange, MacosDisplayClock, MacosDisplayClockError, + MacosCaptureCallbackDiagnostics, MacosCaptureCapabilities, MacosCaptureColorimetry, + MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSelector, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + MacosConfiguredStream, MacosDeliveredFrameMetadata, MacosDisplayClock, MacosDisplayClockError, MacosFrameDecoder, MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, - MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosRawCapturePlane, - MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosScale, - MacosStreamRequest, MacosTransferFunction, MacosYuvMatrix, + MacosGeometryError, MacosHostArchitecture, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, + MacosRuntimeCapability, MacosScale, MacosStreamDeliveryRejection, MacosStreamDeliveryState, + MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeRuntimeProbes, + MacosTransferFunction, MacosYuvMatrix, }; use std::time::{Duration, Instant}; @@ -60,6 +63,17 @@ fn system_display_clock_reads_the_native_timebase() { MacosDisplayClock::system().expect("macOS exposes its monotonic timebase"); } +#[cfg(target_os = "macos")] +#[test] +fn native_capability_probe_resolves_without_an_os_version_check() { + let capabilities = hypercolor_macos_capture::MacosScreenCaptureSession::capabilities() + .expect("native capability probes should resolve"); + assert_eq!( + capabilities.hdr_stream.is_present(), + capabilities.host_architecture == MacosHostArchitecture::AppleSilicon + ); +} + #[test] fn queue_depth_is_the_full_framework_limit() { assert_eq!(MACOS_STREAM_QUEUE_DEPTH, 8); @@ -81,6 +95,273 @@ fn stream_requests_preserve_native_refresh_and_reject_invalid_rates() { MacosStreamRequest::default().cadence, MacosCaptureCadence::FramesPerSecond(60) ); + assert_eq!( + MacosStreamRequest::default().preset(), + MacosStreamPreset::SdrDefault + ); + assert_eq!( + MacosStreamRequest::new_hdr(MacosCaptureCadence::NativeRefresh, true) + .expect("valid HDR stream request") + .preset(), + MacosStreamPreset::CaptureHdrStreamCanonicalDisplay + ); +} + +#[test] +fn hdr_preset_is_only_requested_evidence_until_rgba16f_arrives() { + let configured = configured_hdr(MacosCapturePixelFormat::Rgba16Float); + let mut validator = MacosStreamDeliveryValidator::new(configured); + validator + .validate_configuration() + .expect("canonical HDR configuration should validate"); + assert_eq!( + validator.state(), + &MacosStreamDeliveryState::AwaitingFirstCompleteFrame(configured) + ); + + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + Some(203.0), + Some(4.0), + ) + .expect("RGBA16F HDR metadata should validate"); + let confirmed = validator + .observe_first_complete(Some(delivered)) + .expect("matching first complete frame should confirm HDR"); + assert_eq!(confirmed.configured, configured); + assert_eq!(confirmed.delivered, delivered); + assert!(matches!( + validator.state(), + MacosStreamDeliveryState::Confirmed(_) + )); + + let surface = MacosCaptureSurface::new_fixture(1, 64, 1) + .expect("fixture surface") + .with_delivery_metadata(delivered) + .expect("fixture metadata"); + assert_eq!(surface.delivery_metadata(), Some(delivered)); +} + +#[test] +fn hdr_yuv_delivery_preserves_exact_color_and_luminance_metadata() { + let color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: Some(MacosChromaLocation::TopLeft), + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Yuv420VideoRange, + color, + Some(203.0), + Some(1000.0 / 203.0), + ) + .expect("complete YUV HDR metadata should validate"); + let mut validator = MacosStreamDeliveryValidator::new(configured_hdr( + MacosCapturePixelFormat::Yuv420VideoRange, + )); + let confirmed = validator + .observe_first_complete(Some(delivered)) + .expect("matching YUV delivery should confirm"); + assert_eq!(confirmed.delivered.color, color); + assert_eq!( + confirmed.delivered.pixel_format, + MacosCapturePixelFormat::Yuv420VideoRange + ); + assert_eq!(confirmed.delivered.source_reference_white_nits, Some(203.0)); + assert_eq!(confirmed.delivered.content_headroom, Some(1000.0 / 203.0)); +} + +#[test] +fn first_complete_frame_rejects_range_format_and_missing_delivery() { + let mut configured_range = MacosStreamDeliveryValidator::new(MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Hdr, + requested_preset: MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + configured_dynamic_range: MacosCaptureDynamicRange::Sdr, + configured_pixel_format: MacosCapturePixelFormat::Bgra8, + configured_color_range: MacosColorRange::Full, + }); + assert_eq!( + configured_range.validate_configuration(), + Err( + MacosStreamDeliveryRejection::ConfiguredDynamicRangeMismatch { + requested: MacosCaptureDynamicRange::Hdr, + configured: MacosCaptureDynamicRange::Sdr, + } + ) + ); + + let rgba = hdr_rgb_metadata(MacosCapturePixelFormat::Rgba16Float); + let mut format = + MacosStreamDeliveryValidator::new(configured_hdr(MacosCapturePixelFormat::Argb2101010)); + assert_eq!( + format.observe_first_complete(Some(rgba)), + Err(MacosStreamDeliveryRejection::DeliveredPixelFormatMismatch { + configured: MacosCapturePixelFormat::Argb2101010, + delivered: MacosCapturePixelFormat::Rgba16Float, + }) + ); + + let mut range = + MacosStreamDeliveryValidator::new(configured_hdr(MacosCapturePixelFormat::Rgba16Float)); + let sdr = + MacosDeliveredFrameMetadata::new(MacosCapturePixelFormat::Bgra8, rgb_color(), None, None) + .expect("SDR metadata"); + assert_eq!( + range.observe_first_complete(Some(sdr)), + Err( + MacosStreamDeliveryRejection::DeliveredDynamicRangeMismatch { + configured: MacosCaptureDynamicRange::Hdr, + delivered: MacosCaptureDynamicRange::Sdr, + } + ) + ); + + let yuv444_video = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Yuv44410BiPlanar, + yuv_color(MacosColorRange::Video), + Some(203.0), + Some(4.0), + ) + .expect("valid video-range YUV444 metadata"); + let mut yuv444 = MacosStreamDeliveryValidator::new(configured_hdr( + MacosCapturePixelFormat::Yuv44410BiPlanar, + )); + assert_eq!( + yuv444.observe_first_complete(Some(yuv444_video)), + Err(MacosStreamDeliveryRejection::DeliveredColorRangeMismatch { + configured: MacosColorRange::Full, + delivered: MacosColorRange::Video, + }) + ); + + let mut missing = + MacosStreamDeliveryValidator::new(configured_hdr(MacosCapturePixelFormat::Rgba16Float)); + assert_eq!( + missing.finish_without_complete_frame(), + Err(MacosStreamDeliveryRejection::MissingFirstCompleteFrame) + ); + assert_eq!( + missing.state(), + &MacosStreamDeliveryState::Rejected( + MacosStreamDeliveryRejection::MissingFirstCompleteFrame + ) + ); +} + +#[test] +fn missing_or_invalid_hdr_attachments_are_typed_rejections() { + assert_eq!( + MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Yuv420VideoRange, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: None, + }, + Some(203.0), + Some(4.0), + ), + Err(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("colorimetry")) + ); + assert_eq!( + MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_rgb_color(), + Some(203.0), + Some(0.5), + ), + Err(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("content_headroom")) + ); +} + +#[test] +fn sdr_delivery_preserves_existing_bgra_contract() { + let configured = MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Sdr, + requested_preset: MacosStreamPreset::SdrDefault, + configured_dynamic_range: MacosCaptureDynamicRange::Sdr, + configured_pixel_format: MacosCapturePixelFormat::Bgra8, + configured_color_range: MacosColorRange::Full, + }; + let delivered = + MacosDeliveredFrameMetadata::new(MacosCapturePixelFormat::Bgra8, rgb_color(), None, None) + .expect("existing BGRA SDR metadata should remain valid"); + let mut validator = MacosStreamDeliveryValidator::new(configured); + assert_eq!( + validator + .observe_first_complete(Some(delivered)) + .expect("SDR delivery should confirm") + .delivered, + delivered + ); +} + +#[test] +fn intel_hdr_is_rejected_while_sdr_remains_supported() { + let capabilities = MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::Intel, + false, + absent_tahoe_probes(), + ); + assert_eq!(capabilities.hdr_stream, MacosRuntimeCapability::Absent); + assert_eq!( + capabilities.validate_dynamic_range(MacosCaptureDynamicRange::Hdr), + Err(MacosStreamDeliveryRejection::UnsupportedIntelHdr) + ); + assert_eq!( + capabilities.validate_dynamic_range(MacosCaptureDynamicRange::Sdr), + Ok(()) + ); +} + +#[test] +fn tahoe_capabilities_require_callable_runtime_surface() { + let present = MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::AppleSilicon, + false, + MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Present, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + }, + ); + assert_eq!( + present.tahoe.content_tone_mapping_info, + MacosRuntimeCapability::Present + ); + assert_eq!( + present.tahoe.dual_range_screenshots, + MacosRuntimeCapability::Present + ); + + let missing_selector = MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::AppleSilicon, + false, + MacosTahoeRuntimeProbes { + screenshot_capture_selector: MacosRuntimeCapability::Absent, + ..absent_tahoe_probes_with_screenshot_types() + }, + ); + assert_eq!( + missing_selector.tahoe.content_tone_mapping_info, + MacosRuntimeCapability::Absent + ); + assert_eq!( + missing_selector.tahoe.dual_range_screenshots, + MacosRuntimeCapability::Absent + ); } #[test] @@ -717,6 +998,52 @@ fn yuv_color(range: MacosColorRange) -> MacosCaptureColorimetry { } } +fn hdr_rgb_color() -> MacosCaptureColorimetry { + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + } +} + +fn hdr_rgb_metadata(format: MacosCapturePixelFormat) -> MacosDeliveredFrameMetadata { + MacosDeliveredFrameMetadata::new(format, hdr_rgb_color(), Some(203.0), Some(4.0)) + .expect("HDR RGB metadata should validate") +} + +fn configured_hdr(format: MacosCapturePixelFormat) -> MacosConfiguredStream { + MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Hdr, + requested_preset: MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + configured_dynamic_range: MacosCaptureDynamicRange::Hdr, + configured_pixel_format: format, + configured_color_range: match format { + MacosCapturePixelFormat::Yuv420VideoRange => MacosColorRange::Video, + _ => MacosColorRange::Full, + }, + } +} + +const fn absent_tahoe_probes() -> MacosTahoeRuntimeProbes { + MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Absent, + screenshot_configuration_class: MacosRuntimeCapability::Absent, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Absent, + screenshot_capture_selector: MacosRuntimeCapability::Absent, + } +} + +const fn absent_tahoe_probes_with_screenshot_types() -> MacosTahoeRuntimeProbes { + MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Absent, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Absent, + } +} + fn pixel_extent(width: u32, height: u32) -> MacosPixelExtent { MacosPixelExtent::new(width, height).expect("fixture extent should be valid") } From 64b4864069a2d90030f0b4605203b9f145d5e2a9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 23:53:50 -0700 Subject: [PATCH 063/144] fix(macos): bind HDR capability to delivered streams Use the canonical l10r encoding. Resolve host architecture without rejecting Intel systems. Publish Tahoe capabilities only after the selected stream confirms them on its first frame. Preserve the live stream across failed replacement. Treat malformed later metadata as typed per-frame drops. Co-Authored-By: Feynman (GPT-5) --- .../src/diagnostics.rs | 17 +- crates/hypercolor-macos-capture/src/frame.rs | 13 +- crates/hypercolor-macos-capture/src/lib.rs | 2 +- crates/hypercolor-macos-capture/src/native.rs | 558 ++++++++++++++++-- .../src/stream_contract.rs | 193 +++++- .../tests/capture_contract_tests.rs | 43 +- 6 files changed, 762 insertions(+), 64 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 163417df7..c6a11ae87 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -82,6 +82,7 @@ impl MacosFrameDropReason { | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted | MacosCaptureError::StreamDeliveryRejected(_) + | MacosCaptureError::FrameDeliveryDropped(_) | MacosCaptureError::CapabilityProbeFailed(_) | MacosCaptureError::Geometry(_) => Self::Validation, MacosCaptureError::ScreenResourceExhausted { .. } => Self::Resource, @@ -154,7 +155,9 @@ impl CallbackCounters { #[cfg(test)] mod tests { - use super::{MacosCaptureError, MacosFrameDropReason}; + use crate::MacosStreamDeliveryRejection; + + use super::{CallbackCounters, MacosCaptureError, MacosFrameDropReason}; #[test] fn resource_exhaustion_has_a_distinct_drop_reason() { @@ -166,4 +169,16 @@ mod tests { MacosFrameDropReason::Resource ); } + + #[test] + fn dropped_delivery_metadata_increments_the_validation_counter() { + let counters = CallbackCounters::default(); + counters.record_drop(&MacosCaptureError::FrameDeliveryDropped( + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("colorimetry"), + )); + + let diagnostics = counters.snapshot(0); + assert_eq!(diagnostics.total_dropped(), 1); + assert_eq!(diagnostics.dropped(MacosFrameDropReason::Validation), 1); + } } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 70f7766a4..8bde21cd3 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -27,7 +27,7 @@ use crate::{MacosDeliveredFrameMetadata, MacosStreamDeliveryRejection}; pub const MACOS_STREAM_QUEUE_DEPTH: usize = 8; const BGRA8: u32 = 0x4247_5241; -const ARGB2101010: u32 = 0x5231_306b; +const ARGB2101010: u32 = u32::from_be_bytes(*b"l10r"); const RGBA16_FLOAT: u32 = 0x5247_6841; const YUV420_VIDEO_RANGE: u32 = 0x3432_3076; const YUV420_FULL_RANGE: u32 = 0x3432_3066; @@ -397,13 +397,18 @@ impl MacosCaptureSurface { mut self, delivery_metadata: MacosDeliveredFrameMetadata, ) -> Result { - MacosDeliveredFrameMetadata::new( + let validated = MacosDeliveredFrameMetadata::new( delivery_metadata.pixel_format, delivery_metadata.color, delivery_metadata.source_reference_white_nits, delivery_metadata.content_headroom, )?; - self.delivery_metadata = Some(delivery_metadata); + if validated.dynamic_range != delivery_metadata.dynamic_range { + return Err( + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("dynamic_range"), + ); + } + self.delivery_metadata = Some(validated); Ok(self) } @@ -1035,6 +1040,8 @@ pub enum MacosCaptureError { ColorMetadataMismatch, #[error(transparent)] StreamDeliveryRejected(#[from] MacosStreamDeliveryRejection), + #[error("capture frame delivery metadata was rejected: {0}")] + FrameDeliveryDropped(MacosStreamDeliveryRejection), #[error("macOS capture capability probe failed: {0}")] CapabilityProbeFailed(&'static str), #[error("malformed HDR luminance attachment: {0}")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 609024c71..6956cf6d2 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -43,5 +43,5 @@ pub use stream_contract::{ MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosRuntimeCapability, MacosStreamDeliveryRejection, MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, - MacosValidatedStreamDelivery, + MacosTahoeSelectionCapabilities, MacosValidatedStreamDelivery, }; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 97b433277..daef464b3 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -50,6 +50,7 @@ use objc2_screen_capture_kit::{ }; use crate::diagnostics::CallbackCounters; +use crate::stream_contract::MacosTahoeSelectionCapabilityState; use crate::worker::{LatestSampleInput, LatestSampleWorker, SamplePublishOutcome}; use crate::{ MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, @@ -61,8 +62,9 @@ use crate::{ MacosPixelRect, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosRuntimeCapability, MacosScale, MacosStreamDeliveryRejection, MacosStreamDeliveryState, - MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeRuntimeProbes, - MacosTransferFunction, MacosYuvMatrix, + MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeCapabilities, + MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, MacosTransferFunction, + MacosValidatedStreamDelivery, MacosYuvMatrix, }; type PoolBackingLifetime = Arc; @@ -78,21 +80,33 @@ const MACOS_IOSURFACE_ALLOCATION_ALIGNMENT: u64 = 16 * 1024; struct SessionShared { mailbox: MacosFrameMailbox, status: Mutex, - selection: Mutex, + selection: Mutex, selector: Mutex, + tahoe: MacosTahoeCapabilities, counters: CallbackCounters, capture_active: AtomicBool, current_epoch: AtomicU64, resolution_epoch: AtomicU64, } +#[derive(Debug, Default)] +struct SessionSelectionState { + selection: MacosCaptureSelection, + tahoe: MacosTahoeSelectionCapabilityState, +} + impl SessionShared { - fn new(status: MacosProtectedSourceState, selector: MacosCaptureSelector) -> Self { + fn new( + status: MacosProtectedSourceState, + selector: MacosCaptureSelector, + tahoe: MacosTahoeCapabilities, + ) -> Self { Self { mailbox: MacosFrameMailbox::new(), status: Mutex::new(status), - selection: Mutex::new(MacosCaptureSelection::None), + selection: Mutex::new(SessionSelectionState::default()), selector: Mutex::new(selector), + tahoe, counters: CallbackCounters::default(), capture_active: AtomicBool::new(false), current_epoch: AtomicU64::new(0), @@ -109,11 +123,37 @@ impl SessionShared { } fn selection(&self) -> MacosCaptureSelection { - lock(&self.selection).clone() + lock(&self.selection).selection.clone() + } + + fn set_unconfirmed_selection(&self, selection: MacosCaptureSelection) { + let mut state = lock(&self.selection); + state.selection = selection; + state.tahoe.clear(); + } + + fn confirm_selection( + &self, + selection: MacosCaptureSelection, + source_id: Arc, + epoch: u64, + delivery: MacosValidatedStreamDelivery, + ) { + let mut state = lock(&self.selection); + state.selection = selection; + state.tahoe.confirm(source_id, epoch, delivery, self.tahoe); } - fn set_selection(&self, selection: MacosCaptureSelection) { - *lock(&self.selection) = selection; + fn clear_tahoe_selection(&self) { + lock(&self.selection).tahoe.clear(); + } + + fn tahoe_selection_for( + &self, + source_id: &str, + epoch: u64, + ) -> Option { + lock(&self.selection).tahoe.current_for(source_id, epoch) } fn selector(&self) -> MacosCaptureSelector { @@ -199,6 +239,11 @@ struct RetainedNativeSample { cursor_composed: bool, } +struct DecodedSample { + event: MacosFrameEvent, + confirmed_delivery: Option, +} + // SAFETY: The retained Core Video pixel buffer is reference-counted and the // decode worker only reads its immutable descriptor metadata. unsafe impl Send for RetainedNativeSample {} @@ -309,22 +354,27 @@ fn borrowed_surface_identity( } fn publish_decoded_result( - result: Result, + result: Result, epoch: u64, streams: &Weak, shared: &Arc, ) { match result { - Ok(MacosFrameEvent::Frame(frame)) => { + Ok(DecodedSample { + event: MacosFrameEvent::Frame(frame), + confirmed_delivery, + }) => { let active = shared.current_epoch() == epoch || streams .upgrade() - .is_some_and(|streams| streams.activate(epoch)); + .is_some_and(|streams| streams.activate(epoch, confirmed_delivery)); if active { shared.publish(MacosFrameEvent::Frame(frame)); } } - Ok(event) if shared.current_epoch() == epoch => shared.publish(event), + Ok(DecodedSample { event, .. }) if shared.current_epoch() == epoch => { + shared.publish(event); + } Ok(_) => {} Err(error @ MacosCaptureError::StreamDeliveryRejected(_)) => { handle_fatal_stream_error(streams, epoch, Arc::clone(shared), error); @@ -473,6 +523,7 @@ struct NativeStream { stream: Retained, filter: NativeFilter, selection: MacosCaptureSelection, + source_id: Arc, worker: LatestSampleWorker>, _output: Retained, _queue: DispatchRetained, @@ -497,6 +548,7 @@ impl NativeStream { let quote = conservative_pool_quote(extent, configured_stream.configured_pixel_format)?; let pool = reserve_pool(quote.per_surface_bytes, quote.stream_metadata_bytes)?; let selection = selection_from_filter(filter)?; + let source_id = selection_source_id(filter, &selection); // SAFETY: The picker callback supplies a live filter. Retaining it // preserves the immutable selection through stream retirement. let retained_filter = unsafe { @@ -512,7 +564,7 @@ impl NativeStream { "hypercolor-macos-screen-capture", move |sample: Result| match sample { Ok(sample) => decode_sample(&mut decoder, &mut delivery_validator, sample), - Err(error) => Err(reject_first_delivery(&mut delivery_validator, error)), + Err(error) => Err(classify_delivery_error(&mut delivery_validator, error)), }, move |result| { publish_decoded_result(result, epoch, &worker_streams, &worker_shared); @@ -560,6 +612,7 @@ impl NativeStream { stream, filter: NativeFilter(retained_filter), selection, + source_id, worker, _output: output, _queue: queue, @@ -673,7 +726,11 @@ impl StreamSlot { Ok(()) } - fn activate(&self, epoch: u64) -> bool { + fn activate( + &self, + epoch: u64, + confirmed_delivery: Option, + ) -> bool { let previous = { let mut state = lock(&self.state); let Some(candidate) = state @@ -682,10 +739,19 @@ impl StreamSlot { else { return false; }; + let Some(confirmed_delivery) = confirmed_delivery else { + state.candidate = Some(candidate); + return false; + }; let previous = state.current.replace(candidate); state.selected_filter = state.current.as_ref().map(|current| current.filter.clone()); if let Some(current) = &state.current { - self.shared.set_selection(current.selection.clone()); + self.shared.confirm_selection( + current.selection.clone(), + Arc::clone(¤t.source_id), + epoch, + confirmed_delivery, + ); } self.shared.activate_epoch(epoch); previous @@ -712,6 +778,7 @@ impl StreamSlot { { let current = state.current.take(); self.shared.activate_epoch(0); + self.shared.clear_tahoe_selection(); return (StreamRole::Current, current); } (StreamRole::Stale, None) @@ -733,6 +800,13 @@ impl StreamSlot { lock(&self.state).current.is_some() } + fn active_identity(&self) -> Option<(Arc, u64)> { + lock(&self.state) + .current + .as_ref() + .map(|current| (Arc::clone(¤t.source_id), current.epoch())) + } + fn has_selection(&self) -> bool { lock(&self.state).selected_filter.is_some() } @@ -746,7 +820,7 @@ impl StreamSlot { .ok_or(MacosCaptureError::RetainNativeFilterFailed)? }; lock(&self.state).selected_filter = Some(NativeFilter(filter)); - self.shared.set_selection(selection); + self.shared.set_unconfirmed_selection(selection); Ok(()) } @@ -773,6 +847,7 @@ impl StreamSlot { (state.current.take(), state.candidate.take()) }; self.shared.activate_epoch(0); + self.shared.clear_tahoe_selection(); if let Some(candidate) = candidate { self.stop_stream(candidate); } @@ -1113,17 +1188,21 @@ impl MacosScreenCaptureSession { A: Fn(u32, u64) -> Result, MacosCaptureError> + Send + Sync + 'static, { request.cadence.timescale()?; - native_capture_capabilities()?.validate_dynamic_range(request.dynamic_range)?; + let capabilities = native_capture_capabilities()?; + capabilities.validate_dynamic_range(request.dynamic_range)?; let reserve_pool = Arc::new(move |surface_bytes, metadata_bytes| { let observer = reserve_pool(surface_bytes, metadata_bytes)?; Ok(Arc::new(observer) as PoolObservation) }) as PoolReservationFactory; - dispatch2::run_on_main(move |mtm| Self::new_on_main(request, selector, reserve_pool, mtm)) + dispatch2::run_on_main(move |mtm| { + Self::new_on_main(request, selector, capabilities.tahoe, reserve_pool, mtm) + }) } fn new_on_main( request: MacosStreamRequest, selector: MacosCaptureSelector, + tahoe: MacosTahoeCapabilities, reserve_pool: PoolReservationFactory, mtm: MainThreadMarker, ) -> Result { @@ -1133,7 +1212,7 @@ impl MacosScreenCaptureSession { } else { MacosProtectedSourceState::NeedsUserAction }; - let shared = Arc::new(SessionShared::new(status, selector)); + let shared = Arc::new(SessionShared::new(status, selector, tahoe)); let observer = PickerObserver::new(mtm, request, Arc::clone(&shared), reserve_pool); let streams = Arc::clone(&observer.ivars().streams); // SAFETY: These are main-thread ScreenCaptureKit setup calls. The @@ -1208,6 +1287,11 @@ impl MacosScreenCaptureSession { self.shared.selection() } + pub fn tahoe_selection_capabilities(&self) -> Option { + let (source_id, epoch) = self.streams.active_identity()?; + self.shared.tahoe_selection_for(&source_id, epoch) + } + pub fn mailbox(&self) -> MacosFrameMailbox { self.shared.mailbox.clone() } @@ -1379,6 +1463,63 @@ fn selection_from_filter( } } +fn selection_source_id(filter: &SCContentFilter, selection: &MacosCaptureSelection) -> Arc { + match selection { + MacosCaptureSelection::Display { source_id } => Arc::clone(source_id), + MacosCaptureSelection::SessionScoped { content_style } => { + // SAFETY: The retained filter owns immutable selected-content + // arrays and their members for the duration of this query. + let (window_ids, application_ids) = unsafe { + ( + filter + .includedWindows() + .to_vec() + .into_iter() + .map(|window| window.windowID()) + .collect::>(), + filter + .includedApplications() + .to_vec() + .into_iter() + .map(|application| application.bundleIdentifier().to_string()) + .collect::>(), + ) + }; + session_selection_source_id(*content_style, window_ids, application_ids) + } + MacosCaptureSelection::None => Arc::from("macos:session"), + } +} + +fn session_selection_source_id( + content_style: MacosCaptureContentStyle, + mut window_ids: Vec, + mut application_ids: Vec, +) -> Arc { + window_ids.sort_unstable(); + window_ids.dedup(); + application_ids.sort_unstable(); + application_ids.dedup(); + let mut source_id = format!("macos:session:{}", content_style_name(content_style)); + for window_id in window_ids { + source_id.push_str(&format!(":w{window_id}")); + } + for application_id in application_ids { + source_id.push_str(&format!(":a{}:{application_id}", application_id.len())); + } + Arc::from(source_id) +} + +const fn content_style_name(content_style: MacosCaptureContentStyle) -> &'static str { + match content_style { + MacosCaptureContentStyle::Window => "window", + MacosCaptureContentStyle::MultipleWindows => "multiple-windows", + MacosCaptureContentStyle::Application => "application", + MacosCaptureContentStyle::MultipleApplications => "multiple-applications", + MacosCaptureContentStyle::Mixed => "mixed", + } +} + fn display_source_id(display_id: CGDirectDisplayID) -> Result { let uuid = display_uuid(display_id).ok_or(MacosCaptureError::DisplayUuidUnavailable(display_id))?; @@ -1423,16 +1564,6 @@ impl Drop for MainThreadSession { } fn native_capture_capabilities() -> Result { - let host_architecture = match sysctl_i32(c"hw.optional.arm64")? { - Some(1) => MacosHostArchitecture::AppleSilicon, - Some(_) => MacosHostArchitecture::Intel, - None => { - return Err(MacosCaptureError::CapabilityProbeFailed( - "hw.optional.arm64", - )); - } - }; - let translated_process = sysctl_i32(c"sysctl.proc_translated")?.is_some_and(|value| value == 1); let screenshot_configuration = AnyClass::get(c"SCScreenshotConfiguration"); let screenshot_manager = AnyClass::get(c"SCScreenshotManager"); let probes = MacosTahoeRuntimeProbes { @@ -1449,11 +1580,11 @@ fn native_capture_capabilities() -> Result MacosRuntimeCapability { @@ -1464,7 +1595,32 @@ const fn capability(present: bool) -> MacosRuntimeCapability { } } -fn sysctl_i32(name: &CStr) -> Result, MacosCaptureError> { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SysctlI32Value { + Present(i32), + Missing, +} + +fn capture_capabilities_from_probes( + arm64: Result, + translated: Result, + tahoe: MacosTahoeRuntimeProbes, +) -> Result { + let arm64 = arm64?; + let translated_process = matches!(translated?, SysctlI32Value::Present(1)); + let host_architecture = if matches!(arm64, SysctlI32Value::Present(1)) || translated_process { + MacosHostArchitecture::AppleSilicon + } else { + MacosHostArchitecture::Intel + }; + Ok(MacosCaptureCapabilities::from_runtime( + host_architecture, + translated_process, + tahoe, + )) +} + +fn sysctl_i32(name: &CStr, failure: &'static str) -> Result { #[link(name = "System", kind = "dylib")] unsafe extern "C-unwind" { fn sysctlbyname( @@ -1490,9 +1646,13 @@ fn sysctl_i32(name: &CStr) -> Result, MacosCaptureError> { ) }; if status == 0 && length == std::mem::size_of::() { - Ok(Some(value)) + Ok(SysctlI32Value::Present(value)) } else if status != 0 { - Ok(None) + if std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound { + Ok(SysctlI32Value::Missing) + } else { + Err(MacosCaptureError::CapabilityProbeFailed(failure)) + } } else { Err(MacosCaptureError::CapabilityProbeFailed("sysctl size")) } @@ -1720,7 +1880,7 @@ fn decode_sample( decoder: &mut MacosFrameDecoder, delivery_validator: &mut MacosStreamDeliveryValidator, sample: RetainedNativeSample, -) -> Result { +) -> Result { let status = match sample.attachments.status { MacosAttachment::Value(status) => MacosFrameStatus::try_from(status)?, MacosAttachment::Missing => return Err(MacosCaptureError::MissingAttachment("status")), @@ -1729,32 +1889,74 @@ fn decode_sample( } }; if status != MacosFrameStatus::Complete { - return decoder.decode(MacosRawCaptureSample { - frame: None, - attachments: sample.attachments, - }); + return decoder + .decode(MacosRawCaptureSample { + frame: None, + attachments: sample.attachments, + }) + .map(|event| DecodedSample { + event, + confirmed_delivery: None, + }); } + let awaiting_first_delivery = matches!( + delivery_validator.state(), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) + ); let pixel_buffer = sample .pixel_buffer .ok_or(MacosCaptureError::MissingFramePayload) - .map_err(|error| reject_first_delivery(delivery_validator, error))?; + .map_err(|error| classify_delivery_error(delivery_validator, error))?; let frame = decode_complete_frame( pixel_buffer, sample.admission_lifetime, sample.cursor_composed, ) - .map_err(|error| reject_first_delivery(delivery_validator, error))?; + .map_err(|error| classify_delivery_error(delivery_validator, error))?; + let event = decoder + .decode(MacosRawCaptureSample { + frame: Some(frame), + attachments: sample.attachments, + }) + .map_err(|error| classify_delivery_error(delivery_validator, error))?; + let confirmed_delivery = if awaiting_first_delivery { + let MacosFrameEvent::Frame(frame) = &event else { + return Err(classify_delivery_error( + delivery_validator, + MacosCaptureError::MissingFramePayload, + )); + }; + Some( + delivery_validator + .observe_first_complete(frame.surface.delivery_metadata()) + .map_err(MacosCaptureError::StreamDeliveryRejected)?, + ) + } else { + None + }; + Ok(DecodedSample { + event, + confirmed_delivery, + }) +} + +fn classify_delivery_error( + validator: &mut MacosStreamDeliveryValidator, + error: MacosCaptureError, +) -> MacosCaptureError { if matches!( - delivery_validator.state(), + validator.state(), MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) ) { - delivery_validator.observe_first_complete(frame.surface.delivery_metadata())?; + return reject_first_delivery(validator, error); + } + match error { + MacosCaptureError::StreamDeliveryRejected(rejection) => { + MacosCaptureError::FrameDeliveryDropped(rejection) + } + error => error, } - decoder.decode(MacosRawCaptureSample { - frame: Some(frame), - attachments: sample.attachments, - }) } fn reject_first_delivery( @@ -1782,6 +1984,7 @@ fn reject_first_delivery( MacosCaptureError::ColorMetadataMismatch | MacosCaptureError::MissingYuvColorMetadata => { Some(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("colorimetry")) } + MacosCaptureError::StreamDeliveryRejected(rejection) => Some(*rejection), _ => None, }; rejection.map_or(error, |rejection| { @@ -2243,13 +2446,262 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use super::{ - MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, - MacosConfiguredStream, MacosPixelExtent, MacosStreamPreset, PoolBackingLifetime, - PoolObservation, SCCaptureDynamicRange, SCStreamConfiguration, SCStreamConfigurationPreset, - capture_dynamic_range, color_range_from_fourcc, conservative_pool_quote, + MacosCaptureColorimetry, MacosCaptureDynamicRange, MacosCaptureError, + MacosCapturePixelFormat, MacosColorPrimaries, MacosColorRange, MacosConfiguredStream, + MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosPixelExtent, + MacosProtectedSourceState, MacosRuntimeCapability, MacosStreamDeliveryRejection, + MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, + MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTransferFunction, + MacosValidatedStreamDelivery, PoolBackingLifetime, PoolObservation, SCCaptureDynamicRange, + SCStreamConfiguration, SCStreamConfigurationPreset, SessionShared, SysctlI32Value, + capture_capabilities_from_probes, capture_dynamic_range, classify_delivery_error, + color_range_from_fourcc, conservative_pool_quote, session_selection_source_id, with_admitted_surface, }; + const ABSENT_TAHOE_PROBES: MacosTahoeRuntimeProbes = MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Absent, + screenshot_configuration_class: MacosRuntimeCapability::Absent, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Absent, + screenshot_capture_selector: MacosRuntimeCapability::Absent, + }; + + #[test] + fn missing_arm64_and_translation_sysctls_resolve_native_intel_sdr() { + let capabilities = capture_capabilities_from_probes( + Ok(SysctlI32Value::Missing), + Ok(SysctlI32Value::Missing), + ABSENT_TAHOE_PROBES, + ) + .expect("missing Apple Silicon sysctls identify a native Intel host"); + + assert_eq!(capabilities.host_architecture, MacosHostArchitecture::Intel); + assert!(!capabilities.translated_process); + assert_eq!( + capabilities.validate_dynamic_range(MacosCaptureDynamicRange::Sdr), + Ok(()) + ); + assert_eq!( + capabilities.validate_dynamic_range(MacosCaptureDynamicRange::Hdr), + Err(MacosStreamDeliveryRejection::UnsupportedIntelHdr) + ); + } + + #[test] + fn translated_process_resolves_the_native_apple_silicon_host() { + let capabilities = capture_capabilities_from_probes( + Ok(SysctlI32Value::Missing), + Ok(SysctlI32Value::Present(1)), + ABSENT_TAHOE_PROBES, + ) + .expect("translation is direct evidence of an Apple Silicon host"); + + assert_eq!( + capabilities.host_architecture, + MacosHostArchitecture::AppleSilicon + ); + assert!(capabilities.translated_process); + assert_eq!( + capabilities.validate_dynamic_range(MacosCaptureDynamicRange::Hdr), + Ok(()) + ); + } + + #[test] + fn nonmissing_sysctl_failures_remain_typed() { + assert_eq!( + capture_capabilities_from_probes( + Err(MacosCaptureError::CapabilityProbeFailed( + "hw.optional.arm64" + )), + Ok(SysctlI32Value::Missing), + ABSENT_TAHOE_PROBES, + ), + Err(MacosCaptureError::CapabilityProbeFailed( + "hw.optional.arm64" + )) + ); + } + + #[test] + fn partial_tahoe_runtime_surfaces_fail_closed_per_capability() { + let screenshot_only = MacosTahoeRuntimeProbes { + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + ..ABSENT_TAHOE_PROBES + }; + let capabilities = capture_capabilities_from_probes( + Ok(SysctlI32Value::Present(1)), + Ok(SysctlI32Value::Missing), + screenshot_only, + ) + .expect("independent Tahoe capability probes should not disable capture"); + + assert_eq!( + capabilities.tahoe.content_tone_mapping_info, + MacosRuntimeCapability::Absent + ); + assert_eq!( + capabilities.tahoe.screenshot_api, + MacosRuntimeCapability::Present + ); + + let incomplete_screenshot = MacosTahoeRuntimeProbes { + screenshot_configuration_class: MacosRuntimeCapability::Present, + ..ABSENT_TAHOE_PROBES + }; + let capabilities = capture_capabilities_from_probes( + Ok(SysctlI32Value::Present(1)), + Ok(SysctlI32Value::Missing), + incomplete_screenshot, + ) + .expect("an incomplete diagnostic surface should not disable streaming"); + assert_eq!( + capabilities.tahoe.screenshot_api, + MacosRuntimeCapability::Absent + ); + } + + #[test] + fn malformed_delivery_metadata_is_fatal_only_before_confirmation() { + let configured = MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Sdr, + requested_preset: MacosStreamPreset::SdrDefault, + configured_dynamic_range: MacosCaptureDynamicRange::Sdr, + configured_pixel_format: MacosCapturePixelFormat::Bgra8, + configured_color_range: MacosColorRange::Full, + }; + let rejection = + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("dynamic_range"); + let mut awaiting = MacosStreamDeliveryValidator::new(configured); + assert_eq!( + classify_delivery_error( + &mut awaiting, + MacosCaptureError::StreamDeliveryRejected(rejection), + ), + MacosCaptureError::StreamDeliveryRejected(rejection) + ); + assert_eq!( + awaiting.state(), + &MacosStreamDeliveryState::Rejected(rejection) + ); + + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Bgra8, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + None, + None, + ) + .expect("valid SDR delivery"); + let mut confirmed = MacosStreamDeliveryValidator::new(configured); + confirmed + .observe_first_complete(Some(delivered)) + .expect("matching delivery should confirm the stream"); + + assert_eq!( + classify_delivery_error( + &mut confirmed, + MacosCaptureError::StreamDeliveryRejected(rejection), + ), + MacosCaptureError::FrameDeliveryDropped(rejection) + ); + assert!(matches!( + confirmed.state(), + MacosStreamDeliveryState::Confirmed(_) + )); + } + + #[test] + fn session_selection_identity_is_canonical_and_membership_exact() { + let window_ids = vec![41, 7, 41]; + let application_ids = vec![ + "tech.hyperbliss.zeta".to_owned(), + "tech.hyperbliss.alpha".to_owned(), + "tech.hyperbliss.zeta".to_owned(), + ]; + + assert_eq!( + session_selection_source_id( + super::MacosCaptureContentStyle::Mixed, + window_ids, + application_ids, + ) + .as_ref(), + "macos:session:mixed:w7:w41:a21:tech.hyperbliss.alpha:a20:tech.hyperbliss.zeta" + ); + } + + #[test] + fn repick_preserves_the_live_record_until_replacement_confirms() { + let tahoe = MacosTahoeCapabilities { + content_tone_mapping_info: MacosRuntimeCapability::Present, + screenshot_api: MacosRuntimeCapability::Present, + }; + let shared = SessionShared::new( + MacosProtectedSourceState::Live, + super::MacosCaptureSelector::Auto, + tahoe, + ); + let configured = MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Sdr, + requested_preset: MacosStreamPreset::SdrDefault, + configured_dynamic_range: MacosCaptureDynamicRange::Sdr, + configured_pixel_format: MacosCapturePixelFormat::Bgra8, + configured_color_range: MacosColorRange::Full, + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Bgra8, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + None, + None, + ) + .expect("valid SDR delivery"); + let delivery = MacosValidatedStreamDelivery { + configured, + delivered, + }; + shared.confirm_selection( + super::MacosCaptureSelection::Display { + source_id: Arc::from("display:a"), + }, + Arc::from("display:a"), + 1, + delivery, + ); + + shared + .begin_resolution() + .expect("repick resolution should begin"); + assert!(shared.tahoe_selection_for("display:a", 1).is_some()); + + shared.confirm_selection( + super::MacosCaptureSelection::Display { + source_id: Arc::from("display:b"), + }, + Arc::from("display:b"), + 2, + delivery, + ); + assert_eq!(shared.tahoe_selection_for("display:a", 1), None); + assert!(shared.tahoe_selection_for("display:b", 2).is_some()); + + shared.clear_tahoe_selection(); + assert_eq!(shared.tahoe_selection_for("display:b", 2), None); + } + #[test] fn canonical_hdr_preset_resolves_to_a_valid_hdr_configuration() { // SAFETY: The deployment floor includes this pure configuration diff --git a/crates/hypercolor-macos-capture/src/stream_contract.rs b/crates/hypercolor-macos-capture/src/stream_contract.rs index d3860f174..97dc59451 100644 --- a/crates/hypercolor-macos-capture/src/stream_contract.rs +++ b/crates/hypercolor-macos-capture/src/stream_contract.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use thiserror::Error; use crate::{ @@ -46,13 +48,13 @@ pub struct MacosTahoeRuntimeProbes { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MacosTahoeCapabilities { pub content_tone_mapping_info: MacosRuntimeCapability, - pub dual_range_screenshots: MacosRuntimeCapability, + pub screenshot_api: MacosRuntimeCapability, } impl MacosTahoeCapabilities { #[must_use] pub const fn from_probes(probes: MacosTahoeRuntimeProbes) -> Self { - let dual_range_screenshots = if probes.screenshot_configuration_class.is_present() + let screenshot_api = if probes.screenshot_configuration_class.is_present() && probes.screenshot_dynamic_range_selector.is_present() && probes.screenshot_capture_selector.is_present() { @@ -62,11 +64,67 @@ impl MacosTahoeCapabilities { }; Self { content_tone_mapping_info: probes.content_tone_mapping_info_symbol, - dual_range_screenshots, + screenshot_api, } } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MacosTahoeSelectionCapabilities { + pub source_id: Arc, + pub capture_session_generation: u64, + pub hdr_capture: bool, + pub dual_range_screenshots: bool, +} + +impl MacosTahoeSelectionCapabilities { + #[must_use] + pub fn matches_active_stream(&self, source_id: &str, capture_session_generation: u64) -> bool { + self.source_id.as_ref() == source_id + && self.capture_session_generation == capture_session_generation + } +} + +#[derive(Debug, Default)] +pub(crate) struct MacosTahoeSelectionCapabilityState { + current: Option, +} + +impl MacosTahoeSelectionCapabilityState { + pub(crate) fn confirm( + &mut self, + source_id: Arc, + capture_session_generation: u64, + delivery: MacosValidatedStreamDelivery, + host: MacosTahoeCapabilities, + ) { + let hdr_capture = delivery.delivered.dynamic_range == MacosCaptureDynamicRange::Hdr; + self.current = Some(MacosTahoeSelectionCapabilities { + source_id, + capture_session_generation, + hdr_capture, + dual_range_screenshots: hdr_capture && host.screenshot_api.is_present(), + }); + } + + pub(crate) fn current_for( + &self, + source_id: &str, + capture_session_generation: u64, + ) -> Option { + self.current + .as_ref() + .filter(|capabilities| { + capabilities.matches_active_stream(source_id, capture_session_generation) + }) + .cloned() + } + + pub(crate) fn clear(&mut self) { + self.current = None; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MacosCaptureCapabilities { pub host_architecture: MacosHostArchitecture, @@ -422,3 +480,132 @@ fn validate_color_range( ) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::MacosColorPrimaries; + + use super::{ + MacosCaptureCapabilities, MacosCaptureColorimetry, MacosCaptureDynamicRange, + MacosCapturePixelFormat, MacosColorRange, MacosConfiguredStream, + MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosRuntimeCapability, + MacosStreamPreset, MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilityState, + MacosTransferFunction, MacosValidatedStreamDelivery, + }; + + const PRESENT_TAHOE_PROBES: MacosTahoeRuntimeProbes = MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Present, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + }; + + #[test] + fn tahoe_selection_capabilities_are_absent_until_confirmed_and_fenced_by_source_and_epoch() { + let host = MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::AppleSilicon, + false, + PRESENT_TAHOE_PROBES, + ) + .tahoe; + let delivery = hdr_delivery(); + let mut state = MacosTahoeSelectionCapabilityState::default(); + + assert_eq!(state.current_for("display:a", 7), None); + state.confirm(Arc::from("display:a"), 7, delivery, host); + + let current = state + .current_for("display:a", 7) + .expect("the exact confirmed selection should resolve"); + assert!(current.hdr_capture); + assert!(current.dual_range_screenshots); + assert_eq!(state.current_for("display:b", 7), None); + assert_eq!(state.current_for("display:a", 8), None); + + state.clear(); + assert_eq!(state.current_for("display:a", 7), None); + + state.confirm(Arc::from("display:b"), 8, delivery, host); + assert_eq!(state.current_for("display:a", 7), None); + assert!(state.current_for("display:b", 8).is_some()); + + state.clear(); + assert_eq!(state.current_for("display:b", 8), None); + } + + #[test] + fn sdr_selection_never_advertises_paired_range_screenshots() { + let host = MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::Intel, + false, + PRESENT_TAHOE_PROBES, + ) + .tahoe; + let configured = MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Sdr, + requested_preset: MacosStreamPreset::SdrDefault, + configured_dynamic_range: MacosCaptureDynamicRange::Sdr, + configured_pixel_format: MacosCapturePixelFormat::Bgra8, + configured_color_range: MacosColorRange::Full, + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Bgra8, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + None, + None, + ) + .expect("valid SDR delivery"); + let mut state = MacosTahoeSelectionCapabilityState::default(); + + state.confirm( + Arc::from("display:intel"), + 11, + MacosValidatedStreamDelivery { + configured, + delivered, + }, + host, + ); + + let current = state + .current_for("display:intel", 11) + .expect("confirmed SDR selection should resolve"); + assert!(!current.hdr_capture); + assert!(!current.dual_range_screenshots); + } + + fn hdr_delivery() -> MacosValidatedStreamDelivery { + let configured = MacosConfiguredStream { + requested_dynamic_range: MacosCaptureDynamicRange::Hdr, + requested_preset: MacosStreamPreset::CaptureHdrStreamCanonicalDisplay, + configured_dynamic_range: MacosCaptureDynamicRange::Hdr, + configured_pixel_format: MacosCapturePixelFormat::Rgba16Float, + configured_color_range: MacosColorRange::Full, + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + Some(203.0), + Some(4.0), + ) + .expect("valid HDR delivery"); + MacosValidatedStreamDelivery { + configured, + delivered, + } + } +} diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index c81116d84..0a67875a9 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -16,7 +16,7 @@ use hypercolor_macos_capture::{ use std::time::{Duration, Instant}; const BGRA8: u32 = 0x4247_5241; -const ARGB2101010: u32 = 0x5231_306b; +const ARGB2101010: u32 = u32::from_be_bytes(*b"l10r"); const RGBA16_FLOAT: u32 = 0x5247_6841; const YUV420_VIDEO_RANGE: u32 = 0x3432_3076; const YUV420_FULL_RANGE: u32 = 0x3432_3066; @@ -149,6 +149,43 @@ fn hdr_preset_is_only_requested_evidence_until_rgba16f_arrives() { assert_eq!(surface.delivery_metadata(), Some(delivered)); } +#[test] +fn argb2101010_uses_the_screen_capture_kit_l10r_fourcc() { + assert_eq!(ARGB2101010.to_be_bytes(), *b"l10r"); + assert_eq!( + MacosCapturePixelFormat::from_fourcc(ARGB2101010), + Ok(MacosCapturePixelFormat::Argb2101010) + ); + assert_eq!( + MacosCapturePixelFormat::Argb2101010.fourcc(MacosColorRange::Full), + Ok(ARGB2101010) + ); + + let delivered = hdr_rgb_metadata(MacosCapturePixelFormat::Argb2101010); + let mut validator = + MacosStreamDeliveryValidator::new(configured_hdr(MacosCapturePixelFormat::Argb2101010)); + assert_eq!( + validator + .observe_first_complete(Some(delivered)) + .expect("l10r delivery should confirm canonical HDR") + .delivered, + delivered + ); +} + +#[test] +fn fixture_delivery_metadata_rejects_an_inconsistent_dynamic_range() { + let mut inconsistent = hdr_rgb_metadata(MacosCapturePixelFormat::Argb2101010); + inconsistent.dynamic_range = MacosCaptureDynamicRange::Sdr; + + assert!(matches!( + MacosCaptureSurface::new_fixture(1, 64, 1) + .expect("fixture surface") + .with_delivery_metadata(inconsistent), + Err(MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("dynamic_range")) + )); +} + #[test] fn hdr_yuv_delivery_preserves_exact_color_and_luminance_metadata() { let color = MacosCaptureColorimetry { @@ -342,7 +379,7 @@ fn tahoe_capabilities_require_callable_runtime_surface() { MacosRuntimeCapability::Present ); assert_eq!( - present.tahoe.dual_range_screenshots, + present.tahoe.screenshot_api, MacosRuntimeCapability::Present ); @@ -359,7 +396,7 @@ fn tahoe_capabilities_require_callable_runtime_surface() { MacosRuntimeCapability::Absent ); assert_eq!( - missing_selector.tahoe.dual_range_screenshots, + missing_selector.tahoe.screenshot_api, MacosRuntimeCapability::Absent ); } From a3f885212674cdb9722485515598314acbe5fe0d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 00:20:49 -0700 Subject: [PATCH 064/144] feat(macos): execute calibrated capture color processing Resolve measured LED calibration into stable physical route identity and execute SDR, wide-gamut, PQ, and extended-linear color work before every CPU reduction filter. Preserve 250 ms SDR and HDR curve handovers across plan swaps while applying calibration-only changes atomically at frame boundaries. Mirror daemon ownership and conflict state into both native source statuses. Co-Authored-By: Nova (GPT-5 Codex) --- crates/hypercolor-core/src/input/macos.rs | 22 +- crates/hypercolor-core/src/input/mod.rs | 35 + .../src/input/screen/fanout.rs | 210 ++- .../hypercolor-core/src/input/screen/macos.rs | 1394 ++++++++++++++++- .../src/input/screen/materialize.rs | 6 +- .../hypercolor-core/src/input/screen/mod.rs | 7 + .../src/input/screen/publication.rs | 95 +- .../src/input/screen/reducer.rs | 191 ++- .../src/input/screen/tone_map.rs | 938 +++++++++++ crates/hypercolor-core/src/input/traits.rs | 20 + .../tests/capture_color_contract_tests.rs | 299 +++- .../tests/macos_host_input_tests.rs | 22 +- .../tests/macos_screen_capture_tests.rs | 21 +- .../screen_cpu_branch_processing_tests.rs | 115 ++ .../tests/screen_cpu_publication_tests.rs | 2 + .../tests/screen_cpu_reducer_tests.rs | 331 +++- 16 files changed, 3483 insertions(+), 225 deletions(-) create mode 100644 crates/hypercolor-core/src/input/screen/tone_map.rs diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index 0b77d470b..c77605c30 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -102,6 +102,7 @@ pub struct MacosHostInput { status_session: SourceSessionSlot, keyboard_tcc: MacosAuthorizationState, owner: MacosCapabilityOwner, + owner_conflict: Option>, authorization_result: Arc, #[cfg(feature = "macos-native-fixtures")] fixture: Option>, @@ -260,6 +261,7 @@ impl MacosHostInput { status_session: SourceSessionSlot::new(), keyboard_tcc, owner: MacosCapabilityOwner::Standalone, + owner_conflict: None, authorization_result: Arc::new(AtomicU8::new(AUTHORIZATION_NONE)), #[cfg(feature = "macos-native-fixtures")] fixture: None, @@ -322,6 +324,16 @@ impl MacosHostInput { self.refresh_platform_status() } + fn set_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + ) -> anyhow::Result<()> { + self.owner = owner; + self.owner_conflict = conflict.map(Arc::new); + self.refresh_platform_status() + } + #[must_use] pub fn fold_diagnostics(&self) -> MacosInputFoldDiagnostics { self.shared @@ -484,7 +496,7 @@ impl MacosHostInput { keyboard_tcc: self.keyboard_tcc, keyboard_owner: self.owner, pointer_owner: self.owner, - owner_conflict: None, + owner_conflict: self.owner_conflict.clone(), }, )))?; Ok(()) @@ -726,6 +738,14 @@ impl InputSource for MacosHostInput { &self.name } + fn set_macos_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + ) -> anyhow::Result<()> { + self.set_daemon_ownership(owner, conflict) + } + fn start(&mut self) -> anyhow::Result<()> { if self.running { return Ok(()); diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index d1cc4a536..8e6ceba62 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -1886,6 +1886,41 @@ impl InputManager { result } + /// Apply processing-only screen settings without rebuilding native capture. + /// + /// # Errors + /// + /// Returns an error if a registered screen source rejects the profile. + pub fn reconfigure_screen_processing( + &mut self, + config: &screen::CaptureConfig, + ) -> anyhow::Result<()> { + for source in &mut self.sources { + if source.is_screen_source() { + source.reconfigure_screen_processing(config)?; + } + } + self.publish_source_status_registry(); + Ok(()) + } + + /// Mirror the active macOS daemon topology into every native source. + /// + /// # Errors + /// + /// Returns an error if a source can no longer publish status. + pub fn set_macos_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + ) -> anyhow::Result<()> { + for source in &mut self.sources { + source.set_macos_daemon_ownership(owner, conflict.clone())?; + } + self.publish_source_status_registry(); + Ok(()) + } + /// Resolve the explicit Input Monitoring request without retaining the /// input-manager lock while native authorization UI runs. #[must_use] diff --git a/crates/hypercolor-core/src/input/screen/fanout.rs b/crates/hypercolor-core/src/input/screen/fanout.rs index 3132d3556..1dff67f34 100644 --- a/crates/hypercolor-core/src/input/screen/fanout.rs +++ b/crates/hypercolor-core/src/input/screen/fanout.rs @@ -10,12 +10,13 @@ use super::reducer::branch_requires_materialization; use super::{ CaptureCadence, CaptureCadenceError, CaptureFrame, CapturePacer, CaptureTransferFunction, CpuReductionError, CpuReductionExecutor, CpuSurfaceMaterializationError, - CpuZoneMaterializationError, PixelExtent, PreparedCpuMaterializationWorkspace, - PreparedCpuReductionBatch, PreparedCpuSurfaceMaterializer, PreparedCpuZoneMaterializer, - PreparedScreenPublication, RawCaptureSurface, ResolvedScreenPublicationDescriptor, - ScreenBranchPublisher, ScreenCapturePlan, ScreenCommittedState, ScreenContentBarsPolicy, - ScreenGridPolicy, ScreenLetterboxFill, ScreenPayloadKind, ScreenPhysicalReductionDescriptor, - ScreenPlanGeneration, ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, + CpuZoneMaterializationError, LedToneMapCurveTransition, PixelExtent, + PreparedCpuMaterializationWorkspace, PreparedCpuReductionBatch, PreparedCpuSurfaceMaterializer, + PreparedCpuZoneMaterializer, PreparedLedToneMap, PreparedScreenPublication, RawCaptureSurface, + ResolvedScreenPublicationDescriptor, ScreenBranchPublisher, ScreenCapturePlan, + ScreenCommittedState, ScreenContentBarsPolicy, ScreenGridPolicy, ScreenLetterboxFill, + ScreenPayloadKind, ScreenPhysicalReductionDescriptor, ScreenPlanGeneration, + ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationKind, ScreenPublicationMetadata, ScreenSmoothingPolicy, ScreenWorkerBinding, ScreenWorkerBindingState, }; @@ -150,6 +151,8 @@ pub struct PreparedCpuPublicationFanoutCandidate { reservations: Vec, publications: Vec, direct_batch_indices: Vec>, + tone_map_overrides: Vec>, + suppress_scene_cut_bypass: Vec, allocation_byte_len: u64, } @@ -282,6 +285,9 @@ impl PreparedCpuPublicationFanoutCandidate { batch_index, workspace_index, branches: branches.into_boxed_slice(), + tone_map_transition: batch + .prepared_tone_map(batch_index) + .map(LedToneMapCurveTransition::new), }); } if workspace_cursor != workspace.len() { @@ -304,6 +310,16 @@ impl PreparedCpuPublicationFanoutCandidate { direct_batch_indices .try_reserve_exact(branch_count) .map_err(|_| CpuPublicationFanoutError::AllocationFailed)?; + let mut tone_map_overrides = Vec::new(); + tone_map_overrides + .try_reserve_exact(batch.len()) + .map_err(|_| CpuPublicationFanoutError::AllocationFailed)?; + tone_map_overrides.resize(batch.len(), None); + let mut suppress_scene_cut_bypass = Vec::new(); + suppress_scene_cut_bypass + .try_reserve_exact(batch.len()) + .map_err(|_| CpuPublicationFanoutError::AllocationFailed)?; + suppress_scene_cut_bypass.resize(batch.len(), false); Ok(Self { batch: batch.clone(), physical: physical.into_boxed_slice(), @@ -313,6 +329,8 @@ impl PreparedCpuPublicationFanoutCandidate { reservations, publications, direct_batch_indices, + tone_map_overrides, + suppress_scene_cut_bypass, allocation_byte_len, }) } @@ -384,6 +402,9 @@ impl PreparedCpuPublicationFanoutCandidate { reservations: self.reservations, publications: self.publications, direct_batch_indices: self.direct_batch_indices, + tone_map_overrides: self.tone_map_overrides, + suppress_scene_cut_bypass: self.suppress_scene_cut_bypass, + tone_map_epoch: Instant::now(), allocation_byte_len: self.allocation_byte_len, }) } @@ -395,6 +416,7 @@ pub struct PreparedCpuPhysicalFanout { batch_index: usize, workspace_index: Option, branches: Box<[PreparedCpuLogicalFanout]>, + tone_map_transition: Option, } impl PreparedCpuPhysicalFanout { @@ -439,6 +461,9 @@ pub struct PreparedCpuPublicationFanout { reservations: Vec, publications: Vec, direct_batch_indices: Vec>, + tone_map_overrides: Vec>, + suppress_scene_cut_bypass: Vec, + tone_map_epoch: Instant, allocation_byte_len: u64, } @@ -533,6 +558,59 @@ impl PreparedCpuPublicationFanout { .and_then(|route| self.batch.descriptor(route.batch_index)) } + pub(crate) fn inherit_tone_map_transition_from( + &mut self, + previous: &mut Self, + captured_at: Instant, + ) { + for current_index in 0..self.physical.len() { + let Some(target) = self + .batch + .prepared_tone_map(self.physical[current_index].batch_index) + else { + continue; + }; + let current_descriptor = self + .physical_descriptor(current_index) + .expect("prepared physical route retains its batch descriptor"); + let Some(previous_index) = (0..previous.physical.len()).find(|&index| { + let previous_descriptor = previous + .physical_descriptor(index) + .expect("prepared physical route retains its batch descriptor"); + previous.physical[index].tone_map_transition.is_some() + && same_tone_map_route(current_descriptor, previous_descriptor) + && tone_map_dynamic_range_changed(current_descriptor, previous_descriptor) + }) else { + continue; + }; + let Some(previous_transition) = previous.physical[previous_index] + .tone_map_transition + .as_mut() + else { + continue; + }; + let previous_timestamp = captured_at.saturating_duration_since(previous.tone_map_epoch); + let previous_sample = previous_transition.sample(previous_timestamp); + let mut transition = LedToneMapCurveTransition::new(previous_sample.prepared()); + transition.transition_to(target, std::time::Duration::ZERO); + self.physical[current_index].tone_map_transition = Some(transition); + } + self.tone_map_epoch = captured_at; + } + + #[cfg(test)] + pub(crate) fn active_tone_map_transition_count(&self) -> usize { + self.physical + .iter() + .filter(|physical| { + physical + .tone_map_transition + .as_ref() + .is_some_and(LedToneMapCurveTransition::is_active) + }) + .count() + } + /// Number of exact logical branches cached across all physical routes. #[must_use] pub fn branch_count(&self) -> usize { @@ -649,14 +727,9 @@ impl PreparedCpuPublicationFanout { } .into()); } - let executor = self - .executor - .as_ref() - .ok_or(CpuPublicationFanoutError::ExecutionNotAttached)?; - let workspace = self - .workspace - .as_mut() - .ok_or(CpuPublicationFanoutError::ExecutionNotAttached)?; + if self.executor.is_none() || self.workspace.is_none() { + return Err(CpuPublicationFanoutError::ExecutionNotAttached); + } let mut report = CpuPublicationFanoutReport::default(); let plan_generation = self.batch.plan_generation(); self.reservations.clear(); @@ -727,17 +800,32 @@ impl PreparedCpuPublicationFanout { continue; }; if self.workspace_schedule.last() != Some(&workspace_index) - && workspace.completed_source_sequence(workspace_index) != Some(sequence) + && self + .workspace + .as_ref() + .expect("attached fanout retains its workspace") + .completed_source_sequence(workspace_index) + != Some(sequence) { self.workspace_schedule.push(workspace_index); } } + self.sample_tone_map_transitions(frame.metadata().captured_at); + let executor = self + .executor + .as_ref() + .expect("attached fanout retains its executor"); + let workspace = self + .workspace + .as_mut() + .expect("attached fanout retains its workspace"); if let Err(error) = executor.execute_aligned_publications( &self.batch, frame, workspace, &self.workspace_schedule, &self.direct_batch_indices, + &self.tone_map_overrides, &mut self.publications, ) { clear_pending_publications( @@ -782,6 +870,7 @@ impl PreparedCpuPublicationFanout { pixels, frame, plan_generation, + self.suppress_scene_cut_bypass[physical_index], &mut publications[reservation_index], ); if let Err(error) = result { @@ -935,6 +1024,7 @@ impl PreparedCpuPublicationFanout { } } + self.sample_tone_map_transitions(captured_at); for reservation_index in 0..self.reservations.len() { let branch_index = self.reservations[reservation_index].branch_index; let branch = &mut self.physical[physical_index].branches[branch_index]; @@ -944,6 +1034,7 @@ impl PreparedCpuPublicationFanout { pixels, captured_at, plan_generation, + self.suppress_scene_cut_bypass[physical_index], &mut self.publications[reservation_index], ) { discard_all_stages(&mut self.physical, &self.reservations, plan_generation); @@ -1002,6 +1093,79 @@ impl PreparedCpuPublicationFanout { .flat_map(|(_, physical)| physical.branches.iter()) .any(|branch| branch.pending_due) } + + fn sample_tone_map_transitions(&mut self, captured_at: Instant) { + self.tone_map_overrides.fill(None); + self.suppress_scene_cut_bypass.fill(false); + let frame_timestamp = captured_at.saturating_duration_since(self.tone_map_epoch); + let mut previous_physical_index = None; + for pending in &self.reservations { + if previous_physical_index == Some(pending.physical_index) { + continue; + } + previous_physical_index = Some(pending.physical_index); + let physical = &mut self.physical[pending.physical_index]; + let Some(transition) = physical.tone_map_transition.as_mut() else { + continue; + }; + let sample = transition.sample(frame_timestamp); + self.tone_map_overrides[physical.batch_index] = Some(sample.prepared()); + self.suppress_scene_cut_bypass[pending.physical_index] = + sample.suppress_scene_cut_bypass(); + } + } +} + +fn same_tone_map_route( + current: &ScreenPhysicalReductionDescriptor, + previous: &ScreenPhysicalReductionDescriptor, +) -> bool { + let current_source = current.source(); + let previous_source = previous.source(); + let current_output = current.color_pipeline().output(); + let previous_output = previous.color_pipeline().output(); + current.source_epoch() == previous.source_epoch() + && current_source.geometry() == previous_source.geometry() + && current_source.logical_extent() == previous_source.logical_extent() + && current_source.reflection() == previous_source.reflection() + && current_source.pixel_format() == previous_source.pixel_format() + && current_source.cursor_capabilities() == previous_source.cursor_capabilities() + && current_source.resources() == previous_source.resources() + && current.executor() == previous.executor() + && current.source_region() == previous.source_region() + && current.reduction_extent() == previous.reduction_extent() + && current.cursor() == previous.cursor() + && current.reduction_filter() == previous.reduction_filter() + && current.algorithm_revision() == previous.algorithm_revision() + && current.target_pixel_format() == previous.target_pixel_format() + && current_output.color_space() == previous_output.color_space() + && current_output.transfer_function() == previous_output.transfer_function() + && current_output.dynamic_range() == previous_output.dynamic_range() +} + +fn tone_map_dynamic_range_changed( + current: &ScreenPhysicalReductionDescriptor, + previous: &ScreenPhysicalReductionDescriptor, +) -> bool { + let Some(current_source) = current.color_pipeline().effective_source() else { + return false; + }; + let Some(previous_source) = previous.color_pipeline().effective_source() else { + return false; + }; + matches!( + ( + current_source.dynamic_range(), + previous_source.dynamic_range() + ), + ( + super::CaptureDynamicRange::Standard, + super::CaptureDynamicRange::High + ) | ( + super::CaptureDynamicRange::High, + super::CaptureDynamicRange::Standard + ) + ) } fn discard_all_stages( @@ -1034,6 +1198,7 @@ fn stage_workspace_publication( physical_pixels: &[u8], frame: &CaptureFrame, plan_generation: ScreenPlanGeneration, + suppress_scene_cut_bypass: bool, publication: &mut PreparedScreenPublication, ) -> Result<(), CpuPublicationFanoutError> { match branch.kind { @@ -1059,6 +1224,7 @@ fn stage_workspace_publication( physical_descriptor, physical_pixels, frame.metadata().captured_at, + suppress_scene_cut_bypass, publication, )?; } @@ -1072,6 +1238,7 @@ fn stage_workspace_publication( physical_descriptor, physical_pixels, frame.metadata().captured_at, + suppress_scene_cut_bypass, publication, )?; let columns = std::num::NonZeroU32::new(staged.columns()) @@ -1090,6 +1257,7 @@ fn stage_prereduced_publication( physical_pixels: &[u8], captured_at: Instant, plan_generation: ScreenPlanGeneration, + suppress_scene_cut_bypass: bool, publication: &mut PreparedScreenPublication, ) -> Result<(), CpuPublicationFanoutError> { match branch.kind { @@ -1115,6 +1283,7 @@ fn stage_prereduced_publication( physical_descriptor, physical_pixels, captured_at, + suppress_scene_cut_bypass, publication, )?; } @@ -1128,6 +1297,7 @@ fn stage_prereduced_publication( physical_descriptor, physical_pixels, captured_at, + suppress_scene_cut_bypass, publication, )?; let columns = std::num::NonZeroU32::new(staged.columns()) @@ -1336,6 +1506,16 @@ fn candidate_allocation_quote( .ok() .and_then(|scratch| bytes.checked_add(scratch)) }) + .and_then(|bytes| { + checked_bytes::>(batch.len()) + .ok() + .and_then(|scratch| bytes.checked_add(scratch)) + }) + .and_then(|bytes| { + checked_bytes::(batch.len()) + .ok() + .and_then(|scratch| bytes.checked_add(scratch)) + }) .ok_or(CpuPublicationFanoutError::AllocationAccountingOverflow) } diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 47ef14a81..758c144e0 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureColorimetry, MacosCaptureContentStyle, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCaptureContentStyle, MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, MacosColorPrimaries, MacosDisplayClock, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosProtectedSourceState as NativeProtectedSourceState, MacosTransferFunction, @@ -22,9 +22,10 @@ use super::{ AdmittedScreenNativeTargetPreparation, BoundScreenNativeTargetPreparation, CaptureCadence, CaptureColorSpace, CaptureColorimetry, CaptureConfig, CaptureCursor, CaptureCursorContent, CaptureDamage, CaptureDynamicRange, CaptureEpoch, CaptureFrame, CaptureFrameMetadata, - CapturePixelFormat, CapturePlanePool, CaptureRotation, CaptureSourceId, CaptureStorage, - CaptureTransferFunction, CpuCaptureStorage, CpuExactReductionWorkPlan, CpuReductionExecutor, - PixelExtent, PixelRect, PlatformGpuApi, PlatformGpuSurface, PreparedCpuPublicationFanout, + CaptureLuminanceContext, CapturePixelFormat, CapturePlanePool, CapturePositiveScalar, + CaptureRotation, CaptureSourceId, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, + CpuExactReductionWorkPlan, CpuReductionExecutor, LedToneMapCalibration, PixelExtent, PixelRect, + PlatformGpuApi, PlatformGpuSurface, PreparedCpuPublicationFanout, PreparedCpuPublicationFanoutCandidate, RawCaptureSurface, RegisteredScreenBranchDemand, ResolvedScreenBranchDemand, ResolvedScreenColorTransform, ResolvedScreenPublicationDescriptor, ResolvedScreenSource, ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, @@ -35,9 +36,9 @@ use super::{ ScreenGpuSurfacePayload, ScreenNativePreparationPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPreparedWorkerToken, ScreenPublicationColorimetry, ScreenPublicationExecutor, ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationMetadata, ScreenRequiredResourceMinimum, - ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, - ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, + ScreenPublicationHubError, ScreenPublicationMetadata, ScreenPublicationRequest, + ScreenRequiredResourceMinimum, ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, + ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; @@ -380,7 +381,7 @@ impl MacosPublicationSource { SourceScale::ONE, )?, logical_extent: content_rect.extent(), - colorimetry: capture_colorimetry(frame.color)?, + colorimetry: capture_colorimetry(frame)?, pixel_format: frame.pixel_format, resource_generation, allocation_bytes: frame.surface.allocation_bytes, @@ -403,16 +404,21 @@ impl MacosPublicationSource { } } - fn cpu_source(&self, selector: ScreenSourceSelector) -> ResolvedScreenSource { - ResolvedScreenSource::new( + fn cpu_source(&self, selector: ScreenSourceSelector) -> anyhow::Result { + if self.pixel_format != MacosCapturePixelFormat::Bgra8 { + return Err(anyhow!( + "macOS CPU publication requires a byte-addressable BGRA source" + )); + } + Ok(ResolvedScreenSource::new( selector, self.epoch.clone(), ResolvedScreenSourceConfig::new_with_cursor_capabilities( self.geometry, self.logical_extent, ScreenSourceReflection::None, - CapturePixelFormat::Rgba8, - CaptureColorimetry::SRGB, + CapturePixelFormat::Bgra8, + self.colorimetry, self.cursor_capabilities(), ScreenBackendResourceIdentity::new( ScreenCaptureBackend::MacosScreenCaptureKit, @@ -421,7 +427,7 @@ impl MacosPublicationSource { self.resource_generation, ), ), - ) + )) } fn gpu_source( @@ -484,17 +490,21 @@ struct MacosExactPublicationShared { } impl MacosExactPublicationShared { + fn advance_resolution_revision(&self) { + self.resolution_revision + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |revision| { + revision.checked_add(1) + }) + .expect("macOS screen publication resolution revision exhausted"); + } + fn replace_source(&self, next: Option) { let mut source = lock(&self.source); if *source == next { return; } *source = next; - self.resolution_revision - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |revision| { - revision.checked_add(1) - }) - .expect("macOS screen publication resolution revision exhausted"); + self.advance_resolution_revision(); } fn source(&self) -> Option { @@ -636,6 +646,7 @@ pub struct MacosScreenCaptureInput { status: SourceStatusReporter, status_session: SourceSessionSlot, owner: MacosCapabilityOwner, + owner_conflict: Option>, } impl MacosScreenCaptureInput { @@ -699,6 +710,7 @@ impl MacosScreenCaptureInput { ), status_session: SourceSessionSlot::new(), owner: MacosCapabilityOwner::Standalone, + owner_conflict: None, }; source .refresh_platform_status() @@ -738,7 +750,7 @@ impl MacosScreenCaptureInput { owner: self.owner, selection: map_selection(self.control.selection()), tahoe_selection: None, - owner_conflict: None, + owner_conflict: self.owner_conflict.clone(), }, )))?; Ok(()) @@ -879,6 +891,16 @@ impl InputSource for MacosScreenCaptureInput { "macos_screen_capture" } + fn set_macos_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + ) -> anyhow::Result<()> { + self.owner = owner; + self.owner_conflict = conflict.map(Arc::new); + self.refresh_platform_status() + } + fn start(&mut self) -> anyhow::Result<()> { if self.running { return Ok(()); @@ -1053,7 +1075,31 @@ impl InputSource for MacosScreenCaptureInput { let Some(source) = self.exact.source() else { return Ok(None); }; - resolve_macos_publication_branch(&source, demand) + let calibration = LedToneMapCalibration::try_new( + self.config.target_led_white_x, + self.config.target_led_white_y, + self.config.target_led_reference_white_nits, + self.config.target_led_peak_nits, + self.config.exposure_ev, + )?; + let request = demand.request(); + let processing_profile = request + .processing_profile() + .as_ref() + .clone() + .with_led_tone_map(calibration); + let calibrated = RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + request.selector().clone(), + request.kind(), + request.executor().clone(), + request.extent(), + request.aspect(), + Arc::new(processing_profile), + ), + demand.requested_hz(), + ); + resolve_macos_publication_branch(&source, &calibrated) } fn owns_screen_publication_source(&self, source_id: &CaptureSourceId) -> bool { @@ -1147,6 +1193,33 @@ impl InputSource for MacosScreenCaptureInput { Ok(()) } + fn reconfigure_screen_processing(&mut self, config: &CaptureConfig) -> anyhow::Result<()> { + let next = LedToneMapCalibration::try_new( + config.target_led_white_x, + config.target_led_white_y, + config.target_led_reference_white_nits, + config.target_led_peak_nits, + config.exposure_ev, + )?; + let current = LedToneMapCalibration::try_new( + self.config.target_led_white_x, + self.config.target_led_white_y, + self.config.target_led_reference_white_nits, + self.config.target_led_peak_nits, + self.config.exposure_ev, + )?; + if current == next { + return Ok(()); + } + self.config.target_led_white_x = config.target_led_white_x; + self.config.target_led_white_y = config.target_led_white_y; + self.config.target_led_reference_white_nits = config.target_led_reference_white_nits; + self.config.target_led_peak_nits = config.target_led_peak_nits; + self.config.exposure_ev = config.exposure_ev; + self.exact.advance_resolution_revision(); + Ok(()) + } + fn reselect_screen_source(&mut self) -> anyhow::Result<()> { self.present_picker() } @@ -1174,13 +1247,13 @@ fn resolve_macos_publication_branch( return Ok(None); } let selector = selector.clone(); - let capabilities = ScreenColorTransformCapabilities::new(true, false, false, NonZeroU32::MIN); + let capabilities = CpuReductionExecutor::supported_color_capabilities(); if matches!( demand.request().executor(), ScreenPublicationExecutorRequest::Cpu ) { return Ok(Some(demand.resolve_with_color_capabilities( - &source.cpu_source(selector), + &source.cpu_source(selector)?, capabilities, )?)); } @@ -1193,7 +1266,10 @@ fn resolve_macos_publication_branch( source.gpu_source(selector.clone(), target.physical_gpu_device().clone()) && let Ok(resolved) = demand.resolve_with_executor_capabilities( &native_source, - ScreenExecutorColorCapabilities::new(capabilities, capabilities), + ScreenExecutorColorCapabilities::new( + capabilities, + ScreenColorTransformCapabilities::NONE, + ), ) && matches!( resolved.descriptor().executor(), @@ -1206,7 +1282,7 @@ fn resolve_macos_publication_branch( } Ok(Some(demand.resolve_with_color_capabilities( - &source.cpu_source(selector), + &source.cpu_source(selector)?, capabilities, )?)) } @@ -1297,7 +1373,6 @@ fn prepare_macos_exact_runtime( let source = source .filter(|source| &source.epoch.source_id == ticket.source_id()) .ok_or_else(|| anyhow!("macOS exact publication source changed before preparation"))?; - let cpu_source = source.cpu_source(ScreenSourceSelector::Exact(source.epoch.source_id.clone())); let executor = exact.cpu_executor()?; let compute_plan = CpuExactReductionWorkPlan::try_for_source(&candidate, ticket.source_id(), |_| true)?; @@ -1350,6 +1425,8 @@ fn prepare_macos_exact_runtime( { (None, 0, 0) } else { + let cpu_source = + source.cpu_source(ScreenSourceSelector::Exact(source.epoch.source_id.clone()))?; let batch_quote = executor.batch_allocation_quote(&cpu_source, &candidate)?; preflight_macos_scope_bytes(&mut ledger, &mut processing_minimum_remaining, batch_quote)?; let batch = executor.prepare_batch(&cpu_source, &candidate)?; @@ -1539,19 +1616,55 @@ fn bind_current_macos_exact_runtime<'a>( runtimes: &'a mut [MacosExactRuntime], source: &MacosPublicationSource, hub: &ScreenPublicationHub, + captured_at: Instant, ) -> anyhow::Result> { let authority = hub.committed_state(); let Some(current_binding) = authority.runtime_binding(&source.epoch.source_id) else { return Ok(None); }; - let runtime = runtimes + let Some(current_index) = runtimes .iter_mut() - .find(|runtime| runtime.source == *source && runtime.binding.is_same(current_binding)); - let Some(runtime) = runtime else { + .position(|runtime| runtime.source == *source && runtime.binding.is_same(current_binding)) + else { return Ok(None); }; - runtime.bind_if_current(hub)?; - Ok(runtime.is_bound().then_some(runtime)) + let should_inherit = runtimes[current_index].fanout.is_none() + && runtimes[current_index].fanout_candidate.is_some(); + runtimes[current_index].bind_if_current(hub)?; + if should_inherit + && let Some(previous_index) = + runtimes + .iter() + .enumerate() + .rev() + .find_map(|(index, runtime)| { + (index != current_index + && runtime.binding.source_id() == current_binding.source_id() + && runtime.fanout.is_some()) + .then_some(index) + }) + { + let (current, previous) = if current_index < previous_index { + let (before_previous, previous_and_after) = runtimes.split_at_mut(previous_index); + ( + &mut before_previous[current_index], + &mut previous_and_after[0], + ) + } else { + let (before_current, current_and_after) = runtimes.split_at_mut(current_index); + ( + &mut current_and_after[0], + &mut before_current[previous_index], + ) + }; + if let (Some(current), Some(previous)) = (current.fanout.as_mut(), previous.fanout.as_mut()) + { + current.inherit_tone_map_transition_from(previous, captured_at); + } + } + Ok(runtimes[current_index] + .is_bound() + .then_some(&mut runtimes[current_index])) } fn handle_exact_commands( @@ -1715,7 +1828,7 @@ fn publish_frame( .ok_or_else(|| anyhow!("macOS capture plane length overflow"))?; let mut plane = prepared.plane_pool.try_acquire(byte_len)?; plane.resize(byte_len, 0); - frame.convert_bgra8_sdr_to_rgba8(&mut plane, row_stride)?; + frame.copy_bgra8_to(&mut plane, row_stride)?; let cursor = CaptureCursor { visible: frame.cursor_composed, position: None, @@ -1756,12 +1869,12 @@ fn publish_frame( captured_at, fresh_until, geometry: source.geometry, - colorimetry: CaptureColorimetry::SRGB, + colorimetry: source.colorimetry, cursor, }, CaptureStorage::Cpu(CpuCaptureStorage::from_owner( plane.freeze(), - CapturePixelFormat::Rgba8, + CapturePixelFormat::Bgra8, i64::try_from(row_stride)?, 0, )), @@ -1806,7 +1919,8 @@ fn publish_macos_native_exact( let Some(hub) = exact.hub() else { return Ok(MacosExactDelivery::default()); }; - let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub)? else { + let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub, captured_at)? + else { return Ok(MacosExactDelivery::default()); }; let delivery = MacosExactDelivery { @@ -1882,7 +1996,9 @@ fn publish_macos_cpu_exact( let Some(hub) = exact.hub() else { return Ok(()); }; - let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub)? else { + let Some(runtime) = + bind_current_macos_exact_runtime(runtimes, source, &hub, frame.metadata().captured_at)? + else { return Ok(()); }; if let Some(fanout) = runtime.fanout.as_mut() { @@ -2011,32 +2127,69 @@ fn capture_source_id(selection: MacosCaptureSelection) -> anyhow::Result anyhow::Result { +fn capture_colorimetry(frame: &MacosCaptureFrame) -> anyhow::Result { + let color = frame.color; let color_space = match color.primaries { MacosColorPrimaries::Srgb => CaptureColorSpace::Srgb, MacosColorPrimaries::DisplayP3 => CaptureColorSpace::DisplayP3, MacosColorPrimaries::Rec2020 => CaptureColorSpace::Rec2020, }; - let (transfer_function, dynamic_range) = match color.transfer { - MacosTransferFunction::Srgb => { - (CaptureTransferFunction::Srgb, CaptureDynamicRange::Standard) + let transfer_function = match color.transfer { + MacosTransferFunction::Srgb => CaptureTransferFunction::Srgb, + MacosTransferFunction::Linear => CaptureTransferFunction::Linear, + MacosTransferFunction::Pq => CaptureTransferFunction::Pq, + MacosTransferFunction::Hlg => CaptureTransferFunction::Hlg, + MacosTransferFunction::Rec709 | MacosTransferFunction::Rec2020 => { + CaptureTransferFunction::Unknown } - MacosTransferFunction::Linear => ( - CaptureTransferFunction::Linear, - CaptureDynamicRange::Standard, - ), - MacosTransferFunction::Pq => (CaptureTransferFunction::Pq, CaptureDynamicRange::High), - MacosTransferFunction::Hlg => (CaptureTransferFunction::Hlg, CaptureDynamicRange::High), - MacosTransferFunction::Rec709 | MacosTransferFunction::Rec2020 => ( - CaptureTransferFunction::Unknown, - CaptureDynamicRange::Standard, - ), + }; + let delivered = frame.delivered_metadata(); + let dynamic_range = if matches!( + color.transfer, + MacosTransferFunction::Pq | MacosTransferFunction::Hlg + ) || delivered + .is_some_and(|metadata| metadata.dynamic_range == MacosCaptureDynamicRange::Hdr) + || matches!( + frame.pixel_format, + MacosCapturePixelFormat::Argb2101010 | MacosCapturePixelFormat::Rgba16Float + ) { + CaptureDynamicRange::High + } else { + CaptureDynamicRange::Standard + }; + let luminance = if dynamic_range == CaptureDynamicRange::High { + let delivered = delivered + .ok_or_else(|| anyhow!("macOS HDR capture is missing delivered luminance metadata"))?; + if delivered.pixel_format != frame.pixel_format + || delivered.color != frame.color + || delivered.dynamic_range != MacosCaptureDynamicRange::Hdr + { + return Err(anyhow!( + "macOS HDR delivered metadata contradicts the capture frame" + )); + } + let reference_white = delivered + .source_reference_white_nits + .ok_or_else(|| anyhow!("macOS HDR capture is missing source reference white"))?; + let headroom = delivered + .content_headroom + .ok_or_else(|| anyhow!("macOS HDR capture is missing content headroom"))?; + if headroom <= 1.0 { + return Err(anyhow!( + "macOS HDR content headroom must be strictly greater than one" + )); + } + let reference_white = CapturePositiveScalar::try_new(reference_white)?; + let peak = CapturePositiveScalar::try_new(reference_white.value() * headroom)?; + Some(CaptureLuminanceContext::new(reference_white, peak)?) + } else { + None }; Ok(CaptureColorimetry::new( color_space, transfer_function, Some(dynamic_range), - None, + luminance, )?) } @@ -2107,6 +2260,7 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { struct FixtureControl { mailbox: MacosFrameMailbox, active: AtomicBool, + active_transitions: AtomicU64, status: Mutex, selection: Mutex, captured_at: Mutex>, @@ -2118,6 +2272,7 @@ impl Default for FixtureControl { Self { mailbox: MacosFrameMailbox::default(), active: AtomicBool::new(false), + active_transitions: AtomicU64::new(0), status: Mutex::new(NativeProtectedSourceState::ReadyIdle), selection: Mutex::new(MacosCaptureSelection::None), captured_at: Mutex::new(None), @@ -2132,6 +2287,7 @@ impl MacosCaptureControl for FixtureControl { } fn set_active(&self, active: bool) { + self.active_transitions.fetch_add(1, Ordering::AcqRel); self.active.store(active, Ordering::Release); *lock(&self.status) = if active { NativeProtectedSourceState::Starting @@ -2223,19 +2379,23 @@ impl MacosScreenCaptureFixture { mod tests { use super::*; use crate::input::screen::{ - InputPublicationDemandRevision, ScreenAdmissionCapacity, ScreenAspectPolicy, - ScreenExtentRequest, ScreenInputGraphGeneration, ScreenNativeExecutionTarget, - ScreenNativeExecutionTargetId, ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, - ScreenPlanBuilder, ScreenProcessingProfile, ScreenProcessingProfileConfig, - ScreenPublicationKind, ScreenPublicationRequest, + CpuReductionLayout, CpuReductionRequest, InputPublicationDemandRevision, + ScreenAdmissionCapacity, ScreenAspectPolicy, ScreenExtentRequest, ScreenHdrPolicy, + ScreenInputGraphGeneration, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, + ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPlanBuilder, + ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenProfileScalar, + ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, + ScreenSceneCutPolicy, ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, }; use hypercolor_macos_capture::{ - MacosAttachment, MacosCaptureSurface, MacosColorRange, MacosFrameDecoder, MacosPixelExtent, - MacosPointRect, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, + MacosAttachment, MacosCaptureColorimetry, MacosCaptureSurface, MacosColorRange, + MacosDeliveredFrameMetadata, MacosFrameDecoder, MacosPixelExtent, MacosPointRect, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, }; const BGRA8: u32 = 0x4247_5241; + const RGBA16_FLOAT: u32 = 0x5247_6841; #[cfg(target_os = "macos")] #[test] @@ -2365,31 +2525,51 @@ mod tests { } fn frame() -> Arc { + frame_with_color( + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + BGRA8, + &[0, 0, 255, 255], + None, + ) + } + + fn frame_with_color( + color: MacosCaptureColorimetry, + pixel_format_fourcc: u32, + encoded_pixel: &[u8], + delivered: Option, + ) -> Arc { let extent = MacosPixelExtent::new(4, 2).expect("fixture extent is valid"); - let surface = MacosCaptureSurface::new_cpu_fixture( + let byte_len = u64::try_from(encoded_pixel.len() * 8).expect("fixture length fits"); + let mut surface = MacosCaptureSurface::new_cpu_fixture( 7, - 32, + byte_len, 1, - vec![Arc::<[u8]>::from([0_u8, 0, 255, 255].repeat(8))], + vec![Arc::<[u8]>::from(encoded_pixel.repeat(8))], ) .expect("fixture surface is valid"); + if let Some(delivered) = delivered { + surface = surface + .with_delivery_metadata(delivered) + .expect("fixture delivery metadata is valid"); + } let sample = MacosRawCaptureSample { frame: Some(MacosRawCompleteFrame { storage_extent: extent, planes: vec![MacosRawCapturePlane { index: 0, extent, - bytes_per_row: 16, - length_bytes: 32, + bytes_per_row: encoded_pixel.len() * 4, + length_bytes: byte_len, }], - pixel_format_fourcc: BGRA8, - color: MacosCaptureColorimetry { - primaries: MacosColorPrimaries::Srgb, - transfer: MacosTransferFunction::Srgb, - matrix: None, - range: MacosColorRange::Full, - chroma_location: None, - }, + pixel_format_fourcc, + color, cursor_composed: false, surface, }), @@ -2424,6 +2604,1001 @@ mod tests { .expect("fixture source resolves") } + fn cpu_demand(profile: ScreenProcessingProfile) -> RegisteredScreenBranchDemand { + cpu_demand_for_kind(profile, ScreenPublicationKind::Surface) + } + + fn cpu_demand_for_kind( + profile: ScreenProcessingProfile, + kind: ScreenPublicationKind, + ) -> RegisteredScreenBranchDemand { + RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + kind, + ScreenPublicationExecutorRequest::Cpu, + ScreenExtentRequest::Native, + ScreenAspectPolicy::Cover, + Arc::new(profile), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + ) + } + + fn execute_resolved_cpu( + source: &MacosPublicationSource, + descriptor: &ResolvedScreenPublicationDescriptor, + encoded_bgra: [u8; 4], + ) -> Vec { + let source_extent = source.logical_extent; + let source_bytes = Arc::<[u8]>::from( + encoded_bgra.repeat( + usize::try_from(source_extent.width() * source_extent.height()) + .expect("fixture pixel count fits"), + ), + ); + let storage = CpuCaptureStorage::new( + source_bytes, + CapturePixelFormat::Bgra8, + i64::from(source_extent.width()) * 4, + 0, + ); + let layout = + CpuReductionLayout::new(source_extent, descriptor.physical().reduction_extent()) + .expect("fixture reduction layout is valid"); + let mut output = vec![0; layout.target_byte_len_usize()]; + CpuReductionExecutor::new(NonZeroUsize::MIN, NonZeroU32::MIN) + .expect("fixture executor prepares") + .reduce( + CpuReductionRequest::new( + &storage, + layout, + descriptor.physical().target_pixel_format(), + descriptor.physical().reduction_filter(), + descriptor.physical().color_pipeline(), + ), + &mut output, + ) + .expect("resolved macOS CPU color pipeline executes"); + output + } + + fn commit_cpu_runtime( + builder: &mut ScreenPlanBuilder, + exact: &MacosExactPublicationShared, + source: &MacosPublicationSource, + resolved: ResolvedScreenBranchDemand, + runtimes: &mut Vec, + ) -> ResolvedScreenPublicationDescriptor { + commit_cpu_runtimes(builder, exact, source, [resolved], runtimes) + .pop() + .expect("single-demand fixture commits one descriptor") + } + + fn commit_cpu_runtimes( + builder: &mut ScreenPlanBuilder, + exact: &MacosExactPublicationShared, + source: &MacosPublicationSource, + resolved: impl IntoIterator, + runtimes: &mut Vec, + ) -> Vec { + let resolved = resolved.into_iter().collect::>(); + let descriptors = resolved + .iter() + .map(|demand| demand.descriptor().clone()) + .collect(); + let revision = builder + .current() + .demand_revision() + .next() + .expect("fixture demand revision advances"); + let graph = ScreenInputGraphGeneration::new(1); + let mut preparing = builder + .prepare( + resolved, + None, + revision, + graph, + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("macOS CPU candidate plan prepares"); + let ticket = preparing + .worker_ticket(&source.epoch.source_id) + .expect("macOS source owns the candidate worker"); + let (token, runtime) = prepare_macos_exact_runtime(ticket, Some(source), exact) + .expect("macOS CPU runtime prepares"); + let (runtime, owned_source) = runtime.expect("CPU plan owns a runtime"); + exact.register_owned_source(owned_source); + runtimes.push(runtime); + preparing + .acknowledge(token) + .expect("macOS CPU worker token matches candidate"); + let armed = preparing + .arm(builder.current().generation(), revision, graph) + .unwrap_or_else(|failure| panic!("macOS CPU plan arms: {}", failure.error())); + let committed = builder + .commit(armed, revision, graph) + .unwrap_or_else(|failure| panic!("macOS CPU plan commits: {}", failure.error())); + let (_, retirement) = committed.into_parts(); + drop(retirement); + descriptors + } + + fn cpu_capture_frame( + source: &MacosPublicationSource, + sequence: u64, + captured_at: Instant, + encoded_bgra: [u8; 4], + ) -> CaptureFrame { + let byte_len = usize::try_from( + u64::from(source.geometry.storage_extent().width()) + * u64::from(source.geometry.storage_extent().height()) + * 4, + ) + .expect("fixture CPU bytes fit"); + CaptureFrame::new( + CaptureFrameMetadata { + source_id: source.epoch.source_id.clone(), + topology_generation: source.epoch.topology_generation, + session_generation: source.epoch.session_generation, + sequence, + captured_at, + fresh_until: captured_at + Duration::from_secs(1), + geometry: source.geometry, + colorimetry: source.colorimetry, + cursor: CaptureCursor { + visible: false, + position: None, + hotspot: None, + shape_extent: None, + shape_generation: None, + content: CaptureCursorContent::Hidden, + }, + }, + CaptureStorage::Cpu(CpuCaptureStorage::new( + Arc::from(encoded_bgra.repeat(byte_len / 4)), + CapturePixelFormat::Bgra8, + i64::from(source.geometry.storage_extent().width()) * 4, + 0, + )), + CaptureDamage::new(Vec::new(), Vec::new()), + ) + .expect("fixture CPU frame is valid") + } + + fn publish_cpu_bytes( + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + source: &MacosPublicationSource, + descriptor: &ResolvedScreenPublicationDescriptor, + frame: &CaptureFrame, + ) -> Vec { + publish_cpu_frame(exact, runtimes, source, frame); + published_surface_bytes(exact, descriptor) + } + + fn publish_cpu_frame( + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + source: &MacosPublicationSource, + frame: &CaptureFrame, + ) { + let hub = exact.hub().expect("fixture hub remains installed"); + let runtime = + bind_current_macos_exact_runtime(runtimes, source, &hub, frame.metadata().captured_at) + .expect("current macOS runtime binds") + .expect("committed runtime is current"); + let report = runtime + .fanout + .as_mut() + .expect("CPU runtime owns a fanout") + .publish_due( + &hub, + Some(frame), + frame.metadata().captured_at, + ScreenPublicationHealth::Healthy, + ) + .expect("CPU fanout publishes"); + assert!( + report.published() > 0, + "CPU fixture had no due branch: {report:?}" + ); + } + + fn active_tone_map_transition_count( + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + source: &MacosPublicationSource, + captured_at: Instant, + ) -> usize { + let hub = exact.hub().expect("fixture hub remains installed"); + bind_current_macos_exact_runtime(runtimes, source, &hub, captured_at) + .expect("current macOS runtime binds") + .expect("committed runtime is current") + .fanout + .as_ref() + .expect("CPU runtime owns a fanout") + .active_tone_map_transition_count() + } + + fn published_surface_bytes( + exact: &MacosExactPublicationShared, + descriptor: &ResolvedScreenPublicationDescriptor, + ) -> Vec { + let hub = exact.hub().expect("fixture hub remains installed"); + let lease = hub + .lease(descriptor) + .expect("committed Surface branch has a lease"); + let publication = lease.read().expect("Surface branch has published bytes"); + let ScreenBranchPayload::Surface(surface) = publication.payload() else { + panic!("fixture branch publishes Surface bytes"); + }; + surface.pixels().to_vec() + } + + fn published_zone_colors( + exact: &MacosExactPublicationShared, + descriptor: &ResolvedScreenPublicationDescriptor, + ) -> Vec<[u8; 3]> { + let hub = exact.hub().expect("fixture hub remains installed"); + let lease = hub + .lease(descriptor) + .expect("committed Zones branch has a lease"); + let publication = lease.read().expect("Zones branch has published colors"); + let ScreenBranchPayload::Zones(zones) = publication.payload() else { + panic!("fixture branch publishes zone colors"); + }; + zones.colors().to_vec() + } + + fn transition_profile(hdr: bool) -> ScreenProcessingProfile { + transition_profile_with_smoothing(hdr, ScreenSmoothingPolicy::Disabled) + } + + fn transition_profile_with_smoothing( + hdr: bool, + smoothing: ScreenSmoothingPolicy, + ) -> ScreenProcessingProfile { + let calibration = LedToneMapCalibration::DEFAULT; + transition_profile_with_calibration(hdr, smoothing, calibration) + } + + fn transition_profile_with_calibration( + hdr: bool, + smoothing: ScreenSmoothingPolicy, + calibration: LedToneMapCalibration, + ) -> ScreenProcessingProfile { + ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + smoothing, + hdr: if hdr { + ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )) + } else { + ScreenHdrPolicy::Reject + }, + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration) + } + + fn hdr_transition_source(sdr_source: &MacosPublicationSource) -> MacosPublicationSource { + let hdr_color = CaptureColorimetry::new( + CaptureColorSpace::Srgb, + CaptureTransferFunction::Pq, + Some(CaptureDynamicRange::High), + Some( + CaptureLuminanceContext::new( + CapturePositiveScalar::try_new(203.0).expect("reference white is valid"), + CapturePositiveScalar::try_new(1_000.0).expect("peak is valid"), + ) + .expect("HDR luminance is ordered"), + ), + ) + .expect("HDR fixture colorimetry is valid"); + MacosPublicationSource { + colorimetry: hdr_color, + ..sdr_source.clone() + } + } + + #[test] + fn delivered_hdr_luminance_is_required_and_mapped_exactly() { + assert_eq!( + capture_colorimetry(&frame()).expect("SDR remains valid without delivery luminance"), + CaptureColorimetry::SRGB + ); + let hdr_color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let headroom = 1_000.0 / 203.0; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_color, + Some(203.0), + Some(headroom), + ) + .expect("complete HDR metadata is valid"); + let hdr = frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(delivered)); + let colorimetry = capture_colorimetry(&hdr).expect("complete HDR colorimetry maps"); + let luminance = colorimetry.luminance().expect("HDR luminance is retained"); + assert_eq!(luminance.reference_white_nits().value(), 203.0); + assert_eq!(luminance.peak_nits().value(), 203.0 * headroom); + + let linear_hdr_color = MacosCaptureColorimetry { + transfer: MacosTransferFunction::Linear, + ..hdr_color + }; + let linear_delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + linear_hdr_color, + Some(203.0), + Some(headroom), + ) + .expect("extended-linear HDR metadata is valid"); + let linear_hdr = frame_with_color( + linear_hdr_color, + RGBA16_FLOAT, + &[0; 8], + Some(linear_delivered), + ); + let linear_colorimetry = + capture_colorimetry(&linear_hdr).expect("extended-linear HDR colorimetry maps"); + assert_eq!( + linear_colorimetry.transfer_function(), + CaptureTransferFunction::Linear + ); + assert_eq!( + linear_colorimetry.dynamic_range(), + Some(CaptureDynamicRange::High) + ); + assert_eq!(linear_colorimetry.luminance(), colorimetry.luminance()); + let missing_linear = frame_with_color(linear_hdr_color, RGBA16_FLOAT, &[0; 8], None); + assert!(capture_colorimetry(&missing_linear).is_err()); + + let missing = frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], None); + assert!(capture_colorimetry(&missing).is_err()); + + let no_reference_white = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_color, + None, + Some(headroom), + ) + .expect("capture layer admits optional reference white"); + let no_reference_white = + frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(no_reference_white)); + assert!(capture_colorimetry(&no_reference_white).is_err()); + + let no_headroom = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_color, + Some(203.0), + None, + ) + .expect("capture layer admits optional headroom"); + let no_headroom = frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(no_headroom)); + assert!(capture_colorimetry(&no_headroom).is_err()); + + let no_peak_headroom = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_color, + Some(203.0), + Some(1.0), + ) + .expect("capture layer admits unity headroom"); + let no_peak_headroom = + frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(no_peak_headroom)); + assert!(capture_colorimetry(&no_peak_headroom).is_err()); + + let contradictory_color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + ..hdr_color + }; + let contradictory = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + contradictory_color, + Some(203.0), + Some(headroom), + ) + .expect("alternate HDR metadata is valid in isolation"); + let contradictory = frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(contradictory)); + assert!(capture_colorimetry(&contradictory).is_err()); + } + + #[test] + fn macos_cpu_resolves_p3_and_rejects_non_byte_addressable_hdr() { + let p3_color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let p3_frame = frame_with_color(p3_color, BGRA8, &[255, 0, 255, 255], None); + let p3_source = source(&p3_frame); + let p3_profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + ..ScreenProcessingProfileConfig::default() + }); + let p3 = resolve_macos_publication_branch(&p3_source, &cpu_demand(p3_profile)) + .expect("P3 macOS demand resolves") + .expect("configured source owns P3 demand"); + assert!(matches!( + p3.descriptor().physical().color_pipeline().transform(), + ResolvedScreenColorTransform::LinearRelativeColorimetric { .. } + )); + assert_eq!( + &execute_resolved_cpu(&p3_source, p3.descriptor(), [255, 0, 255, 255])[..4], + [255, 59, 242, 255] + ); + + let hdr_color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + hdr_color, + Some(203.0), + Some(1_000.0 / 203.0), + ) + .expect("HDR delivery metadata is valid"); + let hdr_frame = frame_with_color(hdr_color, RGBA16_FLOAT, &[0; 8], Some(delivered)); + let hdr_source = source(&hdr_frame); + let calibration = LedToneMapCalibration::DEFAULT; + let hdr_profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )), + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration); + assert!(resolve_macos_publication_branch(&hdr_source, &cpu_demand(hdr_profile)).is_err()); + } + + #[test] + fn macos_publication_transition_is_deterministic_at_zero_midpoint_and_completion() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let base_frame = frame(); + let sdr_source = source(&base_frame); + exact.replace_source(Some(sdr_source.clone())); + let sdr = + resolve_macos_publication_branch(&sdr_source, &cpu_demand(transition_profile(false))) + .expect("SDR transition branch resolves") + .expect("configured source owns SDR transition branch"); + let sdr_descriptor = + commit_cpu_runtime(&mut builder, &exact, &sdr_source, sdr, &mut runtimes); + let started = Instant::now() + Duration::from_millis(20); + let sdr_frame = cpu_capture_frame(&sdr_source, 1, started, [148, 148, 148, 255]); + let sdr_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &sdr_source, + &sdr_descriptor, + &sdr_frame, + ); + assert_eq!(&sdr_bytes[..4], [148, 148, 148, 255]); + + let hdr_source = hdr_transition_source(&sdr_source); + exact.replace_source(Some(hdr_source.clone())); + let hdr = + resolve_macos_publication_branch(&hdr_source, &cpu_demand(transition_profile(true))) + .expect("HDR transition branch resolves") + .expect("configured source owns HDR transition branch"); + let hdr_descriptor = + commit_cpu_runtime(&mut builder, &exact, &hdr_source, hdr, &mut runtimes); + let at_zero = cpu_capture_frame(&hdr_source, 2, started, [148, 148, 148, 255]); + let zero_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &at_zero, + ); + let at_midpoint = cpu_capture_frame( + &hdr_source, + 3, + started + Duration::from_millis(125), + [148, 148, 148, 255], + ); + let midpoint_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &at_midpoint, + ); + let at_complete = cpu_capture_frame( + &hdr_source, + 4, + started + Duration::from_millis(250), + [148, 148, 148, 255], + ); + let complete_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &at_complete, + ); + assert_eq!(&zero_bytes[..4], [255, 255, 255, 255]); + assert_eq!(&midpoint_bytes[..4], [223, 223, 223, 255]); + assert_eq!(&complete_bytes[..4], [187, 187, 187, 255]); + } + + #[test] + fn macos_transition_inheritance_skips_matching_routes_without_curve_state() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let base_frame = frame(); + let sdr_source = source(&base_frame); + exact.replace_source(Some(sdr_source.clone())); + let identity_profile = ScreenProcessingProfile::new( + ScreenProcessingProfileConfig::exact_encoded_identity(CapturePixelFormat::Bgra8), + ); + let calibration = LedToneMapCalibration::DEFAULT; + let managed_profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + target_pixel_format: CapturePixelFormat::Bgra8, + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration); + let identity = resolve_macos_publication_branch(&sdr_source, &cpu_demand(identity_profile)) + .expect("encoded-identity branch resolves") + .expect("configured source owns encoded-identity branch"); + let managed = resolve_macos_publication_branch(&sdr_source, &cpu_demand(managed_profile)) + .expect("managed SDR branch resolves") + .expect("configured source owns managed SDR branch"); + let sdr_descriptors = commit_cpu_runtimes( + &mut builder, + &exact, + &sdr_source, + [identity, managed], + &mut runtimes, + ); + let started = Instant::now() + Duration::from_millis(20); + let sdr_frame = cpu_capture_frame(&sdr_source, 1, started, [148, 148, 148, 255]); + publish_cpu_frame(&exact, &mut runtimes, &sdr_source, &sdr_frame); + assert_eq!(sdr_descriptors.len(), 2); + + let hdr_source = hdr_transition_source(&sdr_source); + exact.replace_source(Some(hdr_source.clone())); + let hdr_profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + target_pixel_format: CapturePixelFormat::Bgra8, + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )), + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration); + let hdr = resolve_macos_publication_branch(&hdr_source, &cpu_demand(hdr_profile)) + .expect("managed HDR branch resolves") + .expect("configured source owns managed HDR branch"); + let hdr_descriptor = + commit_cpu_runtime(&mut builder, &exact, &hdr_source, hdr, &mut runtimes); + let transition_start = cpu_capture_frame(&hdr_source, 2, started, [148, 148, 148, 255]); + assert_eq!( + &publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &transition_start, + )[..4], + [255, 255, 255, 255] + ); + } + + #[test] + fn macos_publication_transition_restarts_from_its_midpoint_curve() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let base_frame = frame(); + let sdr_source = source(&base_frame); + exact.replace_source(Some(sdr_source.clone())); + let sdr = + resolve_macos_publication_branch(&sdr_source, &cpu_demand(transition_profile(false))) + .expect("SDR transition branch resolves") + .expect("configured source owns SDR transition branch"); + let sdr_descriptor = + commit_cpu_runtime(&mut builder, &exact, &sdr_source, sdr, &mut runtimes); + let started = Instant::now() + Duration::from_millis(20); + let sdr_frame = cpu_capture_frame(&sdr_source, 1, started, [148, 148, 148, 255]); + assert_eq!( + &publish_cpu_bytes( + &exact, + &mut runtimes, + &sdr_source, + &sdr_descriptor, + &sdr_frame, + )[..4], + [148, 148, 148, 255] + ); + + let hdr_source = hdr_transition_source(&sdr_source); + exact.replace_source(Some(hdr_source.clone())); + let hdr = + resolve_macos_publication_branch(&hdr_source, &cpu_demand(transition_profile(true))) + .expect("HDR transition branch resolves") + .expect("configured source owns HDR transition branch"); + let hdr_descriptor = + commit_cpu_runtime(&mut builder, &exact, &hdr_source, hdr, &mut runtimes); + let at_zero = cpu_capture_frame(&hdr_source, 2, started, [148, 148, 148, 255]); + assert_eq!( + &publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &at_zero, + )[..4], + [255, 255, 255, 255] + ); + let at_midpoint = cpu_capture_frame( + &hdr_source, + 3, + started + Duration::from_millis(125), + [148, 148, 148, 255], + ); + assert_eq!( + &publish_cpu_bytes( + &exact, + &mut runtimes, + &hdr_source, + &hdr_descriptor, + &at_midpoint, + )[..4], + [223, 223, 223, 255] + ); + + exact.replace_source(Some(sdr_source.clone())); + let restarted_sdr = + resolve_macos_publication_branch(&sdr_source, &cpu_demand(transition_profile(false))) + .expect("restarted SDR branch resolves") + .expect("configured source owns restarted SDR branch"); + let restarted_descriptor = commit_cpu_runtime( + &mut builder, + &exact, + &sdr_source, + restarted_sdr, + &mut runtimes, + ); + let restart_boundary = cpu_capture_frame( + &sdr_source, + 5, + started + Duration::from_millis(125), + [255, 255, 255, 255], + ); + let restart_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &sdr_source, + &restarted_descriptor, + &restart_boundary, + ); + let restart_midpoint = cpu_capture_frame( + &sdr_source, + 6, + started + Duration::from_millis(250), + [255, 255, 255, 255], + ); + let restart_midpoint_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &sdr_source, + &restarted_descriptor, + &restart_midpoint, + ); + let restart_complete = cpu_capture_frame( + &sdr_source, + 7, + started + Duration::from_millis(375), + [255, 255, 255, 255], + ); + let restart_complete_bytes = publish_cpu_bytes( + &exact, + &mut runtimes, + &sdr_source, + &restarted_descriptor, + &restart_complete, + ); + assert_eq!(&restart_bytes[..4], [224, 224, 224, 255]); + assert_eq!(&restart_midpoint_bytes[..4], [238, 238, 238, 255]); + assert_eq!(&restart_complete_bytes[..4], [255, 255, 255, 255]); + } + + #[test] + fn sdr_exposure_reconfiguration_swaps_atomically_without_transition() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let source = source(&frame()); + exact.replace_source(Some(source.clone())); + let initial = + resolve_macos_publication_branch(&source, &cpu_demand(transition_profile(false))) + .expect("initial SDR branch resolves") + .expect("configured source owns the initial SDR branch"); + let initial_descriptor = + commit_cpu_runtime(&mut builder, &exact, &source, initial, &mut runtimes); + let started = Instant::now() + Duration::from_millis(20); + let initial_frame = cpu_capture_frame(&source, 1, started, [96, 96, 96, 255]); + publish_cpu_bytes( + &exact, + &mut runtimes, + &source, + &initial_descriptor, + &initial_frame, + ); + + let default = LedToneMapCalibration::DEFAULT; + let calibration = LedToneMapCalibration::try_new( + default.target_white_x(), + default.target_white_y(), + default.target_reference_white_nits(), + default.target_peak_nits(), + 1.0, + ) + .expect("updated SDR exposure is valid"); + let next = resolve_macos_publication_branch( + &source, + &cpu_demand(transition_profile_with_calibration( + false, + ScreenSmoothingPolicy::Disabled, + calibration, + )), + ) + .expect("updated SDR branch resolves") + .expect("configured source owns the updated SDR branch"); + let next_descriptor = + commit_cpu_runtime(&mut builder, &exact, &source, next, &mut runtimes); + let boundary = started + Duration::from_millis(20); + assert_eq!( + active_tone_map_transition_count(&exact, &mut runtimes, &source, boundary), + 0 + ); + let encoded = [96, 96, 96, 255]; + let expected = execute_resolved_cpu(&source, &next_descriptor, encoded); + let at_zero = cpu_capture_frame(&source, 2, boundary, encoded); + assert_eq!( + publish_cpu_bytes(&exact, &mut runtimes, &source, &next_descriptor, &at_zero,), + expected + ); + let at_midpoint = + cpu_capture_frame(&source, 3, boundary + Duration::from_millis(125), encoded); + assert_eq!( + publish_cpu_bytes( + &exact, + &mut runtimes, + &source, + &next_descriptor, + &at_midpoint, + ), + expected + ); + assert_eq!( + active_tone_map_transition_count( + &exact, + &mut runtimes, + &source, + boundary + Duration::from_millis(125), + ), + 0 + ); + } + + #[test] + fn hdr_calibration_reconfiguration_swaps_atomically_without_transition() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let source = hdr_transition_source(&source(&frame())); + exact.replace_source(Some(source.clone())); + let initial = + resolve_macos_publication_branch(&source, &cpu_demand(transition_profile(true))) + .expect("initial HDR branch resolves") + .expect("configured source owns the initial HDR branch"); + let initial_descriptor = + commit_cpu_runtime(&mut builder, &exact, &source, initial, &mut runtimes); + let started = Instant::now() + Duration::from_millis(20); + let initial_frame = cpu_capture_frame(&source, 1, started, [148, 148, 148, 255]); + publish_cpu_bytes( + &exact, + &mut runtimes, + &source, + &initial_descriptor, + &initial_frame, + ); + + let default = LedToneMapCalibration::DEFAULT; + let calibration = LedToneMapCalibration::try_new( + default.target_white_x(), + default.target_white_y(), + 160.0, + 640.0, + default.exposure_ev(), + ) + .expect("updated HDR calibration is valid"); + let next = resolve_macos_publication_branch( + &source, + &cpu_demand(transition_profile_with_calibration( + true, + ScreenSmoothingPolicy::Disabled, + calibration, + )), + ) + .expect("updated HDR branch resolves") + .expect("configured source owns the updated HDR branch"); + let next_descriptor = + commit_cpu_runtime(&mut builder, &exact, &source, next, &mut runtimes); + let boundary = started + Duration::from_millis(20); + assert_eq!( + active_tone_map_transition_count(&exact, &mut runtimes, &source, boundary), + 0 + ); + let encoded = [148, 148, 148, 255]; + let expected = execute_resolved_cpu(&source, &next_descriptor, encoded); + let at_zero = cpu_capture_frame(&source, 2, boundary, encoded); + assert_eq!( + publish_cpu_bytes(&exact, &mut runtimes, &source, &next_descriptor, &at_zero,), + expected + ); + let at_midpoint = + cpu_capture_frame(&source, 3, boundary + Duration::from_millis(125), encoded); + assert_eq!( + publish_cpu_bytes( + &exact, + &mut runtimes, + &source, + &next_descriptor, + &at_midpoint, + ), + expected + ); + assert_eq!( + active_tone_map_transition_count( + &exact, + &mut runtimes, + &source, + boundary + Duration::from_millis(125), + ), + 0 + ); + } + + #[test] + fn macos_publication_samples_once_and_suppresses_both_scene_cut_paths() { + let smoothing = ScreenSmoothingPolicy::Exponential { + time_constant: Duration::from_mins(1), + scene_cut: ScreenSceneCutPolicy::MeanAbsoluteDelta { + threshold: ScreenProfileScalar::try_new(0.01) + .expect("scene-cut threshold is valid"), + }, + }; + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + let mut runtimes = Vec::new(); + let base_frame = frame(); + let sdr_source = source(&base_frame); + exact.replace_source(Some(sdr_source.clone())); + let sdr_profile = transition_profile_with_smoothing(false, smoothing); + let sdr_surface = resolve_macos_publication_branch( + &sdr_source, + &cpu_demand_for_kind(sdr_profile.clone(), ScreenPublicationKind::Surface), + ) + .expect("SDR Surface branch resolves") + .expect("configured source owns SDR Surface branch"); + let sdr_zones = resolve_macos_publication_branch( + &sdr_source, + &cpu_demand_for_kind( + sdr_profile, + ScreenPublicationKind::Zones { + columns: NonZeroU32::MIN, + rows: NonZeroU32::MIN, + }, + ), + ) + .expect("SDR Zones branch resolves") + .expect("configured source owns SDR Zones branch"); + let sdr_descriptors = commit_cpu_runtimes( + &mut builder, + &exact, + &sdr_source, + [sdr_surface, sdr_zones], + &mut runtimes, + ); + assert_eq!(sdr_descriptors.len(), 2); + assert_eq!(sdr_descriptors[0].physical(), sdr_descriptors[1].physical()); + let started = Instant::now() + Duration::from_millis(20); + let sdr_frame = cpu_capture_frame(&sdr_source, 1, started, [255, 255, 255, 255]); + publish_cpu_frame(&exact, &mut runtimes, &sdr_source, &sdr_frame); + assert_eq!( + &published_surface_bytes(&exact, &sdr_descriptors[0])[..4], + [255, 255, 255, 255] + ); + assert_eq!( + published_zone_colors(&exact, &sdr_descriptors[1])[0], + [255, 255, 255] + ); + + let hdr_source = hdr_transition_source(&sdr_source); + exact.replace_source(Some(hdr_source.clone())); + let hdr_profile = transition_profile_with_smoothing(true, smoothing); + let hdr_surface = resolve_macos_publication_branch( + &hdr_source, + &cpu_demand_for_kind(hdr_profile.clone(), ScreenPublicationKind::Surface), + ) + .expect("HDR Surface branch resolves") + .expect("configured source owns HDR Surface branch"); + let hdr_zones = resolve_macos_publication_branch( + &hdr_source, + &cpu_demand_for_kind( + hdr_profile, + ScreenPublicationKind::Zones { + columns: NonZeroU32::MIN, + rows: NonZeroU32::MIN, + }, + ), + ) + .expect("HDR Zones branch resolves") + .expect("configured source owns HDR Zones branch"); + let hdr_descriptors = commit_cpu_runtimes( + &mut builder, + &exact, + &hdr_source, + [hdr_surface, hdr_zones], + &mut runtimes, + ); + assert_eq!(hdr_descriptors.len(), 2); + assert_eq!(hdr_descriptors[0].physical(), hdr_descriptors[1].physical()); + let transition_start = cpu_capture_frame(&hdr_source, 2, started, [148, 148, 148, 255]); + publish_cpu_frame(&exact, &mut runtimes, &hdr_source, &transition_start); + assert_eq!( + &published_surface_bytes(&exact, &hdr_descriptors[0])[..4], + [255, 255, 255, 255] + ); + assert_eq!( + published_zone_colors(&exact, &hdr_descriptors[1])[0], + [255, 255, 255] + ); + + let midpoint = cpu_capture_frame( + &hdr_source, + 3, + started + Duration::from_millis(125), + [148, 148, 148, 255], + ); + publish_cpu_frame(&exact, &mut runtimes, &hdr_source, &midpoint); + let surface = published_surface_bytes(&exact, &hdr_descriptors[0]); + let zones = published_zone_colors(&exact, &hdr_descriptors[1]); + assert!(surface[0] > 250); + assert!(zones[0][0] > 250); + assert_eq!(&surface[..3], zones[0]); + } + fn target() -> ScreenNativeExecutionTarget { ScreenNativeExecutionTarget::new( ScreenNativeExecutionTargetId::new(NonZeroU64::new(11).expect("nonzero target")), @@ -2559,4 +3734,83 @@ mod tests { ScreenPublicationExecutor::Cpu )); } + + #[test] + fn processing_reconfiguration_preserves_the_native_capture_runtime() { + let admission = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + let (mut input, fixture) = + MacosScreenCaptureFixture::source(CaptureConfig::default(), admission); + let native_source = source(&frame()); + input.exact.replace_source(Some(native_source)); + fixture.control.set_active(true); + let active_transitions = fixture.control.active_transitions.load(Ordering::Acquire); + let worker_generation = input.worker_generation; + let revision = input.screen_publication_resolution_revision(); + let mut config = input.config.clone(); + config.target_led_white_x = 0.3000; + config.target_led_white_y = 0.3200; + config.target_led_reference_white_nits = 180.0; + config.target_led_peak_nits = 500.0; + config.exposure_ev = 1.25; + + input + .reconfigure_screen_processing(&config) + .expect("valid calibration updates without rebuilding capture"); + + assert_eq!(input.worker_generation, worker_generation); + assert!(fixture.is_active()); + assert_eq!( + fixture.control.active_transitions.load(Ordering::Acquire), + active_transitions + ); + assert_eq!(input.screen_publication_resolution_revision(), revision + 1); + let resolved = input + .resolve_screen_publication_branch(&RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::Cpu, + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + super::super::ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::default()), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + )) + .expect("calibrated branch resolves") + .expect("configured macOS source owns the demand"); + assert_eq!( + resolved + .descriptor() + .physical() + .color_pipeline() + .calibration(), + Some( + LedToneMapCalibration::try_new(0.3000, 0.3200, 180.0, 500.0, 1.25) + .expect("fixture calibration is valid") + ) + ); + } + + #[test] + fn invalid_processing_reconfiguration_preserves_the_active_profile() { + let admission = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + let (mut input, fixture) = + MacosScreenCaptureFixture::source(CaptureConfig::default(), admission); + fixture.control.set_active(true); + let revision = input.screen_publication_resolution_revision(); + let previous = input.config.clone(); + let mut invalid = previous.clone(); + invalid.exposure_ev = f32::INFINITY; + + assert!(input.reconfigure_screen_processing(&invalid).is_err()); + assert_eq!(input.config, previous); + assert_eq!(input.screen_publication_resolution_revision(), revision); + assert!(fixture.is_active()); + } } diff --git a/crates/hypercolor-core/src/input/screen/materialize.rs b/crates/hypercolor-core/src/input/screen/materialize.rs index 8f31844c3..121630c7b 100644 --- a/crates/hypercolor-core/src/input/screen/materialize.rs +++ b/crates/hypercolor-core/src/input/screen/materialize.rs @@ -510,6 +510,7 @@ impl PreparedCpuSurfaceMaterializer { physical_descriptor: &ScreenPhysicalReductionDescriptor, physical_pixels: &[u8], captured_at: Instant, + suppress_scene_cut_bypass: bool, publication: &mut PreparedScreenPublication, ) -> Result<(), CpuSurfaceMaterializationError> { self.validate_generation(plan_generation)?; @@ -601,7 +602,7 @@ impl PreparedCpuSurfaceMaterializer { elapsed, self.committed_bars .is_some_and(|committed| committed != bars), - false, + suppress_scene_cut_bypass, )?; for (pixel, color) in output .chunks_exact_mut(BYTES_PER_PIXEL) @@ -1003,6 +1004,7 @@ impl PreparedCpuZoneMaterializer { physical_descriptor: &ScreenPhysicalReductionDescriptor, physical_pixels: &[u8], captured_at: Instant, + suppress_scene_cut_bypass: bool, publication: &mut PreparedScreenPublication, ) -> Result { self.validate_generation(plan_generation)?; @@ -1055,7 +1057,7 @@ impl PreparedCpuZoneMaterializer { self.transfer, elapsed, reset_history, - false, + suppress_scene_cut_bypass, )?; self.apply_tuning(&mut output[..color_count]); output[color_count..].fill([0, 0, 0]); diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 36e1ed1c4..79097ccc9 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -35,6 +35,7 @@ mod retained; mod sampling; pub mod sector; pub mod smooth; +mod tone_map; pub mod tune; #[cfg(target_os = "linux")] pub mod wayland; @@ -146,6 +147,12 @@ pub use sampling::{ }; pub use sector::{LetterboxBars, SectorGrid, proportional_sector_bounds}; pub use smooth::TemporalSmoother; +pub use tone_map::{ + LED_TONE_MAP_ALGORITHM_REVISION, LED_TONE_MAP_MAX_EXPOSURE_EV, LED_TONE_MAP_MIN_EXPOSURE_EV, + LED_TONE_MAP_TRANSITION_DURATION, LedToneMapCalibration, LedToneMapCalibrationError, + LedToneMapConstants, LedToneMapCurveTransition, LedToneMapTransitionSample, PreparedLedToneMap, + PreparedLedToneMapError, +}; pub use tune::ColorTuning; #[cfg(target_os = "linux")] pub use wayland::WaylandScreenCaptureInput; diff --git a/crates/hypercolor-core/src/input/screen/publication.rs b/crates/hypercolor-core/src/input/screen/publication.rs index 64156d6db..17574f0ea 100644 --- a/crates/hypercolor-core/src/input/screen/publication.rs +++ b/crates/hypercolor-core/src/input/screen/publication.rs @@ -10,6 +10,7 @@ use std::time::Duration; use thiserror::Error; use super::plan::ScreenNativeResourceBindingKey; +use super::tone_map::{LED_TONE_MAP_ALGORITHM_REVISION, LedToneMapCalibration}; use super::{ CaptureColorSpace, CaptureColorimetry, CaptureColorimetryError, CaptureDynamicRange, CaptureEpoch, CaptureGeometry, CaptureLuminanceContext, CapturePixelFormat, CaptureRotation, @@ -1353,7 +1354,7 @@ impl Default for ScreenTargetColorimetry { pub struct ScreenColorTransformCapabilities { linear_light_sdr_processing: bool, linear_relative_color_conversion: bool, - pq_bt2390_tone_mapping: bool, + reference_white_bt2390_tone_mapping: bool, algorithm_revision: Option, } @@ -1362,7 +1363,7 @@ impl ScreenColorTransformCapabilities { pub const NONE: Self = Self { linear_light_sdr_processing: false, linear_relative_color_conversion: false, - pq_bt2390_tone_mapping: false, + reference_white_bt2390_tone_mapping: false, algorithm_revision: None, }; @@ -1371,13 +1372,13 @@ impl ScreenColorTransformCapabilities { pub const fn new( linear_light_sdr_processing: bool, linear_relative_color_conversion: bool, - pq_bt2390_tone_mapping: bool, + reference_white_bt2390_tone_mapping: bool, algorithm_revision: NonZeroU32, ) -> Self { Self { linear_light_sdr_processing, linear_relative_color_conversion, - pq_bt2390_tone_mapping, + reference_white_bt2390_tone_mapping, algorithm_revision: Some(algorithm_revision), } } @@ -1397,7 +1398,13 @@ impl ScreenColorTransformCapabilities { /// Whether PQ HDR can be mapped to SDR with BT.2390 end to end. #[must_use] pub const fn supports_pq_bt2390_tone_mapping(self) -> bool { - self.pq_bt2390_tone_mapping + self.reference_white_bt2390_tone_mapping + } + + /// Whether reference-white BT.2390 mapping accepts supported HDR encodings. + #[must_use] + pub const fn supports_reference_white_bt2390_tone_mapping(self) -> bool { + self.reference_white_bt2390_tone_mapping } /// Whether this reducer's end-to-end conversion promises cover one gamut policy. @@ -1405,7 +1412,7 @@ impl ScreenColorTransformCapabilities { pub const fn supports_gamut_policy(self, policy: ScreenGamutMapPolicy) -> bool { match policy { ScreenGamutMapPolicy::RelativeColorimetricClip => { - self.linear_relative_color_conversion || self.pq_bt2390_tone_mapping + self.linear_relative_color_conversion || self.reference_white_bt2390_tone_mapping } } } @@ -1477,7 +1484,7 @@ pub enum ScreenUnknownColorPolicy { /// Gamut behavior for known-primary conversions. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenGamutMapPolicy { - /// Apply the relative-colorimetric matrix and clip target-linear channels. + /// Apply the relative-colorimetric matrix and compress target-linear chroma. #[default] RelativeColorimetricClip, } @@ -1509,6 +1516,15 @@ impl ScreenToneMapPolicy { } } + /// Construct a tone-map request from the validated target calibration. + #[must_use] + pub fn from_calibration( + operator: ScreenToneMapOperator, + calibration: LedToneMapCalibration, + ) -> Self { + Self::new(operator, calibration.target_luminance()) + } + /// Exact tone-map operator. #[must_use] pub const fn operator(self) -> ScreenToneMapOperator { @@ -1539,6 +1555,7 @@ pub struct ResolvedScreenToneMap { source_luminance: CaptureLuminanceContext, target_luminance: CaptureLuminanceContext, gamut: ScreenGamutMapPolicy, + calibration: LedToneMapCalibration, } impl ResolvedScreenToneMap { @@ -1565,6 +1582,12 @@ impl ResolvedScreenToneMap { pub const fn gamut(self) -> ScreenGamutMapPolicy { self.gamut } + + /// Validated target white point, luminance coordinates, and exposure. + #[must_use] + pub const fn calibration(self) -> LedToneMapCalibration { + self.calibration + } } /// Byte-changing color operation selected before backend preparation. @@ -1586,6 +1609,7 @@ pub struct ResolvedScreenColorPipeline { effective_source: Option, output: CaptureColorimetry, transform: ResolvedScreenColorTransform, + calibration: Option, } impl ResolvedScreenColorPipeline { @@ -1606,6 +1630,12 @@ impl ResolvedScreenColorPipeline { pub const fn transform(self) -> ResolvedScreenColorTransform { self.transform } + + /// Calibration applied by byte-changing managed color processing. + #[must_use] + pub const fn calibration(self) -> Option { + self.calibration + } } /// Complete immutable byte-changing processing configuration. @@ -1623,6 +1653,7 @@ pub struct ScreenProcessingProfile { unknown_color: ScreenUnknownColorPolicy, hdr: ScreenHdrPolicy, gamut: ScreenGamutMapPolicy, + led_tone_map: LedToneMapCalibration, algorithm_revision: NonZeroU32, } @@ -1697,7 +1728,7 @@ impl Default for ScreenProcessingProfileConfig { unknown_color: ScreenUnknownColorPolicy::default(), hdr: ScreenHdrPolicy::default(), gamut: ScreenGamutMapPolicy::default(), - algorithm_revision: NonZeroU32::MIN, + algorithm_revision: LED_TONE_MAP_ALGORITHM_REVISION, } } } @@ -1719,10 +1750,24 @@ impl ScreenProcessingProfile { unknown_color: config.unknown_color, hdr: config.hdr, gamut: config.gamut, + led_tone_map: LedToneMapCalibration::DEFAULT, algorithm_revision: config.algorithm_revision, } } + /// Replace the validated target LED calibration and user exposure. + #[must_use] + pub fn with_led_tone_map(mut self, led_tone_map: LedToneMapCalibration) -> Self { + self.led_tone_map = led_tone_map; + if let ScreenHdrPolicy::ToneMap(policy) = self.hdr { + self.hdr = ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + policy.operator(), + led_tone_map, + )); + } + self + } + /// Content-bar detection policy. #[must_use] pub const fn content_bars(&self) -> ScreenContentBarsPolicy { @@ -1795,6 +1840,12 @@ impl ScreenProcessingProfile { self.gamut } + /// Target LED calibration and authoritative user exposure. + #[must_use] + pub const fn led_tone_map(&self) -> LedToneMapCalibration { + self.led_tone_map + } + /// Complete processing algorithm revision. #[must_use] pub const fn algorithm_revision(&self) -> NonZeroU32 { @@ -1826,6 +1877,7 @@ impl Ord for ScreenProcessingProfile { .then_with(|| self.unknown_color.cmp(&other.unknown_color)) .then_with(|| self.hdr.cmp(&other.hdr)) .then_with(|| self.gamut.cmp(&other.gamut)) + .then_with(|| self.led_tone_map.cmp(&other.led_tone_map)) .then_with(|| self.algorithm_revision.cmp(&other.algorithm_revision)) } } @@ -2733,6 +2785,7 @@ fn resolve_color_pipeline( effective_source: None, output: source, transform: ResolvedScreenColorTransform::PreserveEncodedSamples, + calibration: None, }); } ScreenUnknownColorPolicy::Assume(assumption) => { @@ -2804,6 +2857,7 @@ fn resolve_known_color_pipeline( effective_source: Some(source), output: CaptureColorimetry::from_known(target), transform: ResolvedScreenColorTransform::PreserveEncodedSamples, + calibration: None, }); } if capabilities.algorithm_revision() != Some(profile.algorithm_revision) { @@ -2830,6 +2884,7 @@ fn resolve_known_color_pipeline( gamut: profile.gamut, } }, + calibration: Some(profile.led_tone_map), }) } @@ -2843,34 +2898,44 @@ fn resolve_hdr_color_pipeline( ScreenHdrPolicy::Reject => Err(ScreenPublicationError::HdrRejected), ScreenHdrPolicy::ToneMap(policy) if source.dynamic_range() == CaptureDynamicRange::High - && source.transfer_function() == CaptureTransferFunction::Pq + && matches!( + source.transfer_function(), + CaptureTransferFunction::Pq | CaptureTransferFunction::Linear + ) && target.dynamic_range() == CaptureDynamicRange::Standard => { if capabilities.algorithm_revision() != Some(profile.algorithm_revision) - || !capabilities.supports_pq_bt2390_tone_mapping() + || !capabilities.supports_reference_white_bt2390_tone_mapping() || !capabilities.supports_gamut_policy(profile.gamut) { return Err(ScreenPublicationError::UnsupportedColorTransform); } - if target - .luminance() - .is_some_and(|luminance| luminance != policy.target_luminance) + let target_luminance = profile.led_tone_map.target_luminance(); + if policy.target_luminance != target_luminance + || target + .luminance() + .is_some_and(|luminance| luminance != target_luminance) { return Err(ScreenPublicationError::ToneMapTargetLuminanceConflict); } let source_luminance = source .luminance() .ok_or(ScreenPublicationError::MissingSourceLuminance)?; - let output = target.with_luminance(policy.target_luminance); + if source_luminance.peak_nits() <= source_luminance.reference_white_nits() { + return Err(ScreenPublicationError::UnsupportedHdrConversion); + } + let output = target.with_luminance(target_luminance); Ok(ResolvedScreenColorPipeline { effective_source: Some(source), output: CaptureColorimetry::from_known(output), transform: ResolvedScreenColorTransform::ToneMap(ResolvedScreenToneMap { operator: policy.operator, source_luminance, - target_luminance: policy.target_luminance, + target_luminance, gamut: profile.gamut, + calibration: profile.led_tone_map, }), + calibration: Some(profile.led_tone_map), }) } ScreenHdrPolicy::ToneMap(_) => Err(ScreenPublicationError::UnsupportedHdrConversion), diff --git a/crates/hypercolor-core/src/input/screen/reducer.rs b/crates/hypercolor-core/src/input/screen/reducer.rs index 1ffaa882a..72ffd3c25 100644 --- a/crates/hypercolor-core/src/input/screen/reducer.rs +++ b/crates/hypercolor-core/src/input/screen/reducer.rs @@ -11,12 +11,13 @@ use rayon::prelude::*; use rayon::{ThreadPool, ThreadPoolBuilder}; use thiserror::Error; -use hypercolor_types::canvas::{linear_to_srgb_u8, srgb_u8_to_linear}; - use super::sampling::{ CpuAxisInterpolation, CpuSamplingError, CpuSamplingRow, CpuSamplingTransform, CpuSamplingView, CpuStorageAxis, CpuStorageSpan, PreparedCpuSamplingPlan, }; +use super::tone_map::{ + LED_TONE_MAP_ALGORITHM_REVISION, PreparedLedToneMap, PreparedLedToneMapError, +}; use super::{ CaptureCursor, CaptureCursorContent, CaptureDynamicRange, CaptureFrame, CaptureFrameError, @@ -30,8 +31,6 @@ use super::{ }; const CHANNELS_PER_PIXEL: u64 = 4; -const CPU_REDUCTION_ALGORITHM_REVISION: NonZeroU32 = NonZeroU32::MIN; - /// Platform work required before an exact request can enter the CPU reducer. #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum CpuFallbackNeed { @@ -276,6 +275,13 @@ impl PreparedCpuReductionBatch { .map(|reduction| &reduction.descriptor) } + pub(super) fn prepared_tone_map(&self, index: usize) -> Option { + match self.reductions.get(index)?.color { + ReductionColor::Managed(prepared) => Some(prepared), + ReductionColor::Encoded => None, + } + } + /// Exact caller-owned output bytes required at one output index. #[must_use] pub fn output_byte_len(&self, index: usize) -> Option { @@ -1023,7 +1029,13 @@ impl CpuReductionExecutor { /// Exact color operations and algorithm revision implemented by this executor. #[must_use] pub const fn capabilities(&self) -> ScreenColorTransformCapabilities { - ScreenColorTransformCapabilities::new(true, false, false, CPU_REDUCTION_ALGORITHM_REVISION) + Self::supported_color_capabilities() + } + + /// Exact color operations supported by every CPU reduction executor. + #[must_use] + pub const fn supported_color_capabilities() -> ScreenColorTransformCapabilities { + ScreenColorTransformCapabilities::new(true, true, true, LED_TONE_MAP_ALGORITHM_REVISION) } /// Quote exact prepared-batch backing before allocating descriptor storage. @@ -1213,6 +1225,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1226,6 +1239,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, job.output_mut(batch_index)?, @@ -1267,11 +1281,18 @@ impl CpuReductionExecutor { workspace: &mut PreparedCpuMaterializationWorkspace, workspace_indices: &[usize], surface_batch_indices: &[Option], + tone_map_overrides: &[Option], publications: &mut [PreparedScreenPublication], ) -> Result { if !Arc::ptr_eq(&batch.reductions, &workspace.reductions) { return Err(CpuReductionError::WorkspaceBatchMismatch); } + if tone_map_overrides.len() != batch.reductions.len() { + return Err(CpuReductionError::ToneMapOverrideCountMismatch { + expected: batch.reductions.len(), + actual: tone_map_overrides.len(), + }); + } validate_workspace_schedule(workspace, workspace_indices)?; validate_aligned_surface_schedule(batch, surface_batch_indices, publications)?; validate_aligned_schedule_disjoint(workspace, workspace_indices, surface_batch_indices)?; @@ -1297,6 +1318,9 @@ impl CpuReductionExecutor { }); } let reduction = &batch.reductions[plane.batch_index]; + reduction + .color + .with_tone_map_override(tone_map_overrides[plane.batch_index])?; preflight_reduction( reduction, &plane.scratch, @@ -1315,6 +1339,9 @@ impl CpuReductionExecutor { continue; }; let reduction = &batch.reductions[batch_index]; + reduction + .color + .with_tone_map_override(tone_map_overrides[batch_index])?; let output = publication.output_mut(batch_index)?; preflight_reduction( reduction, @@ -1340,9 +1367,13 @@ impl CpuReductionExecutor { }) .try_for_each(|(_, plane)| { let reduction = &batch.reductions[plane.batch_index]; + let color = reduction + .color + .with_tone_map_override(tone_map_overrides[plane.batch_index])?; reduce_prepared_in_pool( &view, reduction, + color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1358,9 +1389,13 @@ impl CpuReductionExecutor { return Ok(()); }; let reduction = &batch.reductions[batch_index]; + let color = reduction + .color + .with_tone_map_override(tone_map_overrides[batch_index])?; reduce_prepared_in_pool( &view, reduction, + color, self.inner.worker_count, self.inner.tile_rows, publication.output_mut(batch_index)?, @@ -1465,6 +1500,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1551,6 +1587,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, destination.output_mut(index)?, @@ -1605,9 +1642,9 @@ fn prepare_physical_reduction( descriptor: &ScreenPhysicalReductionDescriptor, sampling_transform: CpuSamplingTransform, ) -> Result { - if descriptor.algorithm_revision() != CPU_REDUCTION_ALGORITHM_REVISION { + if descriptor.algorithm_revision() != LED_TONE_MAP_ALGORITHM_REVISION { return Err(CpuReductionError::AlgorithmRevisionMismatch { - expected: CPU_REDUCTION_ALGORITHM_REVISION, + expected: LED_TONE_MAP_ALGORITHM_REVISION, actual: descriptor.algorithm_revision(), }); } @@ -1696,6 +1733,7 @@ fn reduce_request_in_pool( fn reduce_prepared_in_pool( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, worker_count: NonZeroUsize, tile_rows: NonZeroU32, output: &mut [u8], @@ -1705,7 +1743,14 @@ fn reduce_prepared_in_pool( .par_chunks_mut(tile_plan.bytes_per_tile) .enumerate() .try_for_each(|(tile_index, tile)| { - reduce_prepared_tile(view, reduction, tile_index, tile_plan.pixels_per_tile, tile) + reduce_prepared_tile( + view, + reduction, + color, + tile_index, + tile_plan.pixels_per_tile, + tile, + ) }) } @@ -1769,6 +1814,7 @@ fn prepare_reduction_tiles( fn reduce_prepared_tile( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, tile_index: usize, tile_pixels: usize, mut tile: &mut [u8], @@ -1791,7 +1837,7 @@ fn reduce_prepared_tile( .checked_mul(4) .ok_or(CpuReductionError::GeometryOverflow { resource: "tile" })?; let (row, remainder) = tile.split_at_mut(run_bytes); - reduce_prepared_row(view, reduction, target_y, first_target_x, row)?; + reduce_prepared_row(view, reduction, color, target_y, first_target_x, row)?; first_pixel = first_pixel .checked_add(run_pixels) .ok_or(CpuReductionError::GeometryOverflow { resource: "tile" })?; @@ -1803,19 +1849,20 @@ fn reduce_prepared_tile( fn reduce_prepared_row( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, target_y: u32, first_target_x: usize, row: &mut [u8], ) -> Result<(), CpuReductionError> { match reduction.descriptor.reduction_filter() { ScreenReductionFilter::Nearest => { - reduce_prepared_nearest_row(view, reduction, target_y, first_target_x, row) + reduce_prepared_nearest_row(view, reduction, color, target_y, first_target_x, row) } ScreenReductionFilter::Bilinear => { - reduce_prepared_bilinear_row(view, reduction, target_y, first_target_x, row) + reduce_prepared_bilinear_row(view, reduction, color, target_y, first_target_x, row) } ScreenReductionFilter::Area => { - reduce_prepared_area_row(view, reduction, target_y, first_target_x, row) + reduce_prepared_area_row(view, reduction, color, target_y, first_target_x, row) } } } @@ -1823,6 +1870,7 @@ fn reduce_prepared_row( fn reduce_prepared_nearest_row( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, target_y: u32, first_target_x: usize, row: &mut [u8], @@ -1832,12 +1880,15 @@ fn reduce_prepared_nearest_row( CpuStorageAxis::X => { let source_row = view.storage_row(fixed)?; write_prepared_row(reduction, first_target_x, row, |target_x| { - Ok(source_row.read_rgba(reduction.sampling.logical_x_nearest(target_x))?) + let sample = + source_row.read_rgba(reduction.sampling.logical_x_nearest(target_x))?; + Ok(color.encode(color.decode(sample))) }) } CpuStorageAxis::Y => write_prepared_row(reduction, first_target_x, row, |target_x| { let source_row = view.storage_row(reduction.sampling.logical_x_nearest(target_x))?; - Ok(source_row.read_rgba(fixed)?) + let sample = source_row.read_rgba(fixed)?; + Ok(color.encode(color.decode(sample))) }), } } @@ -1845,6 +1896,7 @@ fn reduce_prepared_nearest_row( fn reduce_prepared_bilinear_row( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, target_y: u32, first_target_x: usize, row: &mut [u8], @@ -1856,14 +1908,14 @@ fn reduce_prepared_bilinear_row( let bottom = view.storage_row(fixed.upper())?; write_prepared_row(reduction, first_target_x, row, |target_x| { let x = reduction.sampling.logical_x_bilinear(target_x); - sample_prepared_bilinear(top, bottom, x, fixed, reduction.color) + sample_prepared_bilinear(top, bottom, x, fixed, color) }) } CpuStorageAxis::Y => write_prepared_row(reduction, first_target_x, row, |target_x| { let y = reduction.sampling.logical_x_bilinear(target_x); let top = view.storage_row(y.lower())?; let bottom = view.storage_row(y.upper())?; - sample_prepared_bilinear(top, bottom, fixed, y, reduction.color) + sample_prepared_bilinear(top, bottom, fixed, y, color) }), } } @@ -1895,6 +1947,7 @@ fn sample_prepared_bilinear( fn reduce_prepared_area_row( view: &CpuSamplingView<'_>, reduction: &PreparedCpuReduction, + color: ReductionColor, target_y: u32, first_target_x: usize, row: &mut [u8], @@ -1906,7 +1959,7 @@ fn reduce_prepared_area_row( view, reduction.sampling.logical_x_area(target_x), fixed, - reduction.color, + color, ) }), CpuStorageAxis::Y => write_prepared_row(reduction, first_target_x, row, |target_x| { @@ -1914,7 +1967,7 @@ fn reduce_prepared_area_row( view, fixed, reduction.sampling.logical_x_area(target_x), - reduction.color, + color, ) }), } @@ -1991,6 +2044,18 @@ pub enum CpuReductionError { /// The resolved SDR transfer function has no 8-bit CPU codec. #[error("unsupported CPU reduction transfer function: {0:?}")] UnsupportedTransferFunction(CaptureTransferFunction), + /// The resolved managed pipeline omitted its exact target calibration. + #[error("managed CPU color processing requires target LED calibration")] + MissingLedToneMapCalibration, + /// A frame supplied another number of prepared curve overrides than routes. + #[error("CPU tone-map override count mismatch: expected {expected}, got {actual}")] + ToneMapOverrideCountMismatch { expected: usize, actual: usize }, + /// An encoded-preservation route cannot consume a managed curve override. + #[error("encoded-sample preservation cannot consume a tone-map override")] + UnexpectedToneMapOverride, + /// Shared color constants could not be prepared from the resolved contract. + #[error(transparent)] + ToneMapPreparation(#[from] PreparedLedToneMapError), /// The resolved linear-light contract is internally inconsistent. #[error("resolved linear-light SDR pipeline has inconsistent source and output metadata")] InconsistentLinearLightPipeline, @@ -2117,11 +2182,21 @@ impl From for CpuReductionError { #[derive(Clone, Copy, Debug)] enum ReductionColor { Encoded, - Srgb, - Linear, + Managed(PreparedLedToneMap), } impl ReductionColor { + fn with_tone_map_override( + self, + tone_map_override: Option, + ) -> Result { + match (self, tone_map_override) { + (Self::Managed(_), Some(prepared)) => Ok(Self::Managed(prepared)), + (color, None) => Ok(color), + (Self::Encoded, Some(_)) => Err(CpuReductionError::UnexpectedToneMapOverride), + } + } + fn resolve(request: CpuReductionRequest<'_>) -> Result { let preserves_encoded_samples = request.layout.source_extent() == request.layout.target_extent() @@ -2134,14 +2209,17 @@ impl ReductionColor { color_pipeline: ResolvedScreenColorPipeline, preserves_encoded_samples: bool, ) -> Result { - match color_pipeline.transform() { + let transform = color_pipeline.transform(); + match transform { ResolvedScreenColorTransform::PreserveEncodedSamples => { if !preserves_encoded_samples { return Err(CpuReductionError::InexactEncodedSamplePreservation); } Ok(Self::Encoded) } - ResolvedScreenColorTransform::LinearLightSdr => { + ResolvedScreenColorTransform::LinearLightSdr + | ResolvedScreenColorTransform::LinearRelativeColorimetric { .. } + | ResolvedScreenColorTransform::ToneMap(_) => { let Some(source) = color_pipeline.effective_source() else { return Err(CpuReductionError::InconsistentLinearLightPipeline); }; @@ -2149,22 +2227,37 @@ impl ReductionColor { .output() .try_known() .map_err(|_| CpuReductionError::InconsistentLinearLightPipeline)?; - if source.dynamic_range() != CaptureDynamicRange::Standard - || output.dynamic_range() != CaptureDynamicRange::Standard - || source.color_space() != output.color_space() - || source.transfer_function() != output.transfer_function() - { + let calibration = color_pipeline + .calibration() + .ok_or(CpuReductionError::MissingLedToneMapCalibration)?; + let consistent = match transform { + ResolvedScreenColorTransform::LinearLightSdr => { + source.dynamic_range() == CaptureDynamicRange::Standard + && output.dynamic_range() == CaptureDynamicRange::Standard + && source.color_space() == output.color_space() + && source.transfer_function() == output.transfer_function() + } + ResolvedScreenColorTransform::LinearRelativeColorimetric { .. } => { + source.dynamic_range() == CaptureDynamicRange::Standard + && output.dynamic_range() == CaptureDynamicRange::Standard + } + ResolvedScreenColorTransform::ToneMap(tone_map) => { + source.dynamic_range() == CaptureDynamicRange::High + && output.dynamic_range() == CaptureDynamicRange::Standard + && source.luminance() == Some(tone_map.source_luminance()) + && output.luminance() == Some(tone_map.target_luminance()) + && tone_map.calibration() == calibration + } + ResolvedScreenColorTransform::PreserveEncodedSamples => false, + }; + if !consistent { return Err(CpuReductionError::InconsistentLinearLightPipeline); } - match source.transfer_function() { - CaptureTransferFunction::Srgb => Ok(Self::Srgb), - CaptureTransferFunction::Linear => Ok(Self::Linear), - transfer => Err(CpuReductionError::UnsupportedTransferFunction(transfer)), - } - } - transform @ (ResolvedScreenColorTransform::LinearRelativeColorimetric { .. } - | ResolvedScreenColorTransform::ToneMap(_)) => { - Err(CpuReductionError::UnsupportedColorTransform(transform)) + Ok(Self::Managed(PreparedLedToneMap::prepare( + source, + output, + calibration, + )?)) } } } @@ -2187,30 +2280,14 @@ impl ReductionColor { f64::from(sample[2]) / 255.0, f64::from(sample[3]) / 255.0, ], - Self::Srgb => [ - f64::from(srgb_u8_to_linear(sample[0])), - f64::from(srgb_u8_to_linear(sample[1])), - f64::from(srgb_u8_to_linear(sample[2])), - f64::from(sample[3]) / 255.0, - ], - Self::Linear => [ - f64::from(sample[0]) / 255.0, - f64::from(sample[1]) / 255.0, - f64::from(sample[2]) / 255.0, - f64::from(sample[3]) / 255.0, - ], + Self::Managed(prepared) => prepared.decode_and_map(sample), } } fn encode(self, sample: [f64; 4]) -> [u8; 4] { match self { - Self::Srgb => [ - linear_to_srgb_u8(sample[0] as f32), - linear_to_srgb_u8(sample[1] as f32), - linear_to_srgb_u8(sample[2] as f32), - encode_linear_byte(sample[3]), - ], - Self::Encoded | Self::Linear => [ + Self::Managed(prepared) => prepared.encode(sample), + Self::Encoded => [ encode_linear_byte(sample[0]), encode_linear_byte(sample[1]), encode_linear_byte(sample[2]), @@ -2338,7 +2415,9 @@ fn reduce_row( let target_x = u32::try_from(target_x) .map_err(|_| CpuReductionError::GeometryOverflow { resource: "target" })?; let sample = match request.filter { - ScreenReductionFilter::Nearest => sample_nearest(request, target_x, target_y)?, + ScreenReductionFilter::Nearest => { + color.encode(color.decode(sample_nearest(request, target_x, target_y)?)) + } ScreenReductionFilter::Bilinear => sample_bilinear(request, color, target_x, target_y)?, ScreenReductionFilter::Area => sample_area(request, color, target_x, target_y)?, }; diff --git a/crates/hypercolor-core/src/input/screen/tone_map.rs b/crates/hypercolor-core/src/input/screen/tone_map.rs new file mode 100644 index 000000000..7d39d9601 --- /dev/null +++ b/crates/hypercolor-core/src/input/screen/tone_map.rs @@ -0,0 +1,938 @@ +//! Shared CPU and GPU contract for LED-targeted capture color processing. + +use std::num::NonZeroU32; +use std::time::Duration; + +use thiserror::Error; + +use hypercolor_types::canvas::{linear_to_srgb_u8, srgb_u8_to_linear}; + +use super::frame::{ + CaptureColorSpace, CaptureDynamicRange, CaptureLuminanceContext, CapturePositiveScalar, + CaptureTransferFunction, KnownCaptureColorimetry, +}; + +/// Exact cache revision of the shared LED tone-mapping algorithm. +pub const LED_TONE_MAP_ALGORITHM_REVISION: NonZeroU32 = NonZeroU32::MIN; +/// Duration of an SDR/HDR curve transition. +pub const LED_TONE_MAP_TRANSITION_DURATION: Duration = Duration::from_millis(250); +/// Lowest accepted user exposure. +pub const LED_TONE_MAP_MIN_EXPOSURE_EV: f32 = -8.0; +/// Highest accepted user exposure. +pub const LED_TONE_MAP_MAX_EXPOSURE_EV: f32 = 8.0; + +const D65_X: f32 = 0.3127; +const D65_Y: f32 = 0.3290; +const DEFAULT_REFERENCE_WHITE_NITS: f32 = 203.0; +const DEFAULT_PEAK_NITS: f32 = 406.0; +const MIN_REFERENCE_WHITE_NITS: f32 = 1.0; +const MAX_REFERENCE_WHITE_NITS: f32 = 5_000.0; +const MIN_PEAK_NITS: f32 = 1.0; +const MAX_PEAK_NITS: f32 = 10_000.0; + +const SRGB_TO_XYZ: Matrix3 = Matrix3([ + [0.412_390_8, 0.357_584_33, 0.180_480_8], + [0.212_639, 0.715_168_65, 0.072_192_32], + [0.019_330_82, 0.119_194_78, 0.950_532_14], +]); +const DISPLAY_P3_TO_XYZ: Matrix3 = Matrix3([ + [0.486_570_95, 0.265_667_7, 0.198_217_29], + [0.228_974_57, 0.691_738_55, 0.079_286_91], + [0.0, 0.045_113_38, 1.043_944_4], +]); +const REC2020_TO_XYZ: Matrix3 = Matrix3([ + [0.636_958_06, 0.144_616_9, 0.168_880_98], + [0.262_700_2, 0.677_998_07, 0.059_301_715], + [0.0, 0.028_072_694, 1.060_985_1], +]); +const BRADFORD: Matrix3 = Matrix3([ + [0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296], +]); +const BRADFORD_INVERSE: Matrix3 = Matrix3([ + [0.986_992_9, -0.147_054_3, 0.159_962_7], + [0.432_305_3, 0.518_360_3, 0.049_291_2], + [-0.008_528_7, 0.040_042_8, 0.968_486_7], +]); +const IDENTITY: Matrix3 = Matrix3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct CanonicalScalar(u32); + +impl CanonicalScalar { + const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + fn try_new(value: f32) -> Result { + if !value.is_finite() { + return Err(LedToneMapCalibrationError::NonFiniteScalar); + } + Ok(if value == 0.0 { + Self::from_bits(0.0_f32.to_bits()) + } else { + Self::from_bits(value.to_bits()) + }) + } + + const fn value(self) -> f32 { + f32::from_bits(self.0) + } +} + +/// Validated target LED calibration and authoritative user exposure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LedToneMapCalibration { + target_white_x: CanonicalScalar, + target_white_y: CanonicalScalar, + target_reference_white_nits: CanonicalScalar, + target_peak_nits: CanonicalScalar, + exposure_ev: CanonicalScalar, +} + +impl LedToneMapCalibration { + /// Nominal D65 calibration with one stop of HDR output headroom. + pub const DEFAULT: Self = Self { + target_white_x: CanonicalScalar::from_bits(D65_X.to_bits()), + target_white_y: CanonicalScalar::from_bits(D65_Y.to_bits()), + target_reference_white_nits: CanonicalScalar::from_bits( + DEFAULT_REFERENCE_WHITE_NITS.to_bits(), + ), + target_peak_nits: CanonicalScalar::from_bits(DEFAULT_PEAK_NITS.to_bits()), + exposure_ev: CanonicalScalar::from_bits(0.0_f32.to_bits()), + }; + + /// Validate one complete target calibration without clamping any field. + /// + /// # Errors + /// + /// Rejects non-finite values, chromaticities outside the CIE xy triangle, + /// luminance outside the specified ranges, a non-increasing peak, and + /// exposure outside `-8..=8` EV. + pub fn try_new( + target_white_x: f32, + target_white_y: f32, + target_reference_white_nits: f32, + target_peak_nits: f32, + exposure_ev: f32, + ) -> Result { + let calibration = Self { + target_white_x: CanonicalScalar::try_new(target_white_x)?, + target_white_y: CanonicalScalar::try_new(target_white_y)?, + target_reference_white_nits: CanonicalScalar::try_new(target_reference_white_nits)?, + target_peak_nits: CanonicalScalar::try_new(target_peak_nits)?, + exposure_ev: CanonicalScalar::try_new(exposure_ev)?, + }; + calibration.validate()?; + Ok(calibration) + } + + fn validate(self) -> Result<(), LedToneMapCalibrationError> { + let x = self.target_white_x(); + let y = self.target_white_y(); + if x <= 0.0 || y <= 0.0 || x + y >= 1.0 { + return Err(LedToneMapCalibrationError::WhitePointOutsideChromaticityTriangle); + } + if !(MIN_REFERENCE_WHITE_NITS..=MAX_REFERENCE_WHITE_NITS) + .contains(&self.target_reference_white_nits()) + { + return Err(LedToneMapCalibrationError::ReferenceWhiteOutOfRange); + } + if !(MIN_PEAK_NITS..=MAX_PEAK_NITS).contains(&self.target_peak_nits()) { + return Err(LedToneMapCalibrationError::PeakOutOfRange); + } + if self.target_peak_nits() <= self.target_reference_white_nits() { + return Err(LedToneMapCalibrationError::PeakNotAboveReferenceWhite); + } + if !(LED_TONE_MAP_MIN_EXPOSURE_EV..=LED_TONE_MAP_MAX_EXPOSURE_EV) + .contains(&self.exposure_ev()) + { + return Err(LedToneMapCalibrationError::ExposureOutOfRange); + } + Ok(()) + } + + const fn has_nominal_d65_white(self) -> bool { + self.target_white_x.0 == Self::DEFAULT.target_white_x.0 + && self.target_white_y.0 == Self::DEFAULT.target_white_y.0 + } + + /// Target LED white-point x chromaticity. + #[must_use] + pub const fn target_white_x(self) -> f32 { + self.target_white_x.value() + } + + /// Target LED white-point y chromaticity. + #[must_use] + pub const fn target_white_y(self) -> f32 { + self.target_white_y.value() + } + + /// Target reference white in nits. + #[must_use] + pub const fn target_reference_white_nits(self) -> f32 { + self.target_reference_white_nits.value() + } + + /// Calibrated target peak in nits. + #[must_use] + pub const fn target_peak_nits(self) -> f32 { + self.target_peak_nits.value() + } + + /// Authoritative user exposure in EV. + #[must_use] + pub const fn exposure_ev(self) -> f32 { + self.exposure_ev.value() + } + + /// Target luminance contract used by resolved publication metadata. + #[must_use] + pub fn target_luminance(self) -> CaptureLuminanceContext { + let reference_white = CapturePositiveScalar::try_new(self.target_reference_white_nits()) + .expect("validated target reference white remains positive and finite"); + let peak = CapturePositiveScalar::try_new(self.target_peak_nits()) + .expect("validated target peak remains positive and finite"); + CaptureLuminanceContext::new(reference_white, peak) + .expect("validated target peak remains above reference white") + } +} + +impl Default for LedToneMapCalibration { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Invalid target LED calibration. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum LedToneMapCalibrationError { + /// Every calibration scalar must be finite. + #[error("LED tone-map calibration values must be finite")] + NonFiniteScalar, + /// White-point coordinates must be strictly inside the CIE xy triangle. + #[error("target LED white point must be strictly inside the CIE xy triangle")] + WhitePointOutsideChromaticityTriangle, + /// Target reference white must be within `1..=5000` nits. + #[error("target LED reference white must be within 1..=5000 nits")] + ReferenceWhiteOutOfRange, + /// Target peak must be within `1..=10000` nits. + #[error("target LED peak must be within 1..=10000 nits")] + PeakOutOfRange, + /// A tone-mapping target requires positive highlight headroom. + #[error("target LED peak must be strictly above reference white")] + PeakNotAboveReferenceWhite, + /// User exposure must be within `-8..=8` EV. + #[error("LED tone-map exposure must be within -8..=8 EV")] + ExposureOutOfRange, +} + +/// GPU-layout-compatible constants consumed by the CPU parity implementation. +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LedToneMapConstants { + /// Source-linear RGB to calibrated target-linear RGB matrix rows. + pub source_to_target: [[f32; 4]; 3], + /// Source linear luminance coefficients followed by exposure multiplier. + pub source_luminance_and_exposure: [f32; 4], + /// Target reference ratio, source headroom, source reference, target peak. + pub curve: [f32; 4], +} + +impl LedToneMapConstants { + /// Interpolate only the old and new curve coordinates with smoothstep. + #[must_use] + pub fn transition_from(mut self, previous: Self, linear_progress: f32) -> Self { + let progress = smoothstep(linear_progress.clamp(0.0, 1.0)); + self.curve[0] = lerp(previous.curve[0], self.curve[0], progress); + self.curve[1] = lerp(previous.curve[1], self.curve[1], progress); + self.curve[3] = lerp(previous.curve[3], self.curve[3], progress); + self + } +} + +/// Fully prepared per-sample color and tone-mapping contract. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreparedLedToneMap { + constants: LedToneMapConstants, + source_transfer: CaptureTransferFunction, + output_transfer: CaptureTransferFunction, +} + +impl PreparedLedToneMap { + /// Prepare exact CPU and GPU constants for one resolved color pipeline. + /// + /// # Errors + /// + /// Rejects unsupported transfer functions, contradictory range metadata, + /// or an HDR source without absolute luminance and positive headroom. + pub fn prepare( + source: KnownCaptureColorimetry, + output: KnownCaptureColorimetry, + calibration: LedToneMapCalibration, + ) -> Result { + calibration.validate()?; + validate_transfer(source.transfer_function(), source.dynamic_range(), true)?; + validate_transfer(output.transfer_function(), output.dynamic_range(), false)?; + + let source_matrix = color_space_matrix(source.color_space())?; + let output_matrix = color_space_matrix(output.color_space())?; + let source_to_target = if source.color_space() == output.color_space() + && calibration.has_nominal_d65_white() + { + IDENTITY + } else { + let device_matrix = chromatic_adaptation( + D65_X, + D65_Y, + calibration.target_white_x(), + calibration.target_white_y(), + ) + .multiply(output_matrix); + device_matrix + .inverse() + .ok_or(PreparedLedToneMapError::SingularWhitePointTransform)? + .multiply(source_matrix) + }; + let source_luminance = source_matrix.padded_rows()[1]; + let (target_reference_ratio, source_headroom, source_reference_nits) = + if source.dynamic_range() == CaptureDynamicRange::High { + let luminance = source + .luminance() + .ok_or(PreparedLedToneMapError::MissingSourceLuminance)?; + let reference = luminance.reference_white_nits().value(); + let peak = luminance.peak_nits().value(); + if peak <= reference { + return Err(PreparedLedToneMapError::SourcePeakNotAboveReferenceWhite); + } + ( + calibration.target_reference_white_nits() / calibration.target_peak_nits(), + peak / reference, + reference, + ) + } else { + (1.0, 1.0, 1.0) + }; + + Ok(Self { + constants: LedToneMapConstants { + source_to_target: source_to_target.padded_rows(), + source_luminance_and_exposure: [ + source_luminance[0], + source_luminance[1], + source_luminance[2], + 2.0_f32.powf(calibration.exposure_ev()), + ], + curve: [ + target_reference_ratio, + source_headroom, + source_reference_nits, + calibration.target_peak_nits(), + ], + }, + source_transfer: source.transfer_function(), + output_transfer: output.transfer_function(), + }) + } + + /// Shared constants suitable for direct upload to a GPU uniform buffer. + #[must_use] + pub const fn constants(self) -> LedToneMapConstants { + self.constants + } + + /// Replace the prepared curve with a smooth transition from an older curve. + #[must_use] + pub fn transition_from(mut self, previous: Self, linear_progress: f32) -> Self { + self.constants = self + .constants + .transition_from(previous.constants, linear_progress); + self + } + + /// Decode one source sample and apply the complete linear-light contract. + #[must_use] + pub fn decode_and_map(self, encoded: [u8; 4]) -> [f64; 4] { + let rgb = [ + self.decode_channel(encoded[0]), + self.decode_channel(encoded[1]), + self.decode_channel(encoded[2]), + ]; + let mapped = self.map_linear(rgb); + [ + f64::from(mapped[0]), + f64::from(mapped[1]), + f64::from(mapped[2]), + f64::from(encoded[3]) / 255.0, + ] + } + + /// Apply white point, exposure, gamut compression, and the prepared curve. + #[must_use] + pub fn map_linear(self, source_rgb: [f32; 3]) -> [f32; 3] { + let constants = self.constants; + let exposure = constants.source_luminance_and_exposure[3]; + let exposed = source_rgb.map(|channel| channel * exposure); + let source_luminance = + dot3(&constants.source_luminance_and_exposure[..3], exposed).max(0.0); + let mapped_luminance = map_luminance( + source_luminance, + constants.curve[0], + constants.curve[1], + constants.curve[3], + ); + let mut target = multiply_padded_rows(constants.source_to_target, exposed); + if source_luminance > f32::EPSILON { + let luminance_scale = mapped_luminance / source_luminance; + target = target.map(|channel| channel * luminance_scale); + } else { + target = [0.0; 3]; + } + let minimum = target.into_iter().fold(f32::INFINITY, f32::min); + let maximum = target.into_iter().fold(f32::NEG_INFINITY, f32::max); + if maximum - minimum <= 1.0e-5 { + target = [mapped_luminance; 3]; + } + compress_gamut(target, mapped_luminance) + } + + /// Encode one spatially accumulated target-linear sample. + #[must_use] + pub fn encode(self, linear: [f64; 4]) -> [u8; 4] { + let encode = |value: f64| match self.output_transfer { + CaptureTransferFunction::Srgb => linear_to_srgb_u8(value as f32), + CaptureTransferFunction::Linear => encode_byte(value as f32), + CaptureTransferFunction::Pq + | CaptureTransferFunction::Hlg + | CaptureTransferFunction::Unknown => { + unreachable!("prepared target transfer remains SDR") + } + }; + [ + encode(linear[0]), + encode(linear[1]), + encode(linear[2]), + encode_byte(linear[3] as f32), + ] + } + + fn decode_channel(self, encoded: u8) -> f32 { + let value = f32::from(encoded) / 255.0; + match self.source_transfer { + CaptureTransferFunction::Srgb => srgb_u8_to_linear(encoded), + CaptureTransferFunction::Linear => value, + CaptureTransferFunction::Pq => pq_to_nits(value) / self.constants.curve[2], + CaptureTransferFunction::Hlg | CaptureTransferFunction::Unknown => { + unreachable!("prepared source transfer remains executable") + } + } + } +} + +/// Stateful frame-boundary transition between prepared SDR and HDR curves. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LedToneMapCurveTransition { + from: PreparedLedToneMap, + target: PreparedLedToneMap, + current: PreparedLedToneMap, + started_at: Duration, + active: bool, +} + +impl LedToneMapCurveTransition { + /// Start with one fully active curve and no transition marker. + #[must_use] + pub const fn new(initial: PreparedLedToneMap) -> Self { + Self { + from: initial, + target: initial, + current: initial, + started_at: Duration::ZERO, + active: false, + } + } + + /// Begin a full 250 ms transition at one frame boundary. + /// + /// Retargeting an active transition begins from its current interpolated + /// curve and restarts the duration at the supplied frame timestamp. + pub fn transition_to(&mut self, target: PreparedLedToneMap, frame_timestamp: Duration) { + self.update(frame_timestamp); + if self.current == target { + self.from = target; + self.target = target; + self.active = false; + return; + } + self.from = self.current; + self.target = target; + self.started_at = frame_timestamp; + self.active = true; + } + + /// Resolve the curve and exact transition marker for one frame boundary. + #[must_use] + pub fn sample(&mut self, frame_timestamp: Duration) -> LedToneMapTransitionSample { + self.update(frame_timestamp); + LedToneMapTransitionSample { + prepared: self.current, + suppress_scene_cut_bypass: self.active, + } + } + + #[cfg(test)] + pub(super) const fn is_active(&self) -> bool { + self.active + } + + fn update(&mut self, frame_timestamp: Duration) { + if !self.active { + return; + } + let elapsed = frame_timestamp.saturating_sub(self.started_at); + if elapsed >= LED_TONE_MAP_TRANSITION_DURATION { + self.current = self.target; + self.active = false; + return; + } + let progress = elapsed.as_secs_f32() / LED_TONE_MAP_TRANSITION_DURATION.as_secs_f32(); + self.current = self.target.transition_from(self.from, progress); + } +} + +/// Prepared per-frame curve and its private smoothing-bypass suppression flag. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LedToneMapTransitionSample { + prepared: PreparedLedToneMap, + suppress_scene_cut_bypass: bool, +} + +impl LedToneMapTransitionSample { + /// Interpolated curve for this frame. + #[must_use] + pub const fn prepared(self) -> PreparedLedToneMap { + self.prepared + } + + /// Whether temporal smoothers must suppress their scene-cut bypass. + #[must_use] + pub const fn suppress_scene_cut_bypass(self) -> bool { + self.suppress_scene_cut_bypass + } +} + +/// Failure to prepare an executable tone-mapping contract. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum PreparedLedToneMapError { + /// The calibration failed its public validation contract. + #[error(transparent)] + InvalidCalibration(#[from] LedToneMapCalibrationError), + /// One required source or target color space is unknown. + #[error("LED tone mapping requires known source and target primaries")] + UnknownColorSpace, + /// One source transfer function is outside the executable CPU/GPU contract. + #[error("unsupported source transfer function for LED tone mapping: {0:?}")] + UnsupportedSourceTransfer(CaptureTransferFunction), + /// Output must use an SDR transfer function. + #[error("unsupported output transfer function for LED tone mapping: {0:?}")] + UnsupportedOutputTransfer(CaptureTransferFunction), + /// HDR source metadata omitted absolute luminance. + #[error("HDR LED tone mapping requires source luminance metadata")] + MissingSourceLuminance, + /// HDR source metadata must provide positive highlight headroom. + #[error("HDR source peak must be strictly above source reference white")] + SourcePeakNotAboveReferenceWhite, + /// The calibrated target basis could not be inverted. + #[error("target LED white-point transform is singular")] + SingularWhitePointTransform, +} + +fn validate_transfer( + transfer: CaptureTransferFunction, + dynamic_range: CaptureDynamicRange, + source: bool, +) -> Result<(), PreparedLedToneMapError> { + let valid = if source { + matches!( + (transfer, dynamic_range), + ( + CaptureTransferFunction::Srgb | CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard + ) | ( + CaptureTransferFunction::Pq | CaptureTransferFunction::Linear, + CaptureDynamicRange::High + ) + ) + } else { + matches!( + (transfer, dynamic_range), + ( + CaptureTransferFunction::Srgb | CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard + ) + ) + }; + if valid { + return Ok(()); + } + Err(if source { + PreparedLedToneMapError::UnsupportedSourceTransfer(transfer) + } else { + PreparedLedToneMapError::UnsupportedOutputTransfer(transfer) + }) +} + +fn color_space_matrix(color_space: CaptureColorSpace) -> Result { + match color_space { + CaptureColorSpace::Srgb => Ok(SRGB_TO_XYZ), + CaptureColorSpace::DisplayP3 => Ok(DISPLAY_P3_TO_XYZ), + CaptureColorSpace::Rec2020 => Ok(REC2020_TO_XYZ), + CaptureColorSpace::Unknown => Err(PreparedLedToneMapError::UnknownColorSpace), + } +} + +fn map_luminance( + value: f32, + reference_ratio: f32, + source_headroom: f32, + target_peak_nits: f32, +) -> f32 { + if source_headroom <= 1.0 { + return value.min(1.0) * reference_ratio; + } + let target_reference_nits = reference_ratio * target_peak_nits; + let source_peak_nits = target_reference_nits * source_headroom; + if source_peak_nits <= target_peak_nits { + return (value * reference_ratio).clamp(0.0, 1.0); + } + + let source_peak_pq = nits_to_pq(source_peak_nits); + let maximum_luminance = nits_to_pq(target_peak_nits) / source_peak_pq; + let input_pq = nits_to_pq(value * target_reference_nits) / source_peak_pq; + let knee_start = 1.5 * maximum_luminance - 0.5; + if input_pq < knee_start { + return (value * reference_ratio).clamp(0.0, 1.0); + } + let t = ((input_pq - knee_start) / (1.0 - knee_start)).clamp(0.0, 1.0); + let t_squared = t * t; + let t_cubed = t_squared * t; + let output_pq = (2.0 * t_cubed - 3.0 * t_squared + 1.0) * knee_start + + (t_cubed - 2.0 * t_squared + t) * (1.0 - knee_start) + + (-2.0 * t_cubed + 3.0 * t_squared) * maximum_luminance; + (pq_to_nits(output_pq * source_peak_pq) / target_peak_nits).clamp(0.0, 1.0) +} + +fn compress_gamut(rgb: [f32; 3], luminance: f32) -> [f32; 3] { + let neutral = luminance.clamp(0.0, 1.0); + let mut scale = 1.0_f32; + for channel in rgb { + let chroma = channel - neutral; + if channel < 0.0 { + scale = scale.min(neutral / -chroma); + } else if channel > 1.0 { + scale = scale.min((1.0 - neutral) / chroma); + } + } + rgb.map(|channel| (neutral + (channel - neutral) * scale).clamp(0.0, 1.0)) +} + +fn chromatic_adaptation(source_x: f32, source_y: f32, target_x: f32, target_y: f32) -> Matrix3 { + let source_white = xyz_from_xy(source_x, source_y); + let target_white = xyz_from_xy(target_x, target_y); + let source_cone = BRADFORD.multiply_vector(source_white); + let target_cone = BRADFORD.multiply_vector(target_white); + let scale = Matrix3([ + [target_cone[0] / source_cone[0], 0.0, 0.0], + [0.0, target_cone[1] / source_cone[1], 0.0], + [0.0, 0.0, target_cone[2] / source_cone[2]], + ]); + BRADFORD_INVERSE.multiply(scale).multiply(BRADFORD) +} + +fn xyz_from_xy(x: f32, y: f32) -> [f64; 3] { + let x = f64::from(x); + let y = f64::from(y); + [x / y, 1.0, (1.0 - x - y) / y] +} + +fn pq_to_nits(encoded: f32) -> f32 { + const M1: f32 = 2_610.0 / 16_384.0; + const M2: f32 = 2_523.0 / 32.0; + const C1: f32 = 3_424.0 / 4_096.0; + const C2: f32 = 2_413.0 / 128.0; + const C3: f32 = 2_392.0 / 128.0; + + let power = encoded.clamp(0.0, 1.0).powf(1.0 / M2); + let numerator = (power - C1).max(0.0); + let denominator = C2 - C3 * power; + 10_000.0 * (numerator / denominator).powf(1.0 / M1) +} + +fn nits_to_pq(nits: f32) -> f32 { + const M1: f32 = 2_610.0 / 16_384.0; + const M2: f32 = 2_523.0 / 32.0; + const C1: f32 = 3_424.0 / 4_096.0; + const C2: f32 = 2_413.0 / 128.0; + const C3: f32 = 2_392.0 / 128.0; + + let power = (nits.max(0.0) / 10_000.0).powf(M1); + ((C1 + C2 * power) / (1.0 + C3 * power)).powf(M2) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn encode_byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn dot3(coefficients: &[f32], value: [f32; 3]) -> f32 { + coefficients[0] * value[0] + coefficients[1] * value[1] + coefficients[2] * value[2] +} + +fn multiply_padded_rows(rows: [[f32; 4]; 3], value: [f32; 3]) -> [f32; 3] { + [ + dot3(&rows[0][..3], value), + dot3(&rows[1][..3], value), + dot3(&rows[2][..3], value), + ] +} + +fn smoothstep(value: f32) -> f32 { + value * value * (3.0 - 2.0 * value) +} + +fn lerp(from: f32, to: f32, progress: f32) -> f32 { + from + (to - from) * progress +} + +#[derive(Clone, Copy)] +struct Matrix3([[f64; 3]; 3]); + +impl Matrix3 { + fn multiply(self, right: Self) -> Self { + let mut output = [[0.0; 3]; 3]; + for (row_index, row) in output.iter_mut().enumerate() { + for (column_index, value) in row.iter_mut().enumerate() { + *value = (0..3) + .map(|index| self.0[row_index][index] * right.0[index][column_index]) + .sum(); + } + } + Self(output) + } + + fn multiply_vector(self, value: [f64; 3]) -> [f64; 3] { + self.0 + .map(|row| row[0] * value[0] + row[1] * value[1] + row[2] * value[2]) + } + + fn inverse(self) -> Option { + let matrix = self.0; + let determinant = matrix[0][0] + * (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1]) + - matrix[0][1] * (matrix[1][0] * matrix[2][2] - matrix[1][2] * matrix[2][0]) + + matrix[0][2] * (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0]); + if determinant.abs() <= f64::EPSILON { + return None; + } + let inverse = 1.0 / determinant; + Some(Self([ + [ + (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1]) * inverse, + (matrix[0][2] * matrix[2][1] - matrix[0][1] * matrix[2][2]) * inverse, + (matrix[0][1] * matrix[1][2] - matrix[0][2] * matrix[1][1]) * inverse, + ], + [ + (matrix[1][2] * matrix[2][0] - matrix[1][0] * matrix[2][2]) * inverse, + (matrix[0][0] * matrix[2][2] - matrix[0][2] * matrix[2][0]) * inverse, + (matrix[0][2] * matrix[1][0] - matrix[0][0] * matrix[1][2]) * inverse, + ], + [ + (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0]) * inverse, + (matrix[0][1] * matrix[2][0] - matrix[0][0] * matrix[2][1]) * inverse, + (matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]) * inverse, + ], + ])) + } + + #[allow(clippy::cast_possible_truncation)] + fn padded_rows(self) -> [[f32; 4]; 3] { + self.0 + .map(|row| [row[0] as f32, row[1] as f32, row[2] as f32, 0.0]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn luminance(reference: f32, peak: f32) -> CaptureLuminanceContext { + CaptureLuminanceContext::new( + CapturePositiveScalar::try_new(reference).expect("reference is valid"), + CapturePositiveScalar::try_new(peak).expect("peak is valid"), + ) + .expect("luminance is ordered") + } + + fn hdr_source() -> KnownCaptureColorimetry { + KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Pq, + CaptureDynamicRange::High, + Some(luminance(203.0, 1_000.0)), + ) + .expect("HDR source is valid") + } + + fn linear_hdr_source() -> KnownCaptureColorimetry { + KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Linear, + CaptureDynamicRange::High, + Some(luminance(203.0, 1_000.0)), + ) + .expect("extended-linear HDR source is valid") + } + + #[test] + fn golden_reference_white_and_highlight_shoulder() { + let sdr = PreparedLedToneMap::prepare( + KnownCaptureColorimetry::SRGB, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("SDR curve prepares"); + assert_eq!(sdr.map_linear([1.0; 3]), [1.0; 3]); + + let hdr = PreparedLedToneMap::prepare( + hdr_source(), + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("HDR curve prepares"); + assert_eq!(hdr.map_linear([1.0; 3]), [0.5; 3]); + for (input_nits, expected) in [ + (300.0, 0.726_557_2), + (406.0, 0.873_631_95), + (600.0, 0.975_499_9), + (1_000.0, 1.0), + ] { + let actual = hdr.map_linear([input_nits / 203.0; 3])[0]; + assert!((actual - expected).abs() < 2.0e-5, "{input_nits} nits"); + } + let mut previous = 0.5; + for index in 1..=64 { + let input = 1.0 + (1_000.0 / 203.0 - 1.0) * index as f32 / 64.0; + let output = hdr.map_linear([input; 3])[0]; + assert!(output >= previous); + assert!(output <= 1.0); + previous = output; + } + assert!((previous - 1.0).abs() < 1.0e-6); + } + + #[test] + fn extended_linear_hdr_uses_the_same_reference_white_curve() { + let prepared = PreparedLedToneMap::prepare( + linear_hdr_source(), + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("extended-linear HDR curve prepares"); + for (source_relative, expected) in [ + (1.0, 0.5), + (300.0 / 203.0, 0.726_557_2), + (406.0 / 203.0, 0.873_631_95), + (600.0 / 203.0, 0.975_499_9), + (1_000.0 / 203.0, 1.0), + ] { + let actual = prepared.map_linear([source_relative; 3]); + assert!((actual[0] - expected).abs() < 2.0e-5); + assert_eq!(actual[0], actual[1]); + assert_eq!(actual[1], actual[2]); + } + assert_eq!(prepared.decode_and_map([255; 4]), [0.5, 0.5, 0.5, 1.0]); + } + + #[test] + fn measured_white_and_wide_gamut_remain_finite_and_bounded() { + let measured = LedToneMapCalibration::try_new(0.3457, 0.3585, 203.0, 406.0, 0.0) + .expect("measured calibration is valid"); + let p3 = KnownCaptureColorimetry::try_new( + CaptureColorSpace::DisplayP3, + CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard, + None, + ) + .expect("P3 source is valid"); + let prepared = PreparedLedToneMap::prepare(p3, KnownCaptureColorimetry::SRGB, measured) + .expect("measured curve prepares"); + let mapped = prepared.map_linear([1.0, 0.0, 1.0]); + for (actual, expected) in mapped.into_iter().zip([0.811_240_8, 0.093_663_424, 1.0]) { + assert!((actual - expected).abs() < 1.0e-6); + } + assert!(mapped.iter().all(|channel| channel.is_finite())); + assert!(mapped.iter().all(|channel| (0.0..=1.0).contains(channel))); + assert_ne!( + prepared.constants().source_to_target, + PreparedLedToneMap::prepare( + p3, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("D65 curve prepares") + .constants() + .source_to_target + ); + } + + #[test] + fn exposure_is_applied_in_linear_light() { + let calibration = LedToneMapCalibration::try_new(D65_X, D65_Y, 203.0, 406.0, -1.0) + .expect("negative exposure is valid"); + let prepared = PreparedLedToneMap::prepare( + KnownCaptureColorimetry::SRGB, + KnownCaptureColorimetry::SRGB, + calibration, + ) + .expect("exposed SDR curve prepares"); + assert_eq!(prepared.map_linear([1.0; 3]), [0.5; 3]); + assert_eq!(std::mem::size_of::(), 80); + assert_eq!(std::mem::align_of::(), 16); + } + + #[test] + fn transition_uses_the_contract_duration_and_monotonic_smoothstep() { + assert_eq!(LED_TONE_MAP_TRANSITION_DURATION, Duration::from_millis(250)); + let sdr = PreparedLedToneMap::prepare( + KnownCaptureColorimetry::SRGB, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("SDR curve prepares"); + let hdr = PreparedLedToneMap::prepare( + hdr_source(), + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("HDR curve prepares"); + assert_eq!(hdr.transition_from(sdr, 0.0).constants().curve[0], 1.0); + assert_eq!(hdr.transition_from(sdr, 1.0).constants().curve[0], 0.5); + assert_eq!(hdr.transition_from(sdr, 0.5).constants().curve[0], 0.75); + + let mut transition = LedToneMapCurveTransition::new(sdr); + transition.transition_to(hdr, Duration::ZERO); + let midpoint = transition.sample(Duration::from_millis(125)); + assert_eq!(midpoint.prepared().constants().curve[0], 0.75); + assert!(midpoint.suppress_scene_cut_bypass()); + + transition.transition_to(sdr, Duration::from_millis(125)); + let restarted_midpoint = transition.sample(Duration::from_millis(250)); + assert_eq!(restarted_midpoint.prepared().constants().curve[0], 0.875); + assert!(restarted_midpoint.suppress_scene_cut_bypass()); + let completed = transition.sample(Duration::from_millis(375)); + assert_eq!(completed.prepared(), sdr); + assert!(!completed.suppress_scene_cut_bypass()); + } +} diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index ff3e92429..e4f636687 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -871,6 +871,26 @@ pub trait InputSource: Send { Ok(()) } + /// Update screen processing without replacing the native capture session. + /// + /// Sources without a split processing contract retain the full + /// reconfiguration behavior. + fn reconfigure_screen_processing( + &mut self, + config: &crate::input::screen::CaptureConfig, + ) -> anyhow::Result<()> { + self.reconfigure_screen_capture(config) + } + + /// Mirror the active macOS daemon topology into source status. + fn set_macos_daemon_ownership( + &mut self, + _owner: crate::input::MacosCapabilityOwner, + _conflict: Option, + ) -> anyhow::Result<()> { + Ok(()) + } + /// Discard any persisted source selection and prompt the user to pick again. /// /// # Errors diff --git a/crates/hypercolor-core/tests/capture_color_contract_tests.rs b/crates/hypercolor-core/tests/capture_color_contract_tests.rs index cdd60722a..2dc83cbfa 100644 --- a/crates/hypercolor-core/tests/capture_color_contract_tests.rs +++ b/crates/hypercolor-core/tests/capture_color_contract_tests.rs @@ -7,16 +7,17 @@ use hypercolor_core::input::screen::{ CaptureColorSpace, CaptureColorimetry, CaptureColorimetryError, CaptureDynamicRange, CaptureEpoch, CaptureGeometry, CaptureLuminanceContext, CapturePixelFormat, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureTransferFunction, - KnownCaptureColorimetry, PhysicalOrigin, PixelExtent, PixelRect, RegisteredScreenBranchDemand, - ResolvedScreenColorTransform, ResolvedScreenSource, ResolvedScreenSourceConfig, - ScreenAspectPolicy, ScreenBackendResourceIdentity, ScreenCaptureBackend, - ScreenColorTransformCapabilities, ScreenColorTuning, ScreenCursorCapabilities, - ScreenExtentRequest, ScreenHdrPolicy, ScreenProcessingProfile, ScreenProcessingProfileConfig, - ScreenPublicationError, ScreenPublicationExecutorRequest, ScreenPublicationKind, - ScreenPublicationRequest, ScreenReductionFilter, ScreenResourceApi, ScreenSceneCutPolicy, - ScreenSmoothingPolicy, ScreenSourceReflection, ScreenSourceSelector, ScreenTargetColorimetry, - ScreenToneMapOperator, ScreenToneMapPolicy, ScreenUnknownColorPolicy, ScreenUpscalePolicy, - SourceScale, + KnownCaptureColorimetry, LED_TONE_MAP_ALGORITHM_REVISION, LedToneMapCalibration, + LedToneMapCalibrationError, PhysicalOrigin, PixelExtent, PixelRect, + RegisteredScreenBranchDemand, ResolvedScreenColorTransform, ResolvedScreenSource, + ResolvedScreenSourceConfig, ScreenAspectPolicy, ScreenBackendResourceIdentity, + ScreenCaptureBackend, ScreenColorTransformCapabilities, ScreenColorTuning, + ScreenCursorCapabilities, ScreenExtentRequest, ScreenHdrPolicy, ScreenProcessingProfile, + ScreenProcessingProfileConfig, ScreenPublicationError, ScreenPublicationExecutorRequest, + ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, ScreenResourceApi, + ScreenSceneCutPolicy, ScreenSmoothingPolicy, ScreenSourceReflection, ScreenSourceSelector, + ScreenTargetColorimetry, ScreenToneMapOperator, ScreenToneMapPolicy, ScreenUnknownColorPolicy, + ScreenUpscalePolicy, SourceScale, }; fn extent(width: u32, height: u32) -> PixelExtent { @@ -32,6 +33,109 @@ fn luminance(reference_white: f32, peak: f32) -> CaptureLuminanceContext { .expect("test luminance is ordered") } +#[test] +fn led_target_calibration_rejects_invalid_values_without_clamping() { + let error = |values: [f32; 5]| { + LedToneMapCalibration::try_new(values[0], values[1], values[2], values[3], values[4]) + }; + for values in [ + [0.0, 0.329, 203.0, 406.0, 0.0], + [-0.0, 0.329, 203.0, 406.0, 0.0], + [0.3127, 0.0, 203.0, 406.0, 0.0], + [0.3127, -0.0, 203.0, 406.0, 0.0], + [0.7, 0.3, 203.0, 406.0, 0.0], + [0.8, 0.3, 203.0, 406.0, 0.0], + ] { + assert_eq!( + error(values), + Err(LedToneMapCalibrationError::WhitePointOutsideChromaticityTriangle) + ); + } + for values in [ + [f32::NAN, 0.329, 203.0, 406.0, 0.0], + [0.3127, f32::INFINITY, 203.0, 406.0, 0.0], + [0.3127, 0.329, f32::NEG_INFINITY, 406.0, 0.0], + [0.3127, 0.329, 203.0, f32::NAN, 0.0], + [0.3127, 0.329, 203.0, 406.0, f32::INFINITY], + ] { + assert_eq!( + error(values), + Err(LedToneMapCalibrationError::NonFiniteScalar) + ); + } + assert_eq!( + error([0.3127, 0.329, 0.99, 406.0, 0.0]), + Err(LedToneMapCalibrationError::ReferenceWhiteOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, -0.0, 406.0, 0.0]), + Err(LedToneMapCalibrationError::ReferenceWhiteOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, 5_000.1, 10_000.0, 0.0]), + Err(LedToneMapCalibrationError::ReferenceWhiteOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, 1.0, 0.99, 0.0]), + Err(LedToneMapCalibrationError::PeakOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, 1.0, -0.0, 0.0]), + Err(LedToneMapCalibrationError::PeakOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, 203.0, 10_000.1, 0.0]), + Err(LedToneMapCalibrationError::PeakOutOfRange) + ); + for values in [ + [0.3127, 0.329, 203.0, 203.0, 0.0], + [0.3127, 0.329, 204.0, 203.0, 0.0], + [0.3127, 0.329, 1.0, 1.0, 0.0], + ] { + assert_eq!( + error(values), + Err(LedToneMapCalibrationError::PeakNotAboveReferenceWhite) + ); + } + assert_eq!( + error([0.3127, 0.329, 203.0, 406.0, -8.01]), + Err(LedToneMapCalibrationError::ExposureOutOfRange) + ); + assert_eq!( + error([0.3127, 0.329, 203.0, 406.0, 8.01]), + Err(LedToneMapCalibrationError::ExposureOutOfRange) + ); + for values in [ + [0.3127, 0.329, 1.0, 10_000.0, -8.0], + [0.3127, 0.329, 5_000.0, 10_000.0, 8.0], + ] { + assert!(error(values).is_ok()); + } + assert_eq!( + error([0.3127, 0.329, 203.0, 406.0, -0.0]), + error([0.3127, 0.329, 203.0, 406.0, 0.0]) + ); +} + +#[test] +fn replacing_led_calibration_refreshes_an_existing_hdr_policy() { + let calibration = LedToneMapCalibration::try_new(0.3457, 0.3585, 160.0, 480.0, 1.0) + .expect("measured target calibration is valid"); + let profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::new( + ScreenToneMapOperator::Bt2390Eetf, + luminance(100.0, 100.0), + )), + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration); + let ScreenHdrPolicy::ToneMap(policy) = profile.hdr() else { + panic!("HDR tone-map policy must remain enabled"); + }; + assert_eq!(policy.target_luminance(), calibration.target_luminance()); + assert_eq!(policy.operator(), ScreenToneMapOperator::Bt2390Eetf); +} + fn known_sdr( color_space: CaptureColorSpace, transfer_function: CaptureTransferFunction, @@ -128,6 +232,14 @@ fn request( kind: ScreenPublicationKind, extent: ScreenExtentRequest, config: ScreenProcessingProfileConfig, +) -> ScreenPublicationRequest { + request_with_profile(kind, extent, ScreenProcessingProfile::new(config)) +} + +fn request_with_profile( + kind: ScreenPublicationKind, + extent: ScreenExtentRequest, + profile: ScreenProcessingProfile, ) -> ScreenPublicationRequest { ScreenPublicationRequest::new( ScreenSourceSelector::Configured, @@ -135,7 +247,7 @@ fn request( ScreenPublicationExecutorRequest::Cpu, extent, ScreenAspectPolicy::Contain, - Arc::new(ScreenProcessingProfile::new(config)), + Arc::new(profile), ) } @@ -147,6 +259,17 @@ fn native_surface(config: ScreenProcessingProfileConfig) -> ScreenPublicationReq ) } +fn calibrated_native_surface( + config: ScreenProcessingProfileConfig, + calibration: LedToneMapCalibration, +) -> ScreenPublicationRequest { + request_with_profile( + ScreenPublicationKind::Surface, + ScreenExtentRequest::Native, + ScreenProcessingProfile::new(config).with_led_tone_map(calibration), + ) +} + #[test] fn positive_scalars_and_luminance_reject_non_physical_values() { assert_eq!(CaptureColorSpace::default(), CaptureColorSpace::Unknown); @@ -545,7 +668,8 @@ fn encoded_byte_identity_rejects_noncanonical_source_storage() { #[test] fn hdr_tone_mapping_remains_unresolved_without_reducer_capabilities() { let source_luminance = luminance(203.0, 1_000.0); - let target_luminance = luminance(100.0, 100.0); + let calibration = LedToneMapCalibration::DEFAULT; + let target_luminance = calibration.target_luminance(); let known_hdr = known_hdr(CaptureTransferFunction::Pq, source_luminance); let hdr_source = source(CaptureColorimetry::from_known(known_hdr)); @@ -556,9 +680,9 @@ fn hdr_tone_mapping_remains_unresolved_without_reducer_capabilities() { assert_eq!( native_surface(ScreenProcessingProfileConfig { - hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::new( + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( ScreenToneMapOperator::Bt2390Eetf, - target_luminance, + calibration, )), ..ScreenProcessingProfileConfig::default() }) @@ -567,9 +691,9 @@ fn hdr_tone_mapping_remains_unresolved_without_reducer_capabilities() { ); let descriptor = native_surface(ScreenProcessingProfileConfig { - hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::new( + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( ScreenToneMapOperator::Bt2390Eetf, - target_luminance, + calibration, )), ..ScreenProcessingProfileConfig::default() }) @@ -608,6 +732,77 @@ fn hdr_tone_mapping_remains_unresolved_without_reducer_capabilities() { ); } +#[test] +fn extended_linear_hdr_resolves_the_reference_white_bt2390_contract() { + let source_luminance = luminance(203.0, 1_000.0); + let calibration = LedToneMapCalibration::DEFAULT; + let source_color = known_hdr(CaptureTransferFunction::Linear, source_luminance); + let descriptor = native_surface(ScreenProcessingProfileConfig { + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )), + ..ScreenProcessingProfileConfig::default() + }) + .resolve_with_color_capabilities( + &source(CaptureColorimetry::from_known(source_color)), + ScreenColorTransformCapabilities::new(false, false, true, LED_TONE_MAP_ALGORITHM_REVISION), + ) + .expect("extended-linear HDR resolves through the shared BT.2390 contract"); + let ResolvedScreenColorTransform::ToneMap(resolved) = + descriptor.physical().color_pipeline().transform() + else { + panic!("extended-linear HDR resolves a tone-map transform"); + }; + assert_eq!(resolved.source_luminance(), source_luminance); + assert_eq!(resolved.calibration(), calibration); +} + +#[test] +fn hdr_tone_mapping_requires_positive_source_headroom() { + let calibration = LedToneMapCalibration::DEFAULT; + let request = native_surface(ScreenProcessingProfileConfig { + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )), + ..ScreenProcessingProfileConfig::default() + }); + let capabilities = + ScreenColorTransformCapabilities::new(false, false, true, LED_TONE_MAP_ALGORITHM_REVISION); + + for transfer in [CaptureTransferFunction::Pq, CaptureTransferFunction::Linear] { + let unity = known_hdr(transfer, luminance(203.0, 203.0)); + assert_eq!( + request.resolve_with_color_capabilities( + &source(CaptureColorimetry::from_known(unity)), + capabilities, + ), + Err(ScreenPublicationError::UnsupportedHdrConversion) + ); + + let positive = known_hdr(transfer, luminance(203.0, 203.0001)); + assert!( + request + .resolve_with_color_capabilities( + &source(CaptureColorimetry::from_known(positive)), + capabilities, + ) + .is_ok() + ); + } + + let sdr = known_sdr(CaptureColorSpace::Srgb, CaptureTransferFunction::Srgb) + .with_luminance(luminance(203.0, 203.0)); + assert!( + native_surface(ScreenProcessingProfileConfig::exact_encoded_identity( + CapturePixelFormat::Rgba8, + )) + .resolve(&source(CaptureColorimetry::from_known(sdr))) + .is_ok() + ); +} + #[test] fn hdr_passthrough_and_sdr_to_hdr_conversion_remain_unavailable() { let hdr = known_hdr(CaptureTransferFunction::Hlg, luminance(203.0, 1_000.0)); @@ -698,6 +893,78 @@ fn color_policy_and_resolved_parameters_participate_in_identity_and_ordering() { ); } +#[test] +fn led_calibration_and_revision_are_stable_physical_cache_identity() { + let d65 = LedToneMapCalibration::DEFAULT; + let capabilities = + ScreenColorTransformCapabilities::new(true, true, true, LED_TONE_MAP_ALGORITHM_REVISION); + let resolve = |calibration| { + calibrated_native_surface( + ScreenProcessingProfileConfig { + algorithm_revision: LED_TONE_MAP_ALGORITHM_REVISION, + ..ScreenProcessingProfileConfig::default() + }, + calibration, + ) + .resolve_with_color_capabilities(&source(CaptureColorimetry::SRGB), capabilities) + .expect("managed SDR profile resolves") + }; + + let first = resolve(d65); + let repeated = resolve(d65); + assert_eq!(first.physical(), repeated.physical()); + for calibration in [ + LedToneMapCalibration::try_new(0.3128, 0.329, 203.0, 406.0, 0.0), + LedToneMapCalibration::try_new(0.3127, 0.3291, 203.0, 406.0, 0.0), + LedToneMapCalibration::try_new(0.3127, 0.329, 202.0, 406.0, 0.0), + LedToneMapCalibration::try_new(0.3127, 0.329, 203.0, 407.0, 0.0), + LedToneMapCalibration::try_new(0.3127, 0.329, 203.0, 406.0, 1.0), + ] { + let calibration = calibration.expect("alternate calibration is valid"); + assert_ne!(first.physical(), resolve(calibration).physical()); + } + assert_eq!(first.physical().color_pipeline().calibration(), Some(d65)); + assert_eq!( + first.physical().algorithm_revision(), + LED_TONE_MAP_ALGORITHM_REVISION + ); +} + +#[test] +fn resolved_hdr_tone_map_carries_the_validated_target_calibration() { + let calibration = LedToneMapCalibration::try_new(0.3457, 0.3585, 160.0, 480.0, -1.0) + .expect("measured target calibration is valid"); + let hdr = known_hdr(CaptureTransferFunction::Pq, luminance(203.0, 1_000.0)); + let descriptor = calibrated_native_surface( + ScreenProcessingProfileConfig { + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )), + algorithm_revision: LED_TONE_MAP_ALGORITHM_REVISION, + ..ScreenProcessingProfileConfig::default() + }, + calibration, + ) + .resolve_with_color_capabilities( + &source(CaptureColorimetry::from_known(hdr)), + ScreenColorTransformCapabilities::new(true, true, true, LED_TONE_MAP_ALGORITHM_REVISION), + ) + .expect("PQ HDR pipeline resolves"); + + let ResolvedScreenColorTransform::ToneMap(tone_map) = + descriptor.physical().color_pipeline().transform() + else { + panic!("resolved transform must be HDR tone mapping"); + }; + assert_eq!(tone_map.calibration(), calibration); + assert_eq!(tone_map.target_luminance(), calibration.target_luminance()); + assert_eq!( + descriptor.physical().color_pipeline().calibration(), + Some(calibration) + ); +} + #[test] fn byte_changing_color_paths_fail_closed_without_reducer_capabilities() { let p3 = known_sdr(CaptureColorSpace::DisplayP3, CaptureTransferFunction::Srgb); diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs index 5f940a524..b2c585d59 100644 --- a/crates/hypercolor-core/tests/macos_host_input_tests.rs +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -333,8 +333,9 @@ fn state_gap_synthesizes_releases_and_stale_epoch_is_inert() { #[cfg(feature = "macos-native-fixtures")] mod fixtures { use hypercolor_core::input::{ - InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, MacosHostInput, - MacosInputFixtureBackend, MacosProtectedSourceState, SourcePlatformStatus, SourceState, + InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosHostInput, MacosInputFixtureBackend, + MacosProtectedSourceState, SourcePlatformStatus, SourceState, }; use hypercolor_macos_input::{MacosInputEvent, event_masks}; @@ -489,7 +490,14 @@ mod fixtures { .expect("macOS host source exposes status"); source - .set_capability_owner(MacosCapabilityOwner::AppSidecar) + .set_macos_daemon_ownership( + MacosCapabilityOwner::AppSidecar, + Some(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }), + ) .expect("owner update should publish"); let snapshot = status.snapshot(); @@ -498,6 +506,14 @@ mod fixtures { }; assert_eq!(platform.keyboard_owner, MacosCapabilityOwner::AppSidecar); assert_eq!(platform.pointer_owner, MacosCapabilityOwner::AppSidecar); + assert_eq!( + platform.owner_conflict.as_deref(), + Some(&MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }) + ); } #[test] diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index 52a6d4068..f34789782 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -10,8 +10,8 @@ use hypercolor_core::input::screen::{ }; use hypercolor_core::input::{ InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, - MacosProtectedSourceState as CoreProtectedSourceState, MacosSelectionState, - SourcePlatformStatus, + MacosDaemonOwnerConflict, MacosProtectedSourceState as CoreProtectedSourceState, + MacosSelectionState, SourcePlatformStatus, }; use hypercolor_macos_capture::{ MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, @@ -130,7 +130,14 @@ fn fixture_capture_activates_only_for_live_demand() { MacosProtectedSourceState::ReadyIdle ); source - .set_capability_owner(MacosCapabilityOwner::AppSidecar) + .set_macos_daemon_ownership( + MacosCapabilityOwner::AppSidecar, + Some(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }), + ) .expect("fixture owner status updates"); source .source_status_reporter() @@ -146,6 +153,14 @@ fn fixture_capture_activates_only_for_live_demand() { assert_eq!(platform.state, CoreProtectedSourceState::ReadyIdle); assert_eq!(platform.tcc, MacosAuthorizationState::Authorized); assert_eq!(platform.owner, MacosCapabilityOwner::AppSidecar); + assert_eq!( + platform.owner_conflict.as_deref(), + Some(&MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }) + ); assert_eq!(platform.selection, MacosSelectionState::None); assert!(!fixture.is_active()); source.start().expect("fixture source starts idle"); diff --git a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs index 2703e04cb..371c663c5 100644 --- a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs @@ -451,6 +451,7 @@ fn surface_letterbox_fill_modes_preserve_content_and_alpha() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("Surface fill stages"); @@ -543,6 +544,7 @@ fn surface_fill_is_exact_after_tuning_for_rgba_and_bgra() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("processed Surface stages"); @@ -607,6 +609,7 @@ fn stateful_surface_and_zones_smooth_before_non_neutral_tuning() { &surface.physical, &encoded_pixel([0, 0, 0, 255], pixel_format), started, + false, &mut surface_baseline, ) .expect("Surface baseline stages"); @@ -621,6 +624,7 @@ fn stateful_surface_and_zones_smooth_before_non_neutral_tuning() { &surface.physical, &encoded_pixel([incoming[0], incoming[1], incoming[2], 255], pixel_format), started + elapsed, + false, &mut surface_next, ) .expect("Surface response stages"); @@ -642,6 +646,7 @@ fn stateful_surface_and_zones_smooth_before_non_neutral_tuning() { &zones.physical, &encoded_pixel([0, 0, 0, 255], pixel_format), started, + false, &mut zone_baseline, ) .expect("Zones baseline stages"); @@ -656,6 +661,7 @@ fn stateful_surface_and_zones_smooth_before_non_neutral_tuning() { &zones.physical, &encoded_pixel([incoming[0], incoming[1], incoming[2], 255], pixel_format), started + elapsed, + false, &mut zone_next, ) .expect("Zones response stages"); @@ -700,6 +706,7 @@ fn detected_bars_reflow_without_stretching_content_aspect() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("detected content stages"); @@ -757,6 +764,7 @@ fn surface_materializer_rejects_substituted_physical_storage_transactionally() { &fixture.physical, &[0; 4], now, + false, &mut publication, ), Err(CpuSurfaceMaterializationError::PhysicalByteLengthMismatch { @@ -800,6 +808,7 @@ fn rejected_moving_bars_preserve_committed_surface_history() { &fixture.physical, &horizontal, start, + false, &mut first, ) .expect("first bar state stages"); @@ -821,6 +830,7 @@ fn rejected_moving_bars_preserve_committed_surface_history() { &fixture.physical, &vertical, start + Duration::from_millis(16), + false, &mut rejected, ) .expect("moving bars stage"); @@ -839,6 +849,7 @@ fn rejected_moving_bars_preserve_committed_surface_history() { &fixture.physical, &restored, start + Duration::from_millis(32), + false, &mut third, ) .expect("restored bars stage from committed history"); @@ -892,6 +903,7 @@ fn dynamic_crop_compacts_the_effective_grid_and_reuses_exact_scratch() { &fixture.physical, &pixels, now, + false, &mut publication, ) .expect("dynamic grid stages"); @@ -967,6 +979,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[0, 0, 0, 255], started, + false, &mut initial, ) .expect("initial frame stages"); @@ -983,6 +996,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[255, 255, 255, 255], next_at, + false, &mut rejected, ) .expect("candidate frame stages"); @@ -1001,6 +1015,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[255, 255, 255, 255], next_at, + false, &mut retry, ) .expect("retry frame stages"); @@ -1054,6 +1069,7 @@ fn content_region_change_resets_smoothing_even_when_shape_is_unchanged() { &fixture.physical, &top_bar, started, + false, &mut first, ) .expect("top-bar frame stages"); @@ -1084,6 +1100,7 @@ fn content_region_change_resets_smoothing_even_when_shape_is_unchanged() { &fixture.physical, &bottom_bar, started + Duration::from_millis(16), + false, &mut second, ) .expect("bottom-bar frame stages"); @@ -1123,6 +1140,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[255, 0, 0, 255], now, + false, &mut publication, ), Err(CpuZoneMaterializationError::PlanGenerationMismatch { .. }) @@ -1135,6 +1153,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[0, 0, 0, 255], now, + false, &mut baseline, ) .expect("baseline stages"); @@ -1150,6 +1169,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[255, 255, 255, 255], later, + false, &mut smoothed, ) .expect("pre-reset frame stages"); @@ -1173,6 +1193,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[255, 255, 255, 255], later, + false, &mut reset, ) .expect("post-reset frame stages"); @@ -1245,6 +1266,7 @@ fn stateful_materialization_supports_rgba_bgra_srgb_and_linear() { &fixture.physical, &pixels, now, + false, &mut publication, ) .expect("stateful transfer stages"); @@ -1301,6 +1323,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &rgba.physical, &[0, 0, 0, 255], started, + false, &mut rgba_initial, ) .expect("RGBA baseline stages"); @@ -1315,6 +1338,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &bgra.physical, &[255, 255, 255, 255], started, + false, &mut bgra_initial, ) .expect("BGRA baseline stages"); @@ -1331,6 +1355,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &rgba.physical, &[128, 128, 128, 255], later, + false, &mut rgba_next, ) .expect("RGBA next frame stages"); @@ -1344,6 +1369,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &bgra.physical, &[128, 128, 128, 255], later, + false, &mut bgra_next, ) .expect("BGRA next frame stages"); @@ -1481,6 +1507,95 @@ fn prepared_smoothing_can_suppress_scene_cut_bypass() { assert!(colors[0][0] < 255); } +#[test] +fn materializers_forward_transition_suppression_to_both_smoothing_seams() { + let profile = ScreenProcessingProfileConfig { + smoothing: ScreenSmoothingPolicy::Exponential { + time_constant: Duration::from_mins(1), + scene_cut: ScreenSceneCutPolicy::MeanAbsoluteDelta { + threshold: scalar(0.01), + }, + }, + ..point_profile() + }; + let started = Instant::now(); + let later = started + Duration::from_millis(16); + + let surface = SurfaceFixture::new(1, 1, 1, 1, ScreenAspectPolicy::Cover, profile.clone()); + let mut surface_materializer = + PreparedCpuSurfaceMaterializer::prepare_stateful(&surface.descriptor, surface.generation) + .expect("stateful Surface prepares"); + let mut surface_baseline = surface.publication(1, started); + surface_materializer + .stage( + surface.generation, + &surface.physical, + &[0, 0, 0, 255], + started, + false, + &mut surface_baseline, + ) + .expect("Surface baseline stages"); + surface_materializer + .commit_staged(surface.generation) + .expect("Surface baseline commits"); + drop(surface_baseline); + let mut surface_transition = surface.publication(2, later); + surface_materializer + .stage( + surface.generation, + &surface.physical, + &[255, 255, 255, 255], + later, + true, + &mut surface_transition, + ) + .expect("Surface transition stages"); + assert!( + surface_transition + .surface_pixels_mut() + .expect("Surface output remains writable")[0] + < 255 + ); + + let zones = ZoneFixture::new(1, 1, 1, 1, profile, CaptureColorimetry::SRGB); + let mut zone_materializer = + PreparedCpuZoneMaterializer::prepare_stateful(&zones.descriptor, zones.generation) + .expect("stateful Zones prepare"); + let mut zone_baseline = zones.publication(1, started); + zone_materializer + .stage( + zones.generation, + &zones.physical, + &[0, 0, 0, 255], + started, + false, + &mut zone_baseline, + ) + .expect("Zones baseline stages"); + zone_materializer + .commit_staged(zones.generation) + .expect("Zones baseline commits"); + drop(zone_baseline); + let mut zone_transition = zones.publication(2, later); + zone_materializer + .stage( + zones.generation, + &zones.physical, + &[255, 255, 255, 255], + later, + true, + &mut zone_transition, + ) + .expect("Zones transition stages"); + assert!( + zone_transition + .zone_colors_mut() + .expect("Zones output remains writable")[0][0] + < 255 + ); +} + #[test] fn prepared_state_admits_odd_portrait_ultrawide_and_one_pixel_shapes() { for (width, height) in [(1, 1), (7, 5), (127, 3), (3, 127)] { diff --git a/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs b/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs index f70f98a2a..5b9e5dcbc 100644 --- a/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs @@ -479,6 +479,7 @@ fn one_exact_reduction_fans_out_to_surface_and_oversubscribed_zones() { .surface_pixels_mut() .expect("physical surface remains writable"), frame.metadata().captured_at, + false, &mut zones_publication, ) .expect("the same physical bytes stage Zones"); @@ -523,6 +524,7 @@ fn one_exact_reduction_fans_out_to_surface_and_oversubscribed_zones() { physical, surface.pixels(), rejected_frame.metadata().captured_at, + false, &mut rejected_publication, ) .expect("next Zones state stages"); diff --git a/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs b/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs index a94320561..e62bbba95 100644 --- a/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs @@ -3,18 +3,24 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use std::thread; +use std::time::{Duration, Instant}; use hypercolor_core::input::screen::{ - CaptureColorSpace, CaptureColorimetry, CaptureDynamicRange, CaptureEpoch, CaptureGeometry, - CapturePixelFormat, CaptureRotation, CaptureSourceId, CaptureTransferFunction, - CpuCaptureStorage, CpuReductionError, CpuReductionExecutor, CpuReductionLayout, - CpuReductionRequest, KnownCaptureColorimetry, PhysicalOrigin, PixelExtent, - ResolvedScreenColorPipeline, ResolvedScreenColorTransform, ResolvedScreenSource, - ResolvedScreenSourceConfig, ScreenAspectPolicy, ScreenBackendResourceIdentity, - ScreenCaptureBackend, ScreenColorTransformCapabilities, ScreenExtentRequest, + CaptureColorSpace, CaptureColorimetry, CaptureCursor, CaptureDamage, CaptureDynamicRange, + CaptureEpoch, CaptureFrame, CaptureFrameMetadata, CaptureGeometry, CaptureLuminanceContext, + CapturePixelFormat, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureStorage, + CaptureTransferFunction, CpuCaptureStorage, CpuReductionBatchJob, CpuReductionError, + CpuReductionExecutor, CpuReductionLayout, CpuReductionRequest, InputPublicationDemandRevision, + KnownCaptureColorimetry, LED_TONE_MAP_ALGORITHM_REVISION, LedToneMapCalibration, + PhysicalOrigin, PixelExtent, RawCaptureSurface, RegisteredScreenBranchDemand, + ResolvedScreenBranchDemand, ResolvedScreenColorPipeline, ResolvedScreenSource, + ResolvedScreenSourceConfig, ScreenAdmissionCapacity, ScreenAspectPolicy, + ScreenBackendResourceIdentity, ScreenCaptureBackend, ScreenColorTransformCapabilities, + ScreenExtentRequest, ScreenHdrPolicy, ScreenInputGraphGeneration, ScreenPlanBuilder, ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenPublicationExecutorRequest, ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, ScreenResourceApi, - ScreenSourceReflection, ScreenSourceSelector, ScreenTargetColorimetry, SourceScale, + ScreenSourceReflection, ScreenSourceSelector, ScreenTargetColorimetry, ScreenToneMapOperator, + ScreenToneMapPolicy, SourceScale, }; use hypercolor_types::canvas::{linear_to_srgb_u8, srgb_u8_to_linear}; @@ -22,6 +28,14 @@ fn extent(width: u32, height: u32) -> PixelExtent { PixelExtent::new(width, height).expect("test extent is non-empty") } +fn luminance(reference: f32, peak: f32) -> CaptureLuminanceContext { + CaptureLuminanceContext::new( + CapturePositiveScalar::try_new(reference).expect("reference is valid"), + CapturePositiveScalar::try_new(peak).expect("peak is valid"), + ) + .expect("luminance is ordered") +} + fn linear_srgb_pipeline() -> ResolvedScreenColorPipeline { managed_pipeline(KnownCaptureColorimetry::SRGB, KnownCaptureColorimetry::SRGB) } @@ -42,6 +56,32 @@ fn managed_pipeline( ) } +fn calibrated_pipeline( + source_color: KnownCaptureColorimetry, + target_color: KnownCaptureColorimetry, + calibration: LedToneMapCalibration, + hdr: bool, +) -> ResolvedScreenColorPipeline { + let config = ScreenProcessingProfileConfig { + target_colorimetry: ScreenTargetColorimetry::ConvertTo(target_color), + hdr: if hdr { + ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( + ScreenToneMapOperator::Bt2390Eetf, + calibration, + )) + } else { + ScreenHdrPolicy::Reject + }, + ..ScreenProcessingProfileConfig::default() + }; + resolve_pipeline_with_profile( + source_color, + CapturePixelFormat::Rgba8, + ScreenProcessingProfile::new(config).with_led_tone_map(calibration), + ScreenColorTransformCapabilities::new(true, true, true, LED_TONE_MAP_ALGORITHM_REVISION), + ) +} + fn preserve_encoded_pipeline(pixel_format: CapturePixelFormat) -> ResolvedScreenColorPipeline { resolve_pipeline( KnownCaptureColorimetry::SRGB, @@ -58,8 +98,49 @@ fn resolve_pipeline( profile_config: ScreenProcessingProfileConfig, linear_light_sdr: bool, relative_color_conversion: bool, +) -> ResolvedScreenColorPipeline { + let profile = ScreenProcessingProfile::new(profile_config); + let capabilities = if linear_light_sdr || relative_color_conversion { + ScreenColorTransformCapabilities::new( + linear_light_sdr, + relative_color_conversion, + false, + profile.algorithm_revision(), + ) + } else { + ScreenColorTransformCapabilities::NONE + }; + resolve_pipeline_with_profile(source_color, source_pixel_format, profile, capabilities) +} + +fn resolve_pipeline_with_profile( + source_color: KnownCaptureColorimetry, + source_pixel_format: CapturePixelFormat, + profile: ScreenProcessingProfile, + capabilities: ScreenColorTransformCapabilities, ) -> ResolvedScreenColorPipeline { let source_extent = extent(2, 2); + let source = resolved_source(source_color, source_pixel_format, source_extent); + let profile = Arc::new(profile); + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::Cpu, + ScreenExtentRequest::Native, + ScreenAspectPolicy::Contain, + Arc::clone(&profile), + ) + .resolve_with_color_capabilities(&source, capabilities) + .expect("CPU reducer declares the exact color operation") + .physical() + .color_pipeline() +} + +fn resolved_source( + source_color: KnownCaptureColorimetry, + source_pixel_format: CapturePixelFormat, + source_extent: PixelExtent, +) -> ResolvedScreenSource { let source_id = CaptureSourceId::new("synthetic:cpu-reducer").expect("test source identity is non-empty"); let geometry = CaptureGeometry::new( @@ -71,7 +152,7 @@ fn resolve_pipeline( SourceScale::ONE, ) .expect("test geometry is valid"); - let source = ResolvedScreenSource::new( + ResolvedScreenSource::new( ScreenSourceSelector::Configured, CaptureEpoch { source_id, @@ -91,32 +172,7 @@ fn resolve_pipeline( 1, ), ), - ); - let profile = Arc::new(ScreenProcessingProfile::new(profile_config)); - ScreenPublicationRequest::new( - ScreenSourceSelector::Configured, - ScreenPublicationKind::Surface, - ScreenPublicationExecutorRequest::Cpu, - ScreenExtentRequest::Native, - ScreenAspectPolicy::Contain, - Arc::clone(&profile), - ) - .resolve_with_color_capabilities( - &source, - if linear_light_sdr || relative_color_conversion { - ScreenColorTransformCapabilities::new( - linear_light_sdr, - relative_color_conversion, - false, - profile.algorithm_revision(), - ) - } else { - ScreenColorTransformCapabilities::NONE - }, ) - .expect("CPU reducer declares the exact color operation") - .physical() - .color_pipeline() } fn executor(worker_count: usize, tile_rows: u32) -> CpuReductionExecutor { @@ -127,6 +183,196 @@ fn executor(worker_count: usize, tile_rows: u32) -> CpuReductionExecutor { .expect("test worker pool builds") } +#[test] +fn cpu_capabilities_publish_the_shared_color_algorithm_contract() { + let capabilities = executor(1, 1).capabilities(); + assert!(capabilities.supports_linear_light_sdr_processing()); + assert!(capabilities.supports_linear_relative_color_conversion()); + assert!(capabilities.supports_pq_bt2390_tone_mapping()); + assert!(capabilities.supports_reference_white_bt2390_tone_mapping()); + assert_eq!( + capabilities.algorithm_revision(), + Some(LED_TONE_MAP_ALGORITHM_REVISION) + ); +} + +#[test] +fn managed_nearest_applies_exposure_wide_gamut_and_hdr_eetf() { + let source_extent = extent(1, 1); + let layout = CpuReductionLayout::new(source_extent, source_extent) + .expect("test reduction geometry is addressable"); + let executor = executor(1, 1); + let run = |pixel, pipeline| { + let source = storage(pixel, source_extent, CapturePixelFormat::Rgba8); + let mut output = vec![0; layout.target_byte_len_usize()]; + executor + .reduce( + CpuReductionRequest::new( + &source, + layout, + CapturePixelFormat::Rgba8, + ScreenReductionFilter::Nearest, + pipeline, + ), + &mut output, + ) + .expect("managed nearest reduction succeeds"); + output + }; + + let negative_exposure = LedToneMapCalibration::try_new(0.3127, 0.329, 203.0, 406.0, -1.0) + .expect("negative exposure is valid"); + assert_eq!( + run( + vec![255, 255, 255, 255], + calibrated_pipeline( + KnownCaptureColorimetry::SRGB, + KnownCaptureColorimetry::SRGB, + negative_exposure, + false, + ), + ), + vec![188, 188, 188, 255] + ); + + let p3 = KnownCaptureColorimetry::try_new( + CaptureColorSpace::DisplayP3, + CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard, + None, + ) + .expect("P3 source is valid"); + assert_eq!( + run( + vec![255, 0, 255, 255], + calibrated_pipeline( + p3, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + false, + ), + ), + vec![255, 59, 242, 255] + ); + + let hdr = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Pq, + CaptureDynamicRange::High, + Some(luminance(203.0, 1_000.0)), + ) + .expect("PQ source is valid"); + assert_eq!( + run( + vec![159, 159, 159, 255], + calibrated_pipeline( + hdr, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + true, + ), + ), + vec![223, 223, 223, 255] + ); + + let linear_hdr = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Linear, + CaptureDynamicRange::High, + Some(luminance(203.0, 1_000.0)), + ) + .expect("extended-linear HDR source is valid"); + assert_eq!( + run( + vec![255, 255, 255, 255], + calibrated_pipeline( + linear_hdr, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + true, + ), + ), + vec![188, 188, 188, 255] + ); +} + +#[test] +fn prepared_managed_nearest_applies_color_before_publication() { + let source_extent = extent(1, 1); + let source = resolved_source( + KnownCaptureColorimetry::SRGB, + CapturePixelFormat::Rgba8, + source_extent, + ); + let calibration = LedToneMapCalibration::try_new(0.3127, 0.329, 203.0, 406.0, -1.0) + .expect("negative exposure is valid"); + let profile = ScreenProcessingProfile::new(ScreenProcessingProfileConfig { + reduction_filter: ScreenReductionFilter::Nearest, + ..ScreenProcessingProfileConfig::default() + }) + .with_led_tone_map(calibration); + let demand: ResolvedScreenBranchDemand = RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::Cpu, + ScreenExtentRequest::Native, + ScreenAspectPolicy::Contain, + Arc::new(profile), + ), + NonZeroU32::MIN, + ) + .resolve_with_color_capabilities(&source, executor(1, 1).capabilities()) + .expect("managed nearest demand resolves"); + let mut builder = ScreenPlanBuilder::new(); + let preparing = builder + .prepare( + [demand], + None, + InputPublicationDemandRevision::new(1), + ScreenInputGraphGeneration::new(1), + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("managed nearest plan is admitted"); + let executor = executor(1, 1); + let batch = executor + .prepare_batch(&source, preparing.candidate_plan()) + .expect("managed nearest batch prepares"); + let captured_at = Instant::now(); + let frame = CaptureFrame::::new( + CaptureFrameMetadata { + source_id: source.epoch().source_id.clone(), + topology_generation: source.epoch().topology_generation, + session_generation: source.epoch().session_generation, + sequence: 1, + captured_at, + fresh_until: captured_at + Duration::from_secs(1), + geometry: source.config().geometry(), + colorimetry: source.config().colorimetry(), + cursor: CaptureCursor::default(), + }, + CaptureStorage::Cpu(storage( + vec![255, 255, 255, 255], + source_extent, + CapturePixelFormat::Rgba8, + )), + CaptureDamage::default(), + ) + .expect("managed nearest frame is valid"); + let descriptor = batch.descriptor(0).expect("prepared descriptor exists"); + let mut output = vec![ + 0; + batch + .output_byte_len(0) + .expect("prepared output size exists") + ]; + let mut jobs = [CpuReductionBatchJob::new(descriptor, &mut output)]; + executor + .execute_batch(&batch, &frame, &mut jobs) + .expect("prepared managed nearest executes"); + assert_eq!(output, vec![188, 188, 188, 255]); +} + fn storage( pixels: Vec, source_extent: PixelExtent, @@ -588,7 +834,7 @@ fn linear_light_sdr_uses_the_resolved_transfer_function() { } #[test] -fn relative_color_conversion_is_a_typed_unsupported_operation() { +fn relative_color_conversion_compresses_wide_gamut_per_source_sample() { let display_p3 = KnownCaptureColorimetry::try_new( CaptureColorSpace::DisplayP3, CaptureTransferFunction::Srgb, @@ -598,14 +844,14 @@ fn relative_color_conversion_is_a_typed_unsupported_operation() { .expect("Display P3 SDR contract is complete"); let source_extent = extent(1, 1); let source = storage( - vec![10, 20, 30, 255], + vec![255, 0, 255, 255], source_extent, CapturePixelFormat::Rgba8, ); let layout = CpuReductionLayout::new(source_extent, source_extent) .expect("test reduction geometry is addressable"); let mut output = vec![0; layout.target_byte_len_usize()]; - let error = executor(1, 1) + executor(1, 1) .reduce( CpuReductionRequest::new( &source, @@ -616,13 +862,10 @@ fn relative_color_conversion_is_a_typed_unsupported_operation() { ), &mut output, ) - .expect_err("relative gamut conversion is not implemented by this CPU lane"); - assert!(matches!( - error, - CpuReductionError::UnsupportedColorTransform( - ResolvedScreenColorTransform::LinearRelativeColorimetric { .. } - ) - )); + .expect("relative gamut conversion is executable by the CPU lane"); + assert_eq!(output[3], 255); + assert!(output[0] > output[1]); + assert!(output[2] > output[1]); } fn scalar_reference( From 795ea44d5771769ff8dfe458d743b3c0fd7f8ba0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 00:24:40 -0700 Subject: [PATCH 065/144] feat(macos): import exact ScreenCaptureKit planes Import every retained capture plane through direct IOSurface or Core Video according to the active GPU storage contract. Preserve exact FourCC, allocation, plane, format, device, and lifetime identity across wrapper reuse. Reject cross-device handoff before unsafe wrapping. Serialize native wrapper creation per storage identity and keep Core Video cache flushing outside the native wrapper lock to prevent duplicate textures and lock cycles. Co-Authored-By: Nova (GPT-5 Codex) --- Cargo.lock | 1 + .../src/render_thread/sparkleflinger/gpu.rs | 22 +- .../hypercolor-macos-gpu-interop/Cargo.toml | 14 +- .../hypercolor-macos-gpu-interop/src/macos.rs | 479 ++++++++- .../src/screen_capture.rs | 969 ++++++++++++++++-- .../hypercolor-macos-gpu-interop/src/stubs.rs | 29 +- .../tests/descriptor_tests.rs | 43 + .../tests/screen_capture_bridge_tests.rs | 36 +- 8 files changed, 1455 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bd631027..4d60bfda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5352,6 +5352,7 @@ dependencies = [ "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-video", "objc2-io-surface", "objc2-metal 0.3.2", "pollster", diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index 4e38a0176..377f945bc 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -378,11 +378,12 @@ struct PreparedWindowsScreenTarget { struct MacosScreenBridge { interop: MacosInteropScreenBridge, storage_ids: Mutex>, + lifetime: Arc<()>, } #[cfg(all(target_os = "macos", feature = "screen-capture"))] struct MacosScreenTargetPreparer { - bridge: Weak, + bridge_lifetime: Weak<()>, } #[cfg(all(target_os = "macos", feature = "screen-capture"))] @@ -464,7 +465,7 @@ impl ScreenNativeTargetPreparer for MacosScreenTargetPreparer { .downcast_ref::() .context("macOS screen target received an unknown preparation manifest")?; validate_macos_target_manifest(descriptor, manifest)?; - self.bridge + self.bridge_lifetime .upgrade() .context("macOS screen renderer was retired during target admission")?; prepared_macos_screen_target_metadata_bytes() @@ -479,7 +480,7 @@ impl ScreenNativeTargetPreparer for MacosScreenTargetPreparer { .downcast_ref::() .context("macOS screen target received an unknown preparation manifest")?; validate_macos_target_manifest(descriptor, manifest)?; - self.bridge + self.bridge_lifetime .upgrade() .context("macOS screen renderer was retired during target preparation")?; Ok(ScreenNativeTargetPreparation::new( @@ -874,6 +875,7 @@ fn create_screen_bridge( let bridge = Arc::new(MacosScreenBridge { interop, storage_ids: Mutex::new(HashMap::new()), + lifetime: Arc::new(()), }); let target = create_screen_target(&bridge, max_texture_dimension); (Some(bridge), target) @@ -901,7 +903,7 @@ fn create_screen_target( NonZeroU32::new(max_texture_dimension) .expect("wgpu devices expose a non-zero texture dimension limit"), Arc::new(MacosScreenTargetPreparer { - bridge: Arc::downgrade(bridge), + bridge_lifetime: Arc::downgrade(&bridge.lifetime), }), )) } @@ -1797,8 +1799,16 @@ impl GpuSparkleFlinger { let width = extent.width(); let height = extent.height(); let content_generation = imported.content_sequence(); - let texture = imported.texture().as_ref().clone(); - let view = imported.view().as_ref().clone(); + let texture = imported + .texture() + .context("native macOS publication has no wgpu texture")? + .as_ref() + .clone(); + let view = imported + .view() + .context("native macOS publication has no wgpu texture view")? + .as_ref() + .clone(); Ok(Some(GpuTextureFrame { width, height, diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index 04cd32c88..6a2e46f9a 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -10,7 +10,7 @@ description = "macOS IOSurface/Metal texture import boundary for Hypercolor" [features] default = [] -screen-capture = ["dep:hypercolor-macos-capture"] +screen-capture = ["dep:hypercolor-macos-capture", "dep:objc2-core-video"] servo-context = [ "dep:cgl", "dep:dpi", @@ -39,6 +39,18 @@ image = { workspace = true, optional = true } libc = { workspace = true } objc2 = { workspace = true, features = ["std"] } objc2-core-foundation = { workspace = true, features = ["std", "CFDictionary", "CFNumber", "CFString"] } +objc2-core-video = { workspace = true, optional = true, features = [ + "std", + "CVBase", + "CVBuffer", + "CVImageBuffer", + "CVMetalTexture", + "CVMetalTextureCache", + "CVPixelBuffer", + "CVReturn", + "objc2", + "objc2-metal", +] } objc2-io-surface = { workspace = true, features = ["std", "IOSurfaceRef", "IOSurfaceTypes", "objc2-core-foundation", "libc", "bitflags"] } objc2-metal = { workspace = true, features = ["std", "MTLAllocation", "MTLDevice", "MTLPixelFormat", "MTLResource", "MTLTexture", "objc2-io-surface"] } paint_api = { workspace = true, optional = true } diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index 91251f715..065b4cc55 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -5,8 +5,13 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use objc2_core_foundation::{ - CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault, kCFTypeDictionaryKeyCallBacks, - kCFTypeDictionaryValueCallBacks, + CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault, + kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, +}; +#[cfg(feature = "screen-capture")] +use objc2_core_video::{ + CVMetalTexture, CVMetalTextureCache, CVMetalTextureGetTexture, CVPixelBuffer, + kCVMetalTextureStorageMode, kCVMetalTextureUsage, kCVReturnSuccess, }; use objc2_io_surface::{ IOSurfaceLockOptions, IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, @@ -18,7 +23,7 @@ use objc2_metal::{ }; use thiserror::Error; -const BYTES_PER_PIXEL: u32 = 4; +const BGRA_BYTES_PER_PIXEL: u32 = 4; const PIXEL_FORMAT_BGRA: i32 = u32::from_be_bytes(*b"BGRA") as i32; /// Maximum cached wgpu wraps before the importer cache resets. The Servo /// publish ring uses 3 IOSurfaces, so steady state stays well under this. @@ -27,6 +32,13 @@ const MAX_CACHED_WRAPS: usize = 8; /// generation (see [`MacosIosurfaceImporter::import_iosurface_for_test`]). static NEXT_STORAGE_ID: AtomicU64 = AtomicU64::new(1); +#[cfg(feature = "screen-capture")] +type CoreVideoMetalTexturePlane = ( + objc2::rc::Retained>, + CFRetained, + Instant, +); + /// Result type for macOS GPU interop operations. pub type Result = std::result::Result; @@ -75,6 +87,13 @@ pub enum MacosGpuInteropError { actual_height: usize, }, + /// IOSurface allocation size cannot be represented by the cache identity. + #[error("IOSurface allocation size {actual_bytes} exceeds u64")] + IosurfaceAllocationSizeOverflow { + /// Allocation size reported by IOSurface. + actual_bytes: usize, + }, + /// The supplied pixel buffer does not match the IOSurface dimensions. #[error("pixel buffer length mismatch: expected {expected_len} bytes, got {actual_len}")] PixelBufferSizeMismatch { @@ -138,6 +157,15 @@ pub enum MacosGpuInteropError { actual: u32, }, + /// The requested IOSurface plane does not exist. + #[error("IOSurface plane {requested} is unavailable; surface exposes {plane_count} planes")] + IosurfacePlaneUnavailable { + /// Requested plane index. + requested: usize, + /// Number of planes exposed by the IOSurface. + plane_count: usize, + }, + /// Metal could not create a texture from the IOSurface. #[error("Metal failed to create texture from IOSurface")] MetalTextureCreateFailed, @@ -178,9 +206,43 @@ pub enum MacosGpuInteropError { actual: usize, }, + /// Metal returned a texture with another extent. + #[error( + "Metal texture extent mismatch: expected {expected_width}x{expected_height}, got {actual_width}x{actual_height}" + )] + MetalTextureExtentMismatch { + /// Requested texture width. + expected_width: u32, + /// Requested texture height. + expected_height: u32, + /// Created texture width. + actual_width: usize, + /// Created texture height. + actual_height: usize, + }, + + /// Metal returned a texture with another pixel format. + #[error("Metal texture pixel format mismatch: expected {expected}, got {actual}")] + MetalPixelFormatMismatch { + /// Requested Metal pixel format. + expected: usize, + /// Created Metal pixel format. + actual: usize, + }, + /// Metal reported a storage mode outside the supported import contract. #[error("unsupported Metal texture storage mode {0}")] UnsupportedMetalStorageMode(usize), + + /// Core Video could not create the Metal texture cache. + #[cfg(feature = "screen-capture")] + #[error("Core Video Metal texture cache creation failed with CVReturn {0}")] + CoreVideoTextureCacheCreateFailed(i32), + + /// Core Video could not create a texture wrapper for a pixel-buffer plane. + #[cfg(feature = "screen-capture")] + #[error("Core Video Metal texture creation failed with CVReturn {0}")] + CoreVideoTextureCreateFailed(i32), } /// Family-selected Metal storage mode for imported IOSurfaces. @@ -217,6 +279,16 @@ impl MacosMetalStorageMode { pub enum ImportedFrameFormat { /// 8-bit normalized BGRA. Bgra8Unorm, + /// 16-bit floating-point RGBA. + Rgba16Float, + /// One 8-bit normalized component. + R8Unorm, + /// Two 8-bit normalized components. + Rg8Unorm, + /// One 16-bit normalized component. + R16Unorm, + /// Two 16-bit normalized components. + Rg16Unorm, } impl ImportedFrameFormat { @@ -225,12 +297,32 @@ impl ImportedFrameFormat { pub const fn wgpu_format(self) -> wgpu::TextureFormat { match self { Self::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm, + Self::Rgba16Float => wgpu::TextureFormat::Rgba16Float, + Self::R8Unorm => wgpu::TextureFormat::R8Unorm, + Self::Rg8Unorm => wgpu::TextureFormat::Rg8Unorm, + Self::R16Unorm => wgpu::TextureFormat::R16Unorm, + Self::Rg16Unorm => wgpu::TextureFormat::Rg16Unorm, } } const fn metal_format(self) -> MTLPixelFormat { match self { Self::Bgra8Unorm => MTLPixelFormat::BGRA8Unorm, + Self::Rgba16Float => MTLPixelFormat::RGBA16Float, + Self::R8Unorm => MTLPixelFormat::R8Unorm, + Self::Rg8Unorm => MTLPixelFormat::RG8Unorm, + Self::R16Unorm => MTLPixelFormat::R16Unorm, + Self::Rg16Unorm => MTLPixelFormat::RG16Unorm, + } + } + + pub(crate) const fn bytes_per_texel(self) -> u32 { + match self { + Self::Bgra8Unorm => 4, + Self::Rgba16Float => 8, + Self::R8Unorm => 1, + Self::Rg8Unorm | Self::R16Unorm => 2, + Self::Rg16Unorm => 4, } } } @@ -251,7 +343,7 @@ impl MacosIosurfaceImportDescriptor { pub const fn new(width: u32, height: u32, format: ImportedFrameFormat) -> Result { if width == 0 || height == 0 - || width > i32::MAX as u32 / BYTES_PER_PIXEL + || width > i32::MAX as u32 / format.bytes_per_texel() || height > i32::MAX as u32 { Err(MacosGpuInteropError::InvalidDimensions { width, height }) @@ -310,6 +402,8 @@ struct IosurfaceWrapKey { width: u32, height: u32, bytes_per_row: usize, + source_pixel_format: u32, + allocation_bytes: u64, format: ImportedFrameFormat, storage_mode: MacosMetalStorageMode, metal_registry_id: u64, @@ -390,8 +484,30 @@ impl MacosIosurfaceImporter { capture_session_generation: u64, resource_generation: u64, ) -> Result { - validate_iosurface_shape(self.descriptor, iosurface)?; - validate_iosurface_format(self.descriptor, iosurface)?; + self.import_iosurface_plane_scoped( + device, + iosurface, + content_generation, + capture_session_generation, + resource_generation, + 0, + PIXEL_FORMAT_BGRA as u32, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn import_iosurface_plane_scoped( + &mut self, + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + content_generation: u64, + capture_session_generation: u64, + resource_generation: u64, + plane: usize, + source_pixel_format: u32, + ) -> Result { + validate_iosurface_shape(self.descriptor, iosurface, plane)?; + validate_iosurface_format(iosurface, source_pixel_format)?; let (actual_registry_id, _) = metal_device_import_contract(device)?; if actual_registry_id != self.metal_registry_id { return Err(MacosGpuInteropError::MetalRegistryIdMismatch { @@ -402,14 +518,21 @@ impl MacosIosurfaceImporter { let total_start = Instant::now(); let surface_id = iosurface.id(); + let allocation_bytes = u64::try_from(iosurface.alloc_size()).map_err(|_| { + MacosGpuInteropError::IosurfaceAllocationSizeOverflow { + actual_bytes: iosurface.alloc_size(), + } + })?; let cache_key = IosurfaceWrapKey { capture_session_generation, resource_generation, surface_id, - plane: 0, + plane, width: self.descriptor.width, height: self.descriptor.height, - bytes_per_row: iosurface.bytes_per_row(), + bytes_per_row: iosurface_bytes_per_row(iosurface, plane), + source_pixel_format, + allocation_bytes, format: self.descriptor.format, storage_mode: self.storage_mode, metal_registry_id: self.metal_registry_id, @@ -435,10 +558,18 @@ impl MacosIosurfaceImporter { let descriptor = metal_texture_descriptor(self.descriptor, self.storage_mode); hal_device .raw_device() - .newTextureWithDescriptor_iosurface_plane(&descriptor, iosurface, 0) + .newTextureWithDescriptor_iosurface_plane(&descriptor, iosurface, plane) .ok_or(MacosGpuInteropError::MetalTextureCreateFailed)? }; - validate_metal_texture(&metal_texture, surface_id, 0, self.storage_mode)?; + validate_metal_texture( + &metal_texture, + surface_id, + plane, + self.descriptor.width, + self.descriptor.height, + self.storage_mode, + self.descriptor.format.metal_format(), + )?; let wrap_us = elapsed_micros(wrap_start); let wgpu_desc = wgpu_texture_descriptor(self.descriptor); @@ -526,7 +657,7 @@ pub fn write_bgra_pixels( height: u32, pixels: &[u8], ) -> Result<()> { - let expected_len = width as usize * height as usize * BYTES_PER_PIXEL as usize; + let expected_len = width as usize * height as usize * BGRA_BYTES_PER_PIXEL as usize; if pixels.len() != expected_len { return Err(MacosGpuInteropError::PixelBufferSizeMismatch { expected_len, @@ -536,11 +667,12 @@ pub fn write_bgra_pixels( validate_iosurface_shape( MacosIosurfaceImportDescriptor::new(width, height, ImportedFrameFormat::Bgra8Unorm)?, iosurface, + 0, )?; let lock = IosurfaceLockGuard::lock(iosurface)?; let bytes_per_row = iosurface.bytes_per_row(); - let row_len = width as usize * BYTES_PER_PIXEL as usize; + let row_len = width as usize * BGRA_BYTES_PER_PIXEL as usize; let base_address = iosurface.base_address().as_ptr().cast::(); for (row_index, row_pixels) in pixels.chunks_exact(row_len).enumerate() { // SAFETY: the IOSurface is locked for CPU writes, base_address points @@ -556,7 +688,7 @@ pub fn write_bgra_pixels( pub(crate) fn create_iosurface( descriptor: MacosIosurfaceImportDescriptor, ) -> Result> { - let bytes_per_row = descriptor.width * BYTES_PER_PIXEL; + let bytes_per_row = descriptor.width * BGRA_BYTES_PER_PIXEL; // SAFETY: these are framework-provided constant CFString references. let keys = unsafe { [ @@ -570,7 +702,7 @@ pub(crate) fn create_iosurface( let values = [ &*CFNumber::new_i32(descriptor.width as i32), &*CFNumber::new_i32(descriptor.height as i32), - &*CFNumber::new_i32(BYTES_PER_PIXEL as i32), + &*CFNumber::new_i32(BGRA_BYTES_PER_PIXEL as i32), &*CFNumber::new_i32(bytes_per_row as i32), &*CFNumber::new_i32(PIXEL_FORMAT_BGRA), ]; @@ -614,7 +746,7 @@ fn metal_texture_descriptor( ) }; texture_descriptor.setTextureType(MTLTextureType::Type2D); - texture_descriptor.setUsage(MTLTextureUsage::ShaderRead | MTLTextureUsage::RenderTarget); + texture_descriptor.setUsage(MTLTextureUsage::ShaderRead); texture_descriptor.setStorageMode(storage_mode.native()); texture_descriptor } @@ -633,9 +765,7 @@ fn wgpu_texture_descriptor( sample_count: 1, dimension: wgpu::TextureDimension::D2, format: descriptor.format.wgpu_format(), - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::RENDER_ATTACHMENT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, view_formats: &[], } } @@ -643,28 +773,45 @@ fn wgpu_texture_descriptor( fn validate_iosurface_shape( descriptor: MacosIosurfaceImportDescriptor, iosurface: &IOSurfaceRef, + plane: usize, +) -> Result<()> { + validate_iosurface_plane_extent(descriptor.width, descriptor.height, iosurface, plane) +} + +fn validate_iosurface_plane_extent( + expected_width: u32, + expected_height: u32, + iosurface: &IOSurfaceRef, + plane: usize, ) -> Result<()> { - let actual_width = iosurface.width(); - let actual_height = iosurface.height(); - if actual_width == descriptor.width as usize && actual_height == descriptor.height as usize { + let plane_count = iosurface.plane_count(); + if (plane_count == 0 && plane != 0) || (plane_count != 0 && plane >= plane_count) { + return Err(MacosGpuInteropError::IosurfacePlaneUnavailable { + requested: plane, + plane_count, + }); + } + let (actual_width, actual_height) = if plane_count == 0 { + (iosurface.width(), iosurface.height()) + } else { + ( + iosurface.width_of_plane(plane), + iosurface.height_of_plane(plane), + ) + }; + if actual_width == expected_width as usize && actual_height == expected_height as usize { Ok(()) } else { Err(MacosGpuInteropError::IosurfaceShapeMismatch { - expected_width: descriptor.width, - expected_height: descriptor.height, + expected_width, + expected_height, actual_width, actual_height, }) } } -fn validate_iosurface_format( - descriptor: MacosIosurfaceImportDescriptor, - iosurface: &IOSurfaceRef, -) -> Result<()> { - let expected = match descriptor.format { - ImportedFrameFormat::Bgra8Unorm => PIXEL_FORMAT_BGRA as u32, - }; +fn validate_iosurface_format(iosurface: &IOSurfaceRef, expected: u32) -> Result<()> { let actual = iosurface.pixel_format(); if actual == expected { Ok(()) @@ -677,7 +824,10 @@ fn validate_metal_texture( texture: &objc2::runtime::ProtocolObject, expected_surface_id: u32, expected_plane: usize, + expected_width: u32, + expected_height: u32, expected_storage_mode: MacosMetalStorageMode, + expected_pixel_format: MTLPixelFormat, ) -> Result<()> { let actual_storage_mode = MacosMetalStorageMode::from_native(texture.storageMode())?; if actual_storage_mode != expected_storage_mode { @@ -703,9 +853,34 @@ fn validate_metal_texture( actual: actual_plane, }); } + let actual_width = texture.width(); + let actual_height = texture.height(); + if actual_width != expected_width as usize || actual_height != expected_height as usize { + return Err(MacosGpuInteropError::MetalTextureExtentMismatch { + expected_width, + expected_height, + actual_width, + actual_height, + }); + } + let actual_pixel_format = texture.pixelFormat(); + if actual_pixel_format != expected_pixel_format { + return Err(MacosGpuInteropError::MetalPixelFormatMismatch { + expected: expected_pixel_format.0, + actual: actual_pixel_format.0, + }); + } Ok(()) } +fn iosurface_bytes_per_row(iosurface: &IOSurfaceRef, plane: usize) -> usize { + if iosurface.plane_count() == 0 { + iosurface.bytes_per_row() + } else { + iosurface.bytes_per_row_of_plane(plane) + } +} + pub(crate) fn metal_device_import_contract( device: &wgpu::Device, ) -> Result<(u64, MacosMetalStorageMode)> { @@ -728,6 +903,221 @@ fn require_metal_device( .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice) } +#[cfg(feature = "screen-capture")] +pub(crate) fn create_core_video_texture_cache( + device: &wgpu::Device, + storage_mode: MacosMetalStorageMode, +) -> Result> { + let hal_device = require_metal_device(device)?; + let usage = CFNumber::new_i64(MTLTextureUsage::ShaderRead.bits() as i64); + let storage_mode = CFNumber::new_i64(storage_mode.native().0 as i64); + // SAFETY: these are framework-provided constant CFString references. + let texture_attribute_keys = unsafe { [kCVMetalTextureUsage, kCVMetalTextureStorageMode] }; + let texture_attributes = CFDictionary::::from_slices( + &texture_attribute_keys, + &[&usage, &storage_mode], + ); + let mut raw_cache = std::ptr::null_mut(); + // SAFETY: the output pointer is valid, the Metal device outlives this call, + // and the retained dictionary contains documented numeric Metal values. + let result = unsafe { + CVMetalTextureCache::create( + None, + None, + hal_device.raw_device(), + Some(texture_attributes.as_ref()), + std::ptr::NonNull::from(&mut raw_cache), + ) + }; + if result != kCVReturnSuccess { + return Err(MacosGpuInteropError::CoreVideoTextureCacheCreateFailed( + result, + )); + } + let raw_cache = std::ptr::NonNull::new(raw_cache).ok_or( + MacosGpuInteropError::CoreVideoTextureCacheCreateFailed(result), + )?; + // SAFETY: Core Video returned the created cache at +1 ownership. + Ok(unsafe { objc2_core_foundation::CFRetained::from_raw(raw_cache) }) +} + +#[cfg(feature = "screen-capture")] +#[allow(clippy::too_many_arguments)] +pub(crate) fn import_core_video_pixel_buffer_plane( + device: &wgpu::Device, + cache: &CVMetalTextureCache, + pixel_buffer: &CVPixelBuffer, + descriptor: MacosIosurfaceImportDescriptor, + plane: usize, + expected_surface_id: u32, + expected_storage_mode: MacosMetalStorageMode, + content_generation: u64, +) -> Result<( + ImportedEffectFrame, + objc2_core_foundation::CFRetained, +)> { + let (metal_texture, wrapper, total_start) = import_core_video_metal_texture_plane( + cache, + pixel_buffer, + descriptor.width, + descriptor.height, + plane, + descriptor.format.metal_format(), + expected_surface_id, + expected_storage_mode, + )?; + let wrap_us = elapsed_micros(total_start); + let imported = wrap_metal_texture( + device, + metal_texture, + descriptor, + content_generation, + wrap_us, + total_start, + ); + Ok((imported, wrapper)) +} + +#[cfg(feature = "screen-capture")] +#[allow(clippy::too_many_arguments)] +pub(crate) fn import_core_video_metal_texture_plane( + cache: &CVMetalTextureCache, + pixel_buffer: &CVPixelBuffer, + width: u32, + height: u32, + plane: usize, + pixel_format: MTLPixelFormat, + expected_surface_id: u32, + expected_storage_mode: MacosMetalStorageMode, +) -> Result { + let total_start = Instant::now(); + let mut raw_wrapper = std::ptr::null_mut(); + // SAFETY: the output pointer is valid, the pixel buffer remains retained + // by the capture owner, and the descriptor matches the validated plane. + let result = unsafe { + CVMetalTextureCache::create_texture_from_image( + None, + cache, + pixel_buffer, + None, + pixel_format, + width as usize, + height as usize, + plane, + std::ptr::NonNull::from(&mut raw_wrapper), + ) + }; + if result != kCVReturnSuccess { + return Err(MacosGpuInteropError::CoreVideoTextureCreateFailed(result)); + } + let raw_wrapper = std::ptr::NonNull::new(raw_wrapper) + .ok_or(MacosGpuInteropError::CoreVideoTextureCreateFailed(result))?; + // SAFETY: Core Video returned the created texture wrapper at +1 ownership. + let wrapper = unsafe { objc2_core_foundation::CFRetained::from_raw(raw_wrapper) }; + let metal_texture = + CVMetalTextureGetTexture(&wrapper).ok_or(MacosGpuInteropError::MetalTextureCreateFailed)?; + validate_metal_texture( + &metal_texture, + expected_surface_id, + plane, + width, + height, + expected_storage_mode, + pixel_format, + )?; + Ok((metal_texture, wrapper, total_start)) +} + +#[cfg(feature = "screen-capture")] +#[allow(clippy::too_many_arguments)] +pub(crate) fn import_iosurface_metal_texture_plane( + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + width: u32, + height: u32, + plane: usize, + source_pixel_format: u32, + pixel_format: MTLPixelFormat, + expected_storage_mode: MacosMetalStorageMode, +) -> Result>> { + validate_iosurface_plane_extent(width, height, iosurface, plane)?; + validate_iosurface_format(iosurface, source_pixel_format)?; + let hal_device = require_metal_device(device)?; + // SAFETY: the dimensions are validated by CapturePlaneImportDescriptor, + // and the Metal pixel format is selected by the exact capture format. + let descriptor = unsafe { + MTLTextureDescriptor::texture2DDescriptorWithPixelFormat_width_height_mipmapped( + pixel_format, + width as usize, + height as usize, + false, + ) + }; + descriptor.setTextureType(MTLTextureType::Type2D); + descriptor.setUsage(MTLTextureUsage::ShaderRead); + descriptor.setStorageMode(expected_storage_mode.native()); + let texture = hal_device + .raw_device() + .newTextureWithDescriptor_iosurface_plane(&descriptor, iosurface, plane) + .ok_or(MacosGpuInteropError::MetalTextureCreateFailed)?; + validate_metal_texture( + &texture, + iosurface.id(), + plane, + width, + height, + expected_storage_mode, + pixel_format, + )?; + Ok(texture) +} + +#[cfg(feature = "screen-capture")] +fn wrap_metal_texture( + device: &wgpu::Device, + metal_texture: objc2::rc::Retained>, + descriptor: MacosIosurfaceImportDescriptor, + content_generation: u64, + wrap_us: u64, + total_start: Instant, +) -> ImportedEffectFrame { + let wgpu_desc = wgpu_texture_descriptor(descriptor); + let copy_size = wgpu_hal::CopyExtent { + width: descriptor.width, + height: descriptor.height, + depth: 1, + }; + // SAFETY: the Metal texture came from the same device behind this wgpu + // device, matches the descriptor, and remains retained by the wrapper. + let hal_texture = unsafe { + wgpu_hal::metal::Device::texture_from_raw( + metal_texture, + descriptor.format.wgpu_format(), + MTLTextureType::Type2D, + 1, + 1, + copy_size, + ) + }; + // SAFETY: the HAL texture was created from this wgpu device and matches + // the supplied descriptor. + let texture = + unsafe { device.create_texture_from_hal::(hal_texture, &wgpu_desc) }; + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + ImportedEffectFrame { + width: descriptor.width, + height: descriptor.height, + format: descriptor.format, + storage_id: content_generation, + texture: Arc::new(texture), + view: Arc::new(view), + timings: ImportedFrameTimings { + wrap_us, + total_us: elapsed_micros(total_start), + }, + } +} + fn lock_iosurface(iosurface: &IOSurfaceRef) -> Result<()> { // SAFETY: null seed is allowed by IOSurfaceLock. let code = unsafe { iosurface.lock(IOSurfaceLockOptions::empty(), std::ptr::null_mut()) }; @@ -786,3 +1176,32 @@ impl Drop for IosurfaceLockGuard<'_> { fn elapsed_micros(start: Instant) -> u64 { start.elapsed().as_micros().try_into().unwrap_or(u64::MAX) } + +#[cfg(test)] +mod tests { + use super::*; + + fn wrap_key(source_pixel_format: u32, allocation_bytes: u64) -> IosurfaceWrapKey { + IosurfaceWrapKey { + capture_session_generation: 1, + resource_generation: 2, + surface_id: 3, + plane: 0, + width: 4, + height: 5, + bytes_per_row: 16, + source_pixel_format, + allocation_bytes, + format: ImportedFrameFormat::Bgra8Unorm, + storage_mode: MacosMetalStorageMode::Shared, + metal_registry_id: 6, + } + } + + #[test] + fn iosurface_wrap_key_retains_source_format_and_allocation_identity() { + let baseline = wrap_key(u32::from_be_bytes(*b"420v"), 1_024); + assert_ne!(baseline, wrap_key(u32::from_be_bytes(*b"420f"), 1_024)); + assert_ne!(baseline, wrap_key(u32::from_be_bytes(*b"420v"), 2_048)); + } +} diff --git a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs index 66b0ff6d2..c7b631eef 100644 --- a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -2,15 +2,23 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use hypercolor_macos_capture::{MacosCaptureFrame, MacosCapturePixelFormat, MacosPixelExtent}; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2_core_foundation::CFRetained; +use objc2_core_video::{CVMetalTexture, CVMetalTextureCache, CVPixelBuffer}; use objc2_io_surface::IOSurfaceRef; +use objc2_metal::{MTLPixelFormat, MTLTexture}; use thiserror::Error; use crate::macos::{ ImportedEffectFrame, ImportedFrameFormat, MacosGpuInteropError, MacosIosurfaceImportDescriptor, - MacosIosurfaceImporter, MacosMetalStorageMode, metal_device_import_contract, + MacosIosurfaceImporter, MacosMetalStorageMode, create_core_video_texture_cache, + import_core_video_metal_texture_plane, import_core_video_pixel_buffer_plane, + import_iosurface_metal_texture_plane, metal_device_import_contract, }; const MAX_CAPTURE_DESCRIPTORS: usize = 8; +const MAX_CORE_VIDEO_WRAPPERS: usize = 64; /// Complete physical identity of one imported capture plane. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -29,6 +37,8 @@ pub struct MacosScreenStorageIdentity { pub bytes_per_row: usize, /// Capture pixel encoding. pub pixel_format: MacosCapturePixelFormat, + /// Exact Core Video pixel-format FourCC delivered by the source. + pub source_fourcc: u32, /// Exact IOSurface allocation size. pub allocation_bytes: u64, /// Family-selected Metal storage mode. @@ -40,17 +50,118 @@ pub struct MacosScreenStorageIdentity { /// Imported capture frame retaining its Core Video owner and wgpu wrapper. #[derive(Debug, Clone)] pub struct ImportedMacosScreenFrame { - storage_identity: MacosScreenStorageIdentity, content_sequence: u64, capture: Arc, - imported: ImportedEffectFrame, + planes: Arc<[ImportedMacosScreenPlane]>, +} + +/// One imported IOSurface plane and its exact storage identity. +#[derive(Debug, Clone)] +pub struct ImportedMacosScreenPlane { + storage_identity: MacosScreenStorageIdentity, + format: ImportedMacosScreenPlaneFormat, + storage: ImportedMacosScreenPlaneStorage, + core_video_wrapper: Option, +} + +#[derive(Debug, Clone)] +enum ImportedMacosScreenPlaneStorage { + Wgpu(ImportedEffectFrame), + NativeMetal(RetainedMetalTexture), +} + +#[derive(Debug, Clone)] +struct RetainedMetalTexture(Retained>); + +// SAFETY: Metal resource objects have immutable identity and allocation +// properties after creation. Hypercolor only retains and borrows this texture; +// all content access is encoded through Metal command queues with resource +// hazard tracking and the capture owner remains alive through GPU completion. +unsafe impl Send for RetainedMetalTexture {} + +// SAFETY: shared references expose only immutable resource inspection and +// command encoding. Mutable contents remain synchronized by Metal, not Rust. +unsafe impl Sync for RetainedMetalTexture {} + +#[derive(Debug, Clone)] +struct RetainedCoreVideoTexture { + _wrapper: CFRetained, +} + +// SAFETY: the wrapper is retained only as immutable ownership for its Metal +// texture. Hypercolor never mutates the Core Video wrapper after creation. +unsafe impl Send for RetainedCoreVideoTexture {} + +// SAFETY: shared access is limited to retaining and releasing the immutable +// wrapper; pixel contents are synchronized through the retained Metal texture. +unsafe impl Sync for RetainedCoreVideoTexture {} + +struct SendableCoreVideoTextureCache(CFRetained); + +// SAFETY: every operation on this cache is serialized by its containing +// mutex. Moving the retained Core Foundation reference does not invoke it. +unsafe impl Send for SendableCoreVideoTextureCache {} + +impl SendableCoreVideoTextureCache { + fn cache(&self) -> &CVMetalTextureCache { + &self.0 + } +} + +/// Exact native format retained for one imported capture plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ImportedMacosScreenPlaneFormat { + /// A format represented directly by wgpu. + Wgpu(ImportedFrameFormat), + /// ScreenCaptureKit `l10r` represented by Metal BGR10A2 semantics. + Bgr10A2Unorm, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum CapturePlaneImportDescriptor { + Wgpu(MacosIosurfaceImportDescriptor), + Bgr10A2Unorm { width: u32, height: u32 }, +} + +impl CapturePlaneImportDescriptor { + fn new( + width: u32, + height: u32, + format: ImportedMacosScreenPlaneFormat, + ) -> Result { + match format { + ImportedMacosScreenPlaneFormat::Wgpu(format) => Ok(Self::Wgpu( + MacosIosurfaceImportDescriptor::new(width, height, format)?, + )), + ImportedMacosScreenPlaneFormat::Bgr10A2Unorm => { + if width == 0 + || height == 0 + || width > i32::MAX as u32 / 4 + || height > i32::MAX as u32 + { + return Err(MacosGpuInteropError::InvalidDimensions { width, height }.into()); + } + Ok(Self::Bgr10A2Unorm { width, height }) + } + } + } + + const fn minimum_bytes_per_row(self) -> u32 { + match self { + Self::Wgpu(descriptor) => descriptor.width * descriptor.format.bytes_per_texel(), + Self::Bgr10A2Unorm { width, .. } => width * 4, + } + } } impl ImportedMacosScreenFrame { - /// Complete physical storage identity used by the wrapper cache. + /// Complete physical storage identity of the first imported plane. + /// + /// Packed RGB frames have exactly one plane. Multi-plane callers should + /// inspect [`Self::planes`] instead. #[must_use] - pub const fn storage_identity(&self) -> MacosScreenStorageIdentity { - self.storage_identity + pub fn storage_identity(&self) -> MacosScreenStorageIdentity { + self.first_plane().storage_identity } /// Monotonic content identity within the capture session. @@ -65,26 +176,123 @@ impl ImportedMacosScreenFrame { &self.capture } + /// Every imported IOSurface plane in source order. + #[must_use] + pub fn planes(&self) -> &[ImportedMacosScreenPlane] { + &self.planes + } + + /// Complete physical storage identities for every imported plane. + pub fn storage_identities( + &self, + ) -> impl ExactSizeIterator + '_ { + self.planes.iter().map(|plane| plane.storage_identity) + } + + /// Imported wgpu texture for a packed wgpu-representable frame. + #[must_use] + pub fn texture(&self) -> Option<&Arc> { + self.first_plane().texture() + } + + /// Default view over a packed wgpu-representable frame. + #[must_use] + pub fn view(&self) -> Option<&Arc> { + self.first_plane().view() + } + + fn first_plane(&self) -> &ImportedMacosScreenPlane { + self.planes + .first() + .expect("validated capture imports always retain at least one plane") + } +} + +impl ImportedMacosScreenPlane { + /// Complete physical storage identity used by the wrapper cache. + #[must_use] + pub const fn storage_identity(&self) -> MacosScreenStorageIdentity { + self.storage_identity + } + + /// Exact wrapped texture format for this plane. + #[must_use] + pub const fn format(&self) -> ImportedMacosScreenPlaneFormat { + self.format + } + /// Imported wgpu texture. #[must_use] - pub fn texture(&self) -> &Arc { - &self.imported.texture + pub fn texture(&self) -> Option<&Arc> { + match &self.storage { + ImportedMacosScreenPlaneStorage::Wgpu(imported) => Some(&imported.texture), + ImportedMacosScreenPlaneStorage::NativeMetal(_) => None, + } + } + + /// Default view over the imported plane texture. + #[must_use] + pub fn view(&self) -> Option<&Arc> { + match &self.storage { + ImportedMacosScreenPlaneStorage::Wgpu(imported) => Some(&imported.view), + ImportedMacosScreenPlaneStorage::NativeMetal(_) => None, + } } - /// Default view over the imported texture. + /// Whether Core Video owns the retained Metal texture wrapper. #[must_use] - pub fn view(&self) -> &Arc { - &self.imported.view + pub const fn uses_core_video_texture_cache(&self) -> bool { + self.core_video_wrapper.is_some() + } + + /// Borrows the exact native Metal texture for immediate GPU encoding. + pub fn with_metal_texture( + &self, + operation: impl FnOnce(&ProtocolObject) -> R, + ) -> Result { + match &self.storage { + ImportedMacosScreenPlaneStorage::Wgpu(imported) => { + // SAFETY: the guard is borrowed only for this immediate call, + // and the imported texture is known to use the Metal backend. + let texture = unsafe { imported.texture.as_hal::() } + .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice)?; + Ok(operation(texture.raw_handle())) + } + ImportedMacosScreenPlaneStorage::NativeMetal(texture) => Ok(operation(&texture.0)), + } } } +/// Native importer candidate used for a retained capture frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosScreenImporterCandidate { + /// Direct `MTLDevice` IOSurface texture creation. + DirectIosurface, + /// Core Video's Metal texture cache. + CoreVideoTextureCache, +} + /// Errors raised while importing a ScreenCaptureKit frame. #[derive(Debug, Error)] #[non_exhaustive] pub enum MacosScreenBridgeError { - /// The frame does not satisfy the packed BGRA import contract. + /// The frame does not satisfy the native import contract. #[error("invalid macOS capture frame: {0}")] InvalidFrame(&'static str), + /// Both zero-copy importer candidates rejected the frame. + #[error( + "macOS screen import failed via {first_candidate:?}: {first_error}; then {second_candidate:?}: {second_error}" + )] + ImportCandidatesFailed { + /// Candidate attempted first for the active GPU family. + first_candidate: MacosScreenImporterCandidate, + /// Bounded first-candidate result. + first_error: String, + /// Candidate attempted second for the active GPU family. + second_candidate: MacosScreenImporterCandidate, + /// Bounded second-candidate result. + second_error: String, + }, /// The capture surface could not provide native handles. #[error("macOS capture surface handoff failed: {0}")] SurfaceHandoff(String), @@ -98,16 +306,27 @@ pub struct MacosScreenBridge { metal_registry_id: u64, storage_mode: MacosMetalStorageMode, importers: Mutex>, + core_video_cache: Option>, + core_video_cache_error: Option, + native_wrappers: Mutex>, } impl MacosScreenBridge { /// Bind a bridge to one Metal-backed wgpu device. pub fn new(device: &wgpu::Device) -> Result { let (metal_registry_id, storage_mode) = metal_device_import_contract(device)?; + let (core_video_cache, core_video_cache_error) = + match create_core_video_texture_cache(device, storage_mode) { + Ok(cache) => (Some(Mutex::new(SendableCoreVideoTextureCache(cache))), None), + Err(error) => (None, Some(bounded_import_error(&error))), + }; Ok(Self { metal_registry_id, storage_mode, importers: Mutex::new(HashMap::new()), + core_video_cache, + core_video_cache_error, + native_wrappers: Mutex::new(HashMap::new()), }) } @@ -123,142 +342,704 @@ impl MacosScreenBridge { self.storage_mode } - /// Import one retained packed BGRA frame without a full-frame CPU copy. - pub fn import_bgra_frame( + /// Import every directly representable plane without a full-frame CPU copy. + pub fn import_frame( &self, device: &wgpu::Device, resource_generation: u64, frame: Arc, ) -> Result { - validate_bgra_frame(&frame, resource_generation)?; - let descriptor = MacosIosurfaceImportDescriptor::new( - frame.storage_extent.width, - frame.storage_extent.height, - ImportedFrameFormat::Bgra8Unorm, + let device_contract = metal_device_import_contract(device)?; + validate_import_device_contract( + self.metal_registry_id, + self.storage_mode, + device_contract, )?; - let plane = frame - .planes - .first() - .ok_or(MacosScreenBridgeError::InvalidFrame("missing packed plane"))?; - let storage_identity = MacosScreenStorageIdentity { - capture_session_generation: frame.epoch, - resource_generation, - iosurface_id: frame.surface.iosurface_id, - plane: plane.index, - extent: plane.extent, - bytes_per_row: plane.bytes_per_row, - pixel_format: frame.pixel_format, - allocation_bytes: frame.surface.allocation_bytes, - storage_mode: self.storage_mode, - metal_registry_id: self.metal_registry_id, - }; - let imported = frame + let plane_descriptors = validate_frame(&frame, resource_generation)?; + let source_pixel_format = frame + .pixel_format + .fourcc(frame.color.range) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("invalid source color range"))?; + let imported_planes = frame .surface .with_native_surface(|lease| { // SAFETY: the opaque lease was created from this exact // retained IOSurface and cannot outlive this closure. let iosurface = unsafe { lease.iosurface_ptr().cast::().as_ref() }; - validate_native_surface(iosurface, storage_identity)?; - let mut importers = self - .importers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if !importers.contains_key(&descriptor) { - if importers.len() >= MAX_CAPTURE_DESCRIPTORS { - importers.clear(); - } - importers.insert(descriptor, MacosIosurfaceImporter::new(device, descriptor)?); + // SAFETY: the opaque lease was created from this exact + // retained pixel buffer and cannot outlive this closure. + let pixel_buffer = + unsafe { lease.pixel_buffer_ptr().cast::().as_ref() }; + validate_native_surface(iosurface, &frame)?; + let candidates = importer_candidate_order(self.storage_mode); + let first = self.import_candidate( + candidates[0], + device, + iosurface, + pixel_buffer, + &frame, + resource_generation, + source_pixel_format, + &plane_descriptors, + ); + let first_error = match first { + Ok(planes) => return Ok(planes), + Err(error) => bounded_import_error(&error), + }; + match self.import_candidate( + candidates[1], + device, + iosurface, + pixel_buffer, + &frame, + resource_generation, + source_pixel_format, + &plane_descriptors, + ) { + Ok(planes) => Ok(planes), + Err(error) => Err(MacosScreenBridgeError::ImportCandidatesFailed { + first_candidate: candidates[0], + first_error, + second_candidate: candidates[1], + second_error: bounded_import_error(&error), + }), } - let importer = - importers - .get_mut(&descriptor) - .ok_or(MacosScreenBridgeError::InvalidFrame( - "capture importer cache insertion failed", - ))?; - Ok::( - importer.import_iosurface_scoped( - device, - iosurface, - frame.sequence, - frame.epoch, - resource_generation, - )?, - ) }) .map_err(|error| MacosScreenBridgeError::SurfaceHandoff(error.to_string()))??; Ok(ImportedMacosScreenFrame { - storage_identity, content_sequence: frame.sequence, capture: frame, - imported, + planes: imported_planes.into(), }) } + #[allow(clippy::too_many_arguments)] + fn import_candidate( + &self, + candidate: MacosScreenImporterCandidate, + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + pixel_buffer: &CVPixelBuffer, + frame: &MacosCaptureFrame, + resource_generation: u64, + source_pixel_format: u32, + descriptors: &[CapturePlaneImportDescriptor], + ) -> Result, MacosScreenBridgeError> { + match candidate { + MacosScreenImporterCandidate::DirectIosurface => self.import_direct_planes( + device, + iosurface, + frame, + resource_generation, + source_pixel_format, + descriptors, + ), + MacosScreenImporterCandidate::CoreVideoTextureCache => self.import_core_video_planes( + device, + pixel_buffer, + frame, + resource_generation, + source_pixel_format, + descriptors, + ), + } + } + + fn import_direct_planes( + &self, + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + frame: &MacosCaptureFrame, + resource_generation: u64, + source_pixel_format: u32, + descriptors: &[CapturePlaneImportDescriptor], + ) -> Result, MacosScreenBridgeError> { + let mut imported_planes = admitted_plane_vector(descriptors.len())?; + for (plane, descriptor) in frame.planes.iter().zip(descriptors) { + let plane_index = usize::try_from(plane.index).map_err(|_| { + MacosScreenBridgeError::InvalidFrame("capture plane index exceeds usize") + })?; + let storage_identity = + self.storage_identity(frame, plane, resource_generation, source_pixel_format); + let imported_plane = match descriptor { + CapturePlaneImportDescriptor::Wgpu(descriptor) => { + let mut importers = self + .importers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !importers.contains_key(descriptor) { + if importers.len() >= MAX_CAPTURE_DESCRIPTORS { + importers.clear(); + } + importers.insert( + *descriptor, + MacosIosurfaceImporter::new(device, *descriptor)?, + ); + } + let importer = importers.get_mut(descriptor).ok_or( + MacosScreenBridgeError::InvalidFrame( + "capture importer cache insertion failed", + ), + )?; + let imported = importer.import_iosurface_plane_scoped( + device, + iosurface, + frame.sequence, + frame.epoch, + resource_generation, + plane_index, + source_pixel_format, + )?; + ImportedMacosScreenPlane { + storage_identity, + format: ImportedMacosScreenPlaneFormat::Wgpu(descriptor.format), + storage: ImportedMacosScreenPlaneStorage::Wgpu(imported), + core_video_wrapper: None, + } + } + CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } => self + .native_wrapper_or_insert(storage_identity, || { + let texture = import_iosurface_metal_texture_plane( + device, + iosurface, + *width, + *height, + plane_index, + source_pixel_format, + MTLPixelFormat::BGR10A2Unorm, + self.storage_mode, + )?; + Ok(ImportedMacosScreenPlane { + storage_identity, + format: ImportedMacosScreenPlaneFormat::Bgr10A2Unorm, + storage: ImportedMacosScreenPlaneStorage::NativeMetal( + RetainedMetalTexture(texture), + ), + core_video_wrapper: None, + }) + })?, + }; + imported_planes.push(imported_plane); + } + Ok(imported_planes) + } + + fn import_core_video_planes( + &self, + device: &wgpu::Device, + pixel_buffer: &CVPixelBuffer, + frame: &MacosCaptureFrame, + resource_generation: u64, + source_pixel_format: u32, + descriptors: &[CapturePlaneImportDescriptor], + ) -> Result, MacosScreenBridgeError> { + let cache = self.core_video_cache.as_ref().ok_or_else(|| { + MacosScreenBridgeError::SurfaceHandoff( + self.core_video_cache_error + .clone() + .unwrap_or_else(|| "Core Video texture cache unavailable".to_owned()), + ) + })?; + let cache = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wrappers = self + .native_wrappers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut imported_planes = admitted_plane_vector(descriptors.len())?; + for (plane, descriptor) in frame.planes.iter().zip(descriptors) { + let storage_identity = + self.storage_identity(frame, plane, resource_generation, source_pixel_format); + if let Some(cached) = wrappers.get(&storage_identity) { + imported_planes.push(cached.clone()); + continue; + } + let plane_index = usize::try_from(plane.index).map_err(|_| { + MacosScreenBridgeError::InvalidFrame("capture plane index exceeds usize") + })?; + let imported_plane = match descriptor { + CapturePlaneImportDescriptor::Wgpu(descriptor) => { + let (imported, wrapper) = import_core_video_pixel_buffer_plane( + device, + cache.cache(), + pixel_buffer, + *descriptor, + plane_index, + frame.surface.iosurface_id, + self.storage_mode, + frame.sequence, + )?; + ImportedMacosScreenPlane { + storage_identity, + format: ImportedMacosScreenPlaneFormat::Wgpu(descriptor.format), + storage: ImportedMacosScreenPlaneStorage::Wgpu(imported), + core_video_wrapper: Some(RetainedCoreVideoTexture { _wrapper: wrapper }), + } + } + CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } => { + let (texture, wrapper, _) = import_core_video_metal_texture_plane( + cache.cache(), + pixel_buffer, + *width, + *height, + plane_index, + MTLPixelFormat::BGR10A2Unorm, + frame.surface.iosurface_id, + self.storage_mode, + )?; + ImportedMacosScreenPlane { + storage_identity, + format: ImportedMacosScreenPlaneFormat::Bgr10A2Unorm, + storage: ImportedMacosScreenPlaneStorage::NativeMetal( + RetainedMetalTexture(texture), + ), + core_video_wrapper: Some(RetainedCoreVideoTexture { _wrapper: wrapper }), + } + } + }; + if wrappers.len() >= MAX_CORE_VIDEO_WRAPPERS { + wrappers.clear(); + cache.cache().flush(0); + } + wrappers.insert(storage_identity, imported_plane.clone()); + imported_planes.push(imported_plane); + } + Ok(imported_planes) + } + + fn storage_identity( + &self, + frame: &MacosCaptureFrame, + plane: &hypercolor_macos_capture::MacosCapturePlane, + resource_generation: u64, + source_fourcc: u32, + ) -> MacosScreenStorageIdentity { + MacosScreenStorageIdentity { + capture_session_generation: frame.epoch, + resource_generation, + iosurface_id: frame.surface.iosurface_id, + plane: plane.index, + extent: plane.extent, + bytes_per_row: plane.bytes_per_row, + pixel_format: frame.pixel_format, + source_fourcc, + allocation_bytes: frame.surface.allocation_bytes, + storage_mode: self.storage_mode, + metal_registry_id: self.metal_registry_id, + } + } + + /// Import one retained packed BGRA frame without a full-frame CPU copy. + pub fn import_bgra_frame( + &self, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result { + if frame.pixel_format != MacosCapturePixelFormat::Bgra8 { + return Err(MacosScreenBridgeError::InvalidFrame( + "packed BGRA import received another pixel format", + )); + } + self.import_frame(device, resource_generation, frame) + } + /// Number of cached physical IOSurface wrappers across live descriptors. #[must_use] pub fn cached_wrap_count(&self) -> usize { - self.importers + let direct = self + .importers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .values() - .fold(0, |total, importer| { + .fold(0_usize, |total, importer| { total.saturating_add(importer.cached_wrap_count()) - }) + }); + direct.saturating_add( + self.native_wrappers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + ) + } + + fn native_wrapper_or_insert( + &self, + identity: MacosScreenStorageIdentity, + create: impl FnOnce() -> Result, + ) -> Result { + let (plane, flush_core_video) = { + let mut wrappers = self + .native_wrappers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(cached) = wrappers.get(&identity) { + return Ok(cached.clone()); + } + let plane = create()?; + let flush_core_video = wrappers.len() >= MAX_CORE_VIDEO_WRAPPERS; + if flush_core_video { + wrappers.clear(); + } + wrappers.insert(identity, plane.clone()); + (plane, flush_core_video) + }; + if flush_core_video { + self.flush_core_video_cache(); + } + Ok(plane) + } + + fn flush_core_video_cache(&self) { + if let Some(cache) = &self.core_video_cache { + cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .cache() + .flush(0); + } } } -fn validate_bgra_frame( +fn validate_import_device_contract( + expected_registry_id: u64, + expected_storage_mode: MacosMetalStorageMode, + actual: (u64, MacosMetalStorageMode), +) -> Result<(), MacosScreenBridgeError> { + if actual.0 != expected_registry_id { + return Err(MacosGpuInteropError::MetalRegistryIdMismatch { + expected: expected_registry_id, + actual: actual.0, + } + .into()); + } + if actual.1 != expected_storage_mode { + return Err(MacosGpuInteropError::MetalStorageModeMismatch { + expected: expected_storage_mode, + actual: actual.1, + } + .into()); + } + Ok(()) +} + +fn importer_candidate_order( + storage_mode: MacosMetalStorageMode, +) -> [MacosScreenImporterCandidate; 2] { + match storage_mode { + MacosMetalStorageMode::Shared => [ + MacosScreenImporterCandidate::DirectIosurface, + MacosScreenImporterCandidate::CoreVideoTextureCache, + ], + MacosMetalStorageMode::Managed => [ + MacosScreenImporterCandidate::CoreVideoTextureCache, + MacosScreenImporterCandidate::DirectIosurface, + ], + } +} + +fn admitted_plane_vector( + capacity: usize, +) -> Result, MacosScreenBridgeError> { + let mut planes = Vec::new(); + planes.try_reserve_exact(capacity).map_err(|_| { + MacosScreenBridgeError::InvalidFrame("capture plane metadata allocation failed") + })?; + Ok(planes) +} + +fn bounded_import_error(error: &impl std::fmt::Display) -> String { + const MAX_IMPORT_ERROR_CHARS: usize = 512; + error + .to_string() + .chars() + .take(MAX_IMPORT_ERROR_CHARS) + .collect() +} + +fn validate_frame( frame: &MacosCaptureFrame, resource_generation: u64, -) -> Result<(), MacosScreenBridgeError> { +) -> Result, MacosScreenBridgeError> { if frame.epoch == 0 || resource_generation == 0 { return Err(MacosScreenBridgeError::InvalidFrame( "capture and resource generations must be nonzero", )); } - if frame.pixel_format != MacosCapturePixelFormat::Bgra8 { + let expected_formats = capture_plane_formats(frame.pixel_format)?; + if frame.planes.len() != expected_formats.len() { return Err(MacosScreenBridgeError::InvalidFrame( - "packed BGRA import received another pixel format", + "capture plane count does not match its pixel format", )); } - let [plane] = &*frame.planes else { - return Err(MacosScreenBridgeError::InvalidFrame( - "packed BGRA import requires exactly one plane", - )); - }; - let minimum_stride = usize::try_from(frame.storage_extent.width) - .ok() - .and_then(|width| width.checked_mul(4)) - .ok_or(MacosScreenBridgeError::InvalidFrame( - "packed BGRA stride overflowed", - ))?; - if plane.index != 0 - || plane.extent != frame.storage_extent - || plane.bytes_per_row < minimum_stride + let mut descriptors = Vec::new(); + descriptors + .try_reserve_exact(frame.planes.len()) + .map_err(|_| { + MacosScreenBridgeError::InvalidFrame("capture plane descriptor allocation failed") + })?; + for (index, (plane, format)) in frame.planes.iter().zip(expected_formats).enumerate() { + if usize::try_from(plane.index).ok() != Some(index) { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture plane indices are not canonical", + )); + } + let expected_extent = capture_plane_extent(frame.pixel_format, frame.storage_extent, index); + if plane.extent != expected_extent { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture plane extent does not match its pixel format", + )); + } + let descriptor = + CapturePlaneImportDescriptor::new(plane.extent.width, plane.extent.height, *format)?; + let minimum_stride = usize::try_from(plane.extent.width) + .ok() + .and_then(|_| usize::try_from(descriptor.minimum_bytes_per_row()).ok()) + .ok_or(MacosScreenBridgeError::InvalidFrame( + "capture plane stride overflowed", + ))?; + let minimum_length = u64::try_from(plane.bytes_per_row) + .ok() + .and_then(|stride| stride.checked_mul(u64::from(plane.extent.height))) + .ok_or(MacosScreenBridgeError::InvalidFrame( + "capture plane length overflowed", + ))?; + if plane.bytes_per_row < minimum_stride || plane.length_bytes < minimum_length { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture plane storage is smaller than its descriptor", + )); + } + descriptors.push(descriptor); + } + Ok(descriptors) +} + +fn capture_plane_formats( + pixel_format: MacosCapturePixelFormat, +) -> Result<&'static [ImportedMacosScreenPlaneFormat], MacosScreenBridgeError> { + const BGRA: &[ImportedMacosScreenPlaneFormat] = &[ImportedMacosScreenPlaneFormat::Wgpu( + ImportedFrameFormat::Bgra8Unorm, + )]; + const RGB10: &[ImportedMacosScreenPlaneFormat] = + &[ImportedMacosScreenPlaneFormat::Bgr10A2Unorm]; + const RGBA16: &[ImportedMacosScreenPlaneFormat] = &[ImportedMacosScreenPlaneFormat::Wgpu( + ImportedFrameFormat::Rgba16Float, + )]; + const YUV420: &[ImportedMacosScreenPlaneFormat] = &[ + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R8Unorm), + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg8Unorm), + ]; + const YUV44410: &[ImportedMacosScreenPlaneFormat] = &[ + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R16Unorm), + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg16Unorm), + ]; + + match pixel_format { + MacosCapturePixelFormat::Bgra8 => Ok(BGRA), + MacosCapturePixelFormat::Rgba16Float => Ok(RGBA16), + MacosCapturePixelFormat::Yuv420VideoRange | MacosCapturePixelFormat::Yuv420FullRange => { + Ok(YUV420) + } + MacosCapturePixelFormat::Yuv44410BiPlanar => Ok(YUV44410), + MacosCapturePixelFormat::Argb2101010 => Ok(RGB10), + } +} + +const fn capture_plane_extent( + pixel_format: MacosCapturePixelFormat, + storage_extent: MacosPixelExtent, + plane: usize, +) -> MacosPixelExtent { + if matches!( + pixel_format, + MacosCapturePixelFormat::Yuv420VideoRange | MacosCapturePixelFormat::Yuv420FullRange + ) && plane == 1 { - return Err(MacosScreenBridgeError::InvalidFrame( - "packed BGRA plane descriptor is inconsistent", - )); + MacosPixelExtent { + width: storage_extent.width.div_ceil(2), + height: storage_extent.height.div_ceil(2), + } + } else { + storage_extent } - Ok(()) } fn validate_native_surface( iosurface: &IOSurfaceRef, - expected: MacosScreenStorageIdentity, + frame: &MacosCaptureFrame, ) -> Result<(), MacosScreenBridgeError> { let allocation_bytes = u64::try_from(iosurface.alloc_size()) .map_err(|_| MacosScreenBridgeError::InvalidFrame("IOSurface allocation exceeds u64"))?; - if iosurface.id() != expected.iosurface_id - || iosurface.width() != expected.extent.width as usize - || iosurface.height() != expected.extent.height as usize - || iosurface.bytes_per_row() != expected.bytes_per_row - || allocation_bytes != expected.allocation_bytes + let source_pixel_format = frame + .pixel_format + .fourcc(frame.color.range) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("invalid source color range"))?; + if iosurface.id() != frame.surface.iosurface_id + || iosurface.width() != frame.storage_extent.width as usize + || iosurface.height() != frame.storage_extent.height as usize + || iosurface.pixel_format() != source_pixel_format + || allocation_bytes != frame.surface.allocation_bytes { return Err(MacosScreenBridgeError::InvalidFrame( "IOSurface physical descriptor changed after capture validation", )); } + let native_plane_count = iosurface.plane_count(); + if frame.planes.len() == 1 && native_plane_count == 0 { + if iosurface.bytes_per_row() != frame.planes[0].bytes_per_row { + return Err(MacosScreenBridgeError::InvalidFrame( + "IOSurface packed stride changed after capture validation", + )); + } + return Ok(()); + } + if native_plane_count != frame.planes.len() { + return Err(MacosScreenBridgeError::InvalidFrame( + "IOSurface plane count changed after capture validation", + )); + } + for plane in &*frame.planes { + let index = usize::try_from(plane.index) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("plane index exceeds usize"))?; + if iosurface.width_of_plane(index) != plane.extent.width as usize + || iosurface.height_of_plane(index) != plane.extent.height as usize + || iosurface.bytes_per_row_of_plane(index) != plane.bytes_per_row + { + return Err(MacosScreenBridgeError::InvalidFrame( + "IOSurface plane descriptor changed after capture validation", + )); + } + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capture_formats_map_to_exact_direct_plane_formats() { + assert_eq!( + capture_plane_formats(MacosCapturePixelFormat::Bgra8) + .expect("BGRA should import directly"), + &[ImportedMacosScreenPlaneFormat::Wgpu( + ImportedFrameFormat::Bgra8Unorm + )] + ); + assert_eq!( + capture_plane_formats(MacosCapturePixelFormat::Rgba16Float) + .expect("RGBA16Float should import directly"), + &[ImportedMacosScreenPlaneFormat::Wgpu( + ImportedFrameFormat::Rgba16Float + )] + ); + for format in [ + MacosCapturePixelFormat::Yuv420VideoRange, + MacosCapturePixelFormat::Yuv420FullRange, + ] { + assert_eq!( + capture_plane_formats(format).expect("8-bit YUV should import directly"), + &[ + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R8Unorm), + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg8Unorm) + ] + ); + } + assert_eq!( + capture_plane_formats(MacosCapturePixelFormat::Yuv44410BiPlanar) + .expect("10-bit YUV should import directly"), + &[ + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R16Unorm), + ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg16Unorm) + ] + ); + assert_eq!( + capture_plane_formats(MacosCapturePixelFormat::Argb2101010) + .expect("ARGB2101010 should import as native BGR10A2"), + &[ImportedMacosScreenPlaneFormat::Bgr10A2Unorm] + ); + } + + #[test] + fn yuv420_chroma_extent_uses_ceil_division() { + let storage = MacosPixelExtent { + width: 1_919, + height: 1_079, + }; + assert_eq!( + capture_plane_extent(MacosCapturePixelFormat::Yuv420FullRange, storage, 0), + storage + ); + assert_eq!( + capture_plane_extent(MacosCapturePixelFormat::Yuv420FullRange, storage, 1), + MacosPixelExtent { + width: 960, + height: 540 + } + ); + } + + #[test] + fn importer_order_is_selected_by_gpu_family_storage_contract() { + assert_eq!( + importer_candidate_order(MacosMetalStorageMode::Shared), + [ + MacosScreenImporterCandidate::DirectIosurface, + MacosScreenImporterCandidate::CoreVideoTextureCache + ] + ); + assert_eq!( + importer_candidate_order(MacosMetalStorageMode::Managed), + [ + MacosScreenImporterCandidate::CoreVideoTextureCache, + MacosScreenImporterCandidate::DirectIosurface + ] + ); + } + + #[test] + fn importer_errors_are_bounded() { + let oversized = "x".repeat(1_024); + assert_eq!(bounded_import_error(&oversized).len(), 512); + } + + #[test] + fn import_device_contract_requires_registry_and_storage_mode_identity() { + assert!( + validate_import_device_contract( + 7, + MacosMetalStorageMode::Shared, + (7, MacosMetalStorageMode::Shared) + ) + .is_ok() + ); + assert!(matches!( + validate_import_device_contract( + 7, + MacosMetalStorageMode::Shared, + (8, MacosMetalStorageMode::Shared), + ), + Err(MacosScreenBridgeError::Interop( + MacosGpuInteropError::MetalRegistryIdMismatch { + expected: 7, + actual: 8, + } + )) + )); + assert!(matches!( + validate_import_device_contract( + 7, + MacosMetalStorageMode::Shared, + (7, MacosMetalStorageMode::Managed), + ), + Err(MacosScreenBridgeError::Interop( + MacosGpuInteropError::MetalStorageModeMismatch { + expected: MacosMetalStorageMode::Shared, + actual: MacosMetalStorageMode::Managed, + } + )) + )); + } +} diff --git a/crates/hypercolor-macos-gpu-interop/src/stubs.rs b/crates/hypercolor-macos-gpu-interop/src/stubs.rs index d138f2afb..f9124a012 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -2,8 +2,6 @@ use std::sync::Arc; use thiserror::Error; -const BYTES_PER_PIXEL: u32 = 4; - /// Result type for macOS GPU interop operations. pub type Result = std::result::Result; @@ -49,6 +47,16 @@ pub enum MacosMetalStorageMode { pub enum ImportedFrameFormat { /// 8-bit normalized BGRA. Bgra8Unorm, + /// 16-bit floating-point RGBA. + Rgba16Float, + /// One 8-bit normalized component. + R8Unorm, + /// Two 8-bit normalized components. + Rg8Unorm, + /// One 16-bit normalized component. + R16Unorm, + /// Two 16-bit normalized components. + Rg16Unorm, } impl ImportedFrameFormat { @@ -57,6 +65,21 @@ impl ImportedFrameFormat { pub const fn wgpu_format(self) -> wgpu::TextureFormat { match self { Self::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm, + Self::Rgba16Float => wgpu::TextureFormat::Rgba16Float, + Self::R8Unorm => wgpu::TextureFormat::R8Unorm, + Self::Rg8Unorm => wgpu::TextureFormat::Rg8Unorm, + Self::R16Unorm => wgpu::TextureFormat::R16Unorm, + Self::Rg16Unorm => wgpu::TextureFormat::Rg16Unorm, + } + } + + const fn bytes_per_texel(self) -> u32 { + match self { + Self::Bgra8Unorm => 4, + Self::Rgba16Float => 8, + Self::R8Unorm => 1, + Self::Rg8Unorm | Self::R16Unorm => 2, + Self::Rg16Unorm => 4, } } } @@ -77,7 +100,7 @@ impl MacosIosurfaceImportDescriptor { pub const fn new(width: u32, height: u32, format: ImportedFrameFormat) -> Result { if width == 0 || height == 0 - || width > i32::MAX as u32 / BYTES_PER_PIXEL + || width > i32::MAX as u32 / format.bytes_per_texel() || height > i32::MAX as u32 { Err(MacosGpuInteropError::InvalidDimensions { width, height }) diff --git a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs index 46b20ca65..a58775e37 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs @@ -30,3 +30,46 @@ fn descriptor_accepts_largest_iosurface_row_shape() { assert_eq!(descriptor.height, 1); assert_eq!(descriptor.format, ImportedFrameFormat::Bgra8Unorm); } + +#[test] +fn capture_plane_formats_map_to_exact_wgpu_formats() { + let mappings = [ + ( + ImportedFrameFormat::Bgra8Unorm, + wgpu::TextureFormat::Bgra8Unorm, + ), + ( + ImportedFrameFormat::Rgba16Float, + wgpu::TextureFormat::Rgba16Float, + ), + (ImportedFrameFormat::R8Unorm, wgpu::TextureFormat::R8Unorm), + (ImportedFrameFormat::Rg8Unorm, wgpu::TextureFormat::Rg8Unorm), + (ImportedFrameFormat::R16Unorm, wgpu::TextureFormat::R16Unorm), + ( + ImportedFrameFormat::Rg16Unorm, + wgpu::TextureFormat::Rg16Unorm, + ), + ]; + + for (format, expected) in mappings { + assert_eq!(format.wgpu_format(), expected); + } +} + +#[test] +fn descriptor_bounds_each_format_by_its_exact_texel_width() { + let formats = [ + (ImportedFrameFormat::R8Unorm, 1), + (ImportedFrameFormat::Rg8Unorm, 2), + (ImportedFrameFormat::R16Unorm, 2), + (ImportedFrameFormat::Bgra8Unorm, 4), + (ImportedFrameFormat::Rg16Unorm, 4), + (ImportedFrameFormat::Rgba16Float, 8), + ]; + + for (format, bytes_per_texel) in formats { + let maximum_width = i32::MAX as u32 / bytes_per_texel; + assert!(MacosIosurfaceImportDescriptor::new(maximum_width, 1, format).is_ok()); + assert!(MacosIosurfaceImportDescriptor::new(maximum_width + 1, 1, format).is_err()); + } +} diff --git a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs index f332094f3..f934ece0f 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -34,6 +34,12 @@ fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), S assert_eq!(first.content_sequence(), 0); assert_eq!(first.storage_identity().capture_session_generation, 5); assert_eq!(first.storage_identity().resource_generation, 11); + assert_eq!( + first.storage_identity().source_fourcc, + MacosCapturePixelFormat::Bgra8 + .fourcc(MacosColorRange::Full) + .expect("BGRA has one canonical full-range FourCC") + ); assert_eq!( first.storage_identity().iosurface_id, frame.surface.iosurface_id @@ -43,18 +49,40 @@ fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), S frame.planes[0].bytes_per_row ); assert!(Arc::ptr_eq(first.capture(), &frame)); - assert!(Arc::ptr_eq(first.texture(), second.texture())); - assert!(Arc::ptr_eq(first.view(), second.view())); + assert_eq!(first.planes().len(), 1); + assert_eq!( + first.planes()[0].format(), + hypercolor_macos_gpu_interop::ImportedMacosScreenPlaneFormat::Wgpu( + hypercolor_macos_gpu_interop::ImportedFrameFormat::Bgra8Unorm + ) + ); + assert_eq!( + first.planes()[0].storage_identity(), + first.storage_identity() + ); + assert_eq!( + first.storage_identities().collect::>(), + vec![first.storage_identity()] + ); + let first_texture = first.texture().expect("BGRA import has a wgpu texture"); + let second_texture = second.texture().expect("BGRA import has a wgpu texture"); + let first_view = first.view().expect("BGRA import has a wgpu view"); + let second_view = second.view().expect("BGRA import has a wgpu view"); + assert!(Arc::ptr_eq(first_texture, second_texture)); + assert!(Arc::ptr_eq(first_view, second_view)); assert_eq!(bridge.cached_wrap_count(), 1); assert_eq!( - read_texture_pixels(&wgpu.device, &wgpu.queue, first.texture(), WIDTH, HEIGHT,)?, + read_texture_pixels(&wgpu.device, &wgpu.queue, first_texture, WIDTH, HEIGHT,)?, fixture_pixels() ); let next_resource = bridge .import_bgra_frame(&wgpu.device, 12, Arc::clone(&frame)) .map_err(|error| error.to_string())?; - assert!(!Arc::ptr_eq(first.texture(), next_resource.texture())); + let next_texture = next_resource + .texture() + .expect("BGRA import has a wgpu texture"); + assert!(!Arc::ptr_eq(first_texture, next_texture)); assert_eq!(bridge.cached_wrap_count(), 2); drop(frame); From 6f1d11c0cd46a3a8fe95bd81503919ceff6eabcb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 01:06:16 -0700 Subject: [PATCH 066/144] feat(screen): defer exact native GPU reduction Keep raw IOSurfaces truthful to their delivered storage identity while representing transformed branches as renderer-owned native work. Bind every native publication to its exact target, descriptor, worker, and capture lifetime so cross-route substitutions fail before publish. The already-correct identity fast path remains a direct GPU surface. Co-Authored-By: Nova (GPT-5.6) --- .../hypercolor-core/src/input/screen/hub.rs | 194 ++++++++++++++++-- .../hypercolor-core/src/input/screen/macos.rs | 183 ++++++++++------- .../hypercolor-core/src/input/screen/mod.rs | 10 +- .../hypercolor-core/src/input/screen/plan.rs | 31 +++ .../src/input/screen/publication.rs | 21 ++ .../src/input/screen/wayland/tests.rs | 4 +- .../tests/screen_cpu_publication_tests.rs | 4 +- ...creen_gpu_publication_reclamation_tests.rs | 102 ++++++++- ...creen_native_executor_negotiation_tests.rs | 14 ++ .../screen_writable_publication_tests.rs | 76 ++++--- 10 files changed, 499 insertions(+), 140 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/hub.rs b/crates/hypercolor-core/src/input/screen/hub.rs index f24974455..6502785b4 100644 --- a/crates/hypercolor-core/src/input/screen/hub.rs +++ b/crates/hypercolor-core/src/input/screen/hub.rs @@ -17,8 +17,8 @@ use super::plan::{ use super::{ CaptureColorSpace, CaptureColorimetry, CaptureEpoch, CapturePixelFormat, CaptureSourceId, CaptureTransferFunction, PixelExtent, PlatformGpuApi, PlatformGpuSurface, - ResolvedScreenPublicationDescriptor, ScreenByteLease, ScreenPublicationKind, - ScreenPublicationResidency, + ResolvedScreenPublicationDescriptor, ScreenByteLease, ScreenPublicationExecutor, + ScreenPublicationKind, ScreenPublicationResidency, }; const SURFACE_PIXEL_BYTES: u64 = 4; @@ -221,6 +221,39 @@ impl<'a> ScreenGpuSurfacePayload<'a> { } } +/// Renderer work carrying one truthful source-native GPU surface. +#[derive(Clone, Copy, Debug)] +pub struct ScreenNativeWorkPayload<'a> { + source_colorimetry: ScreenPublicationColorimetry, + source: &'a PlatformGpuSurface, +} + +impl<'a> ScreenNativeWorkPayload<'a> { + /// Construct deferred native work from the exact source storage contract. + #[must_use] + pub const fn new( + source_colorimetry: ScreenPublicationColorimetry, + source: &'a PlatformGpuSurface, + ) -> Self { + Self { + source_colorimetry, + source, + } + } + + /// Exact source primaries and transfer contract. + #[must_use] + pub const fn source_colorimetry(self) -> ScreenPublicationColorimetry { + self.source_colorimetry + } + + /// Raw source surface retained until renderer execution completes. + #[must_use] + pub const fn source(self) -> &'a PlatformGpuSurface { + self.source + } +} + /// Typed zone publication input borrowed only for the publish call. #[derive(Clone, Copy, Debug)] pub struct ScreenZonesPayload<'a> { @@ -289,6 +322,8 @@ pub enum ScreenBranchPayload<'a> { Surface(ScreenSurfacePayload<'a>), /// Logical four-channel platform GPU surface. GpuSurface(ScreenGpuSurfacePayload<'a>), + /// Raw source-native GPU work awaiting renderer-owned execution. + NativeWork(ScreenNativeWorkPayload<'a>), /// Logical RGB zone grid. Zones(ScreenZonesPayload<'a>), } @@ -298,7 +333,9 @@ impl ScreenBranchPayload<'_> { #[must_use] pub const fn kind(self) -> ScreenPayloadKind { match self { - Self::Surface(_) | Self::GpuSurface(_) => ScreenPayloadKind::Surface, + Self::Surface(_) | Self::GpuSurface(_) | Self::NativeWork(_) => { + ScreenPayloadKind::Surface + } Self::Zones(_) => ScreenPayloadKind::Zones, } } @@ -311,6 +348,9 @@ impl ScreenBranchPayload<'_> { Self::GpuSurface(payload) => { ScreenPublicationResidency::PlatformGpu(payload.surface().api().clone()) } + Self::NativeWork(payload) => { + ScreenPublicationResidency::PlatformGpu(payload.source().api().clone()) + } } } } @@ -523,6 +563,12 @@ impl ScreenPublicationMetadata { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ScreenStoredGpuPayloadKind { + OutputSurface, + NativeWork, +} + #[derive(Debug)] enum ScreenPublicationStorage { CpuSurface { @@ -534,6 +580,7 @@ enum ScreenPublicationStorage { GpuSurface { api: PlatformGpuApi, colorimetry: ScreenPublicationColorimetry, + payload_kind: ScreenStoredGpuPayloadKind, surface: Option, }, Zones { @@ -571,6 +618,7 @@ impl ScreenPublicationStorage { Ok(Self::GpuSurface { api, colorimetry: descriptor_colorimetry(descriptor), + payload_kind: ScreenStoredGpuPayloadKind::OutputSurface, surface: None, }) } @@ -592,9 +640,32 @@ impl ScreenPublicationStorage { (Self::CpuSurface { pixels, .. }, ScreenBranchPayload::Surface(payload)) => { pixels.copy_from_slice(payload.pixels()); } - (Self::GpuSurface { surface, .. }, ScreenBranchPayload::GpuSurface(payload)) => { + ( + Self::GpuSurface { + colorimetry, + payload_kind, + surface, + .. + }, + ScreenBranchPayload::GpuSurface(payload), + ) => { + *colorimetry = payload.colorimetry(); + *payload_kind = ScreenStoredGpuPayloadKind::OutputSurface; *surface = Some(payload.surface().clone()); } + ( + Self::GpuSurface { + colorimetry, + payload_kind, + surface, + .. + }, + ScreenBranchPayload::NativeWork(payload), + ) => { + *colorimetry = payload.source_colorimetry(); + *payload_kind = ScreenStoredGpuPayloadKind::NativeWork; + *surface = Some(payload.source().clone()); + } ( Self::Zones { columns, @@ -701,14 +772,28 @@ impl ScreenPublicationStorage { }), Self::GpuSurface { colorimetry, + payload_kind, surface, .. - } => ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload { - colorimetry: *colorimetry, - surface: surface + } => { + let surface = surface .as_ref() - .expect("published GPU slots always contain a surface"), - }), + .expect("published GPU slots always contain a surface"); + match payload_kind { + ScreenStoredGpuPayloadKind::OutputSurface => { + ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload { + colorimetry: *colorimetry, + surface, + }) + } + ScreenStoredGpuPayloadKind::NativeWork => { + ScreenBranchPayload::NativeWork(ScreenNativeWorkPayload { + source_colorimetry: *colorimetry, + source: surface, + }) + } + } + } Self::Zones { columns, rows, @@ -1765,7 +1850,7 @@ impl ScreenPublicationHub { payload: ScreenBranchPayload<'_>, metadata: &ScreenPublicationMetadata, ) -> Result { - validate_payload(&publisher.branch.descriptor, payload)?; + validate_payload(&publisher.branch.descriptor, &publisher.binding, payload)?; validate_metadata(&publisher.branch, &publisher.binding, metadata)?; let mut prepared = self.reserve_publication(publisher, metadata)?; let publication_storage = prepared.publication_mut()?; @@ -2690,7 +2775,7 @@ pub enum ScreenPublicationHubError { /// Submitted format. observed: CapturePixelFormat, }, - /// Submitted primaries or transfer differ from the descriptor target. + /// Submitted primaries or transfer differ from the required contract. #[error("publication colorimetry mismatch: expected {expected:?}, observed {observed:?}")] ColorimetryMismatch { /// Descriptor target colorimetry. @@ -2698,6 +2783,31 @@ pub enum ScreenPublicationHubError { /// Submitted colorimetry. observed: ScreenPublicationColorimetry, }, + /// Deferred native work was submitted for a non-native descriptor. + #[error("native GPU work requires a source-native publication descriptor")] + NativeWorkExecutorMismatch, + /// Deferred native work does not expose the exact source storage extent. + #[error("native work source extent mismatch: expected {expected:?}, observed {observed:?}")] + NativeWorkSourceExtentMismatch { + /// Exact source storage extent. + expected: PixelExtent, + /// Submitted raw storage extent. + observed: PixelExtent, + }, + /// Deferred native work does not expose the exact source pixel format. + #[error("native work source format mismatch: expected {expected:?}, observed {observed:?}")] + NativeWorkSourcePixelFormatMismatch { + /// Exact native source format. + expected: CapturePixelFormat, + /// Submitted raw storage format. + observed: CapturePixelFormat, + }, + /// A source-native surface lacks its exact renderer-target lifetime. + #[error("native GPU surface has no lifetime for its exact renderer target and descriptor")] + NativeTargetLifetimeMismatch, + /// A source-native surface lacks its exact capture-worker lifetime. + #[error("native GPU surface has no lifetime for its exact capture worker")] + NativeCaptureLifetimeMismatch, /// Zone grid shape differs from the committed descriptor. #[error( "zone shape mismatch: expected {expected_columns}x{expected_rows}, observed {observed_columns}x{observed_rows}" @@ -2797,6 +2907,7 @@ pub enum ScreenPublicationHubError { fn validate_payload( descriptor: &ResolvedScreenPublicationDescriptor, + binding: &ScreenWorkerBinding, payload: ScreenBranchPayload<'_>, ) -> Result<(), ScreenPublicationHubError> { let expected_residency = descriptor.required_residency(); @@ -2841,6 +2952,37 @@ fn validate_payload( }); } validate_colorimetry(descriptor, surface.colorimetry())?; + validate_native_surface_lifetimes(descriptor, binding, surface.surface())?; + } + (ScreenPublicationKind::Surface, ScreenBranchPayload::NativeWork(work)) => { + if !matches!( + descriptor.executor(), + ScreenPublicationExecutor::SourceNative(_) + ) { + return Err(ScreenPublicationHubError::NativeWorkExecutorMismatch); + } + let source = work.source(); + let expected_extent = descriptor.source().geometry().storage_extent(); + if source.extent() != expected_extent { + return Err(ScreenPublicationHubError::NativeWorkSourceExtentMismatch { + expected: expected_extent, + observed: source.extent(), + }); + } + let expected_format = descriptor.source_pixel_format(); + if source.format() != expected_format { + return Err( + ScreenPublicationHubError::NativeWorkSourcePixelFormatMismatch { + expected: expected_format, + observed: source.format(), + }, + ); + } + validate_expected_colorimetry( + ScreenPublicationColorimetry::new(descriptor.source_colorimetry()), + work.source_colorimetry(), + )?; + validate_native_surface_lifetimes(descriptor, binding, source)?; } (ScreenPublicationKind::Zones { columns, rows }, ScreenBranchPayload::Zones(zones)) => { if zones.columns() != columns || zones.rows() != rows { @@ -2869,6 +3011,28 @@ fn validate_payload( Ok(()) } +fn validate_native_surface_lifetimes( + descriptor: &ResolvedScreenPublicationDescriptor, + binding: &ScreenWorkerBinding, + surface: &PlatformGpuSurface, +) -> Result<(), ScreenPublicationHubError> { + let ScreenPublicationExecutor::SourceNative(target) = descriptor.executor() else { + return Ok(()); + }; + let target_lifetime = surface + .resource_lifetime() + .filter(|lifetime| lifetime.belongs_to_binding(binding)) + .filter(|lifetime| lifetime.matches_native_target(target.id().get(), descriptor)) + .ok_or(ScreenPublicationHubError::NativeTargetLifetimeMismatch)?; + let capture_lifetime = surface + .capture_resource_lifetime() + .filter(|lifetime| lifetime.belongs_to_binding(binding)) + .filter(|lifetime| target_lifetime.belongs_to_same_worker(lifetime)) + .ok_or(ScreenPublicationHubError::NativeCaptureLifetimeMismatch)?; + debug_assert!(capture_lifetime.belongs_to_same_worker(target_lifetime)); + Ok(()) +} + fn validate_payload_kind( descriptor: &ResolvedScreenPublicationDescriptor, observed: ScreenPayloadKind, @@ -2927,7 +3091,13 @@ fn validate_colorimetry( descriptor: &ResolvedScreenPublicationDescriptor, observed: ScreenPublicationColorimetry, ) -> Result<(), ScreenPublicationHubError> { - let expected = descriptor_colorimetry(descriptor); + validate_expected_colorimetry(descriptor_colorimetry(descriptor), observed) +} + +fn validate_expected_colorimetry( + expected: ScreenPublicationColorimetry, + observed: ScreenPublicationColorimetry, +) -> Result<(), ScreenPublicationHubError> { if observed == expected { Ok(()) } else { diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 758c144e0..aeb9117d1 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -27,18 +27,18 @@ use super::{ CpuExactReductionWorkPlan, CpuReductionExecutor, LedToneMapCalibration, PixelExtent, PixelRect, PlatformGpuApi, PlatformGpuSurface, PreparedCpuPublicationFanout, PreparedCpuPublicationFanoutCandidate, RawCaptureSurface, RegisteredScreenBranchDemand, - ResolvedScreenBranchDemand, ResolvedScreenColorTransform, ResolvedScreenPublicationDescriptor, - ResolvedScreenSource, ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, - ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, - ScreenBranchPayload, ScreenBranchPublisher, ScreenByteAdmissionCoordinator, - ScreenCaptureBackend, ScreenCaptureDemand, ScreenCaptureInput, - ScreenColorTransformCapabilities, ScreenCursorCapabilities, ScreenExecutorColorCapabilities, - ScreenGpuSurfacePayload, ScreenNativePreparationPayload, ScreenPhysicalGpuDeviceIdentity, - ScreenPreparedWorkerToken, ScreenPublicationColorimetry, ScreenPublicationExecutor, - ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationMetadata, ScreenPublicationRequest, - ScreenRequiredResourceMinimum, ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, - ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, + ResolvedScreenBranchDemand, ResolvedScreenPublicationDescriptor, ResolvedScreenSource, + ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, + ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, ScreenBranchPayload, + ScreenBranchPublisher, ScreenByteAdmissionCoordinator, ScreenCaptureBackend, + ScreenCaptureDemand, ScreenCaptureInput, ScreenCursorCapabilities, + ScreenExecutorColorCapabilities, ScreenGpuSurfacePayload, ScreenNativePreparationPayload, + ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPreparedWorkerToken, + ScreenPublicationColorimetry, ScreenPublicationExecutor, ScreenPublicationExecutorRequest, + ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, + ScreenPublicationMetadata, ScreenPublicationRequest, ScreenRequiredResourceMinimum, + ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, + ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; @@ -1266,16 +1266,12 @@ fn resolve_macos_publication_branch( source.gpu_source(selector.clone(), target.physical_gpu_device().clone()) && let Ok(resolved) = demand.resolve_with_executor_capabilities( &native_source, - ScreenExecutorColorCapabilities::new( - capabilities, - ScreenColorTransformCapabilities::NONE, - ), + ScreenExecutorColorCapabilities::new(capabilities, target.color_capabilities()), ) && matches!( resolved.descriptor().executor(), ScreenPublicationExecutor::SourceNative(_) ) - && macos_native_descriptor_is_identity(resolved.descriptor(), source) && MacosNativeTargetManifest::new(resolved.descriptor()).is_ok() { return Ok(Some(resolved)); @@ -1287,17 +1283,15 @@ fn resolve_macos_publication_branch( )?)) } -fn macos_native_descriptor_is_identity( - descriptor: &ResolvedScreenPublicationDescriptor, - source: &MacosPublicationSource, -) -> bool { - source.geometry.crop().is_none() - && descriptor.geometry().output_extent() == source.geometry.storage_extent() - && descriptor.physical().reduction_extent() == source.geometry.storage_extent() - && descriptor.physical().target_pixel_format() == CapturePixelFormat::Bgra8 +fn macos_native_descriptor_is_identity(descriptor: &ResolvedScreenPublicationDescriptor) -> bool { + descriptor.source().geometry().crop().is_none() + && descriptor.geometry().output_extent() == descriptor.source().geometry().storage_extent() + && descriptor.physical().reduction_extent() + == descriptor.source().geometry().storage_extent() + && descriptor.physical().target_pixel_format() == descriptor.source_pixel_format() && matches!( descriptor.physical().color_pipeline().transform(), - ResolvedScreenColorTransform::PreserveEncodedSamples + super::ResolvedScreenColorTransform::PreserveEncodedSamples ) } @@ -1951,8 +1945,8 @@ fn publish_macos_native_exact( let surface = PlatformGpuSurface::new( PlatformGpuApi::Metal, u64::from(frame.surface.iosurface_id), - route.descriptor.geometry().output_extent(), - route.descriptor.physical().target_pixel_format(), + source.geometry.storage_extent(), + route.descriptor.source_pixel_format(), Arc::clone(frame), )?; let surface = route @@ -1967,12 +1961,19 @@ fn publish_macos_native_exact( fresh_until, ScreenPublicationHealth::Healthy, )?; - let payload = ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new( - ScreenPublicationColorimetry::new( - route.descriptor.physical().color_pipeline().output(), - ), - &surface, - )); + let payload = if macos_native_descriptor_is_identity(&route.descriptor) { + ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new( + ScreenPublicationColorimetry::new( + route.descriptor.physical().color_pipeline().output(), + ), + &surface, + )) + } else { + ScreenBranchPayload::NativeWork(ScreenNativeWorkPayload::new( + ScreenPublicationColorimetry::new(route.descriptor.source_colorimetry()), + &surface, + )) + }; match hub.publish(publisher, payload, &metadata) { Ok(_) => { route.last_accepted_sequence = Some(frame.sequence); @@ -2380,12 +2381,13 @@ mod tests { use super::*; use crate::input::screen::{ CpuReductionLayout, CpuReductionRequest, InputPublicationDemandRevision, - ScreenAdmissionCapacity, ScreenAspectPolicy, ScreenExtentRequest, ScreenHdrPolicy, - ScreenInputGraphGeneration, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, - ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPlanBuilder, - ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenProfileScalar, - ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, - ScreenSceneCutPolicy, ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, + ResolvedScreenColorTransform, ScreenAdmissionCapacity, ScreenAspectPolicy, + ScreenBranchPublication, ScreenExtentRequest, ScreenHdrPolicy, ScreenInputGraphGeneration, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativeTargetPreparation, + ScreenNativeTargetPreparer, ScreenPlanBuilder, ScreenProcessingProfile, + ScreenProcessingProfileConfig, ScreenProfileScalar, ScreenPublicationKind, + ScreenPublicationRequest, ScreenReductionFilter, ScreenSceneCutPolicy, + ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, }; use hypercolor_macos_capture::{ MacosAttachment, MacosCaptureColorimetry, MacosCaptureSurface, MacosColorRange, @@ -3627,19 +3629,29 @@ mod tests { ) } - #[test] - fn native_publication_commits_owner_backed_metal_surface() { - let frame = frame(); - let source = source(&frame); - let demand = native_demand(&target()); - let resolved = resolve_macos_publication_branch(&source, &demand) - .expect("native demand resolves") - .expect("configured macOS source owns native demand"); - assert!(matches!( - resolved.descriptor().executor(), - ScreenPublicationExecutor::SourceNative(_) - )); + fn reduced_native_demand(target: &ScreenNativeExecutionTarget) -> RegisteredScreenBranchDemand { + RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + super::super::ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::default()), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + ) + } + fn publish_native_fixture( + frame: &Arc, + source: &MacosPublicationSource, + resolved: ResolvedScreenBranchDemand, + ) -> Arc { let exact = MacosExactPublicationShared::default(); exact.replace_source(Some(source.clone())); let mut builder = ScreenPlanBuilder::new(); @@ -3658,7 +3670,7 @@ mod tests { let ticket = preparing .worker_ticket(&source.epoch.source_id) .expect("macOS source owns its worker ticket"); - let (token, runtime) = prepare_macos_exact_runtime(ticket, Some(&source), &exact) + let (token, runtime) = prepare_macos_exact_runtime(ticket, Some(source), &exact) .expect("native runtime prepares"); let (runtime, owned_source) = runtime.expect("native branch owns a runtime"); exact.register_owned_source(owned_source); @@ -3679,28 +3691,46 @@ mod tests { let now = Instant::now(); publish_macos_native_exact( - &frame, + frame, now, now + Duration::from_secs(1), - &source, + source, &exact, &mut runtimes, ) .expect("native frame publishes"); let hub = exact.hub().expect("test hub remains installed"); let (_, lease) = hub.observe_matching_lease(|_| true); - let publication = lease + lease .expect("committed native branch has a lease") .read() - .expect("native branch has a publication"); + .expect("native branch has a publication") + } + + #[test] + fn native_publication_commits_owner_backed_metal_surface() { + let frame = frame(); + let source = source(&frame); + let demand = native_demand(&target()); + let resolved = resolve_macos_publication_branch(&source, &demand) + .expect("native demand resolves") + .expect("configured macOS source owns native demand"); + assert!(matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + )); + + let publication = publish_native_fixture(&frame, &source, resolved); assert_eq!(publication.native_sequence(), NonZeroU64::MIN); let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { - panic!("macOS native branch publishes a GPU surface"); + panic!("identity macOS native branch publishes its GPU surface"); }; let surface = payload.surface(); assert_eq!(surface.api(), &PlatformGpuApi::Metal); assert_eq!(surface.handle_id(), 7); assert_eq!(surface.format(), CapturePixelFormat::Bgra8); + assert_eq!(surface.extent(), source.geometry.storage_extent()); + assert_eq!(payload.colorimetry().value(), source.colorimetry); assert!(surface.owner::().is_some()); assert!(surface.retained_owner::().is_some()); assert!(surface.resource_lifetime().is_some()); @@ -3711,21 +3741,7 @@ mod tests { fn reduced_rgba_demand_falls_back_until_native_reducer_exists() { let frame = frame(); let source = source(&frame); - let demand = RegisteredScreenBranchDemand::new( - ScreenPublicationRequest::new( - ScreenSourceSelector::Configured, - ScreenPublicationKind::Surface, - ScreenPublicationExecutorRequest::SourceNative(target()), - ScreenExtentRequest::bounded( - NonZeroU32::new(2), - NonZeroU32::new(1), - super::super::ScreenUpscalePolicy::Never, - ), - ScreenAspectPolicy::Contain, - Arc::new(ScreenProcessingProfile::default()), - ), - NonZeroU32::new(60).expect("nonzero cadence"), - ); + let demand = reduced_native_demand(&target()); let resolved = resolve_macos_publication_branch(&source, &demand) .expect("reduced demand resolves") .expect("configured macOS source owns reduced demand"); @@ -3733,6 +3749,27 @@ mod tests { resolved.descriptor().executor(), ScreenPublicationExecutor::Cpu )); + + let capable_target = + target().with_color_capabilities(CpuReductionExecutor::supported_color_capabilities()); + let capable = + resolve_macos_publication_branch(&source, &reduced_native_demand(&capable_target)) + .expect("capable reduced demand resolves") + .expect("configured macOS source owns capable demand"); + assert!(matches!( + capable.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + )); + assert!(!macos_native_descriptor_is_identity(capable.descriptor())); + let output_extent = capable.descriptor().geometry().output_extent(); + let publication = publish_native_fixture(&frame, &source, capable); + let ScreenBranchPayload::NativeWork(payload) = publication.payload() else { + panic!("reduced macOS native branch publishes deferred GPU work"); + }; + assert_eq!(payload.source().extent(), source.geometry.storage_extent()); + assert_ne!(payload.source().extent(), output_extent); + assert_eq!(payload.source().format(), CapturePixelFormat::Bgra8); + assert_eq!(payload.source_colorimetry().value(), source.colorimetry); } #[test] diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 79097ccc9..c323605f3 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -84,11 +84,11 @@ pub use hub::{ ScreenBranchLease, ScreenBranchPayload, ScreenBranchPublication, ScreenBranchPublisher, ScreenCommittedState, ScreenContinuityActivationFailure, ScreenContinuityError, ScreenContinuityLease, ScreenContinuityStageFailure, ScreenGpuSurfacePayload, - ScreenLiveBranchReceipt, ScreenPayloadKind, ScreenPublicationColorimetry, - ScreenPublicationFreshness, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationMetadata, ScreenPublicationRetirement, - ScreenPublicationSlotPolicy, ScreenSurfacePayload, ScreenTwoPlanContinuityLease, - ScreenZonesPayload, + ScreenLiveBranchReceipt, ScreenNativeWorkPayload, ScreenPayloadKind, + ScreenPublicationColorimetry, ScreenPublicationFreshness, ScreenPublicationHealth, + ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationMetadata, + ScreenPublicationRetirement, ScreenPublicationSlotPolicy, ScreenSurfacePayload, + ScreenTwoPlanContinuityLease, ScreenZonesPayload, }; pub use ledger::{ ScreenWorkerExactLedger, ScreenWorkerExactLedgerBuilder, ScreenWorkerLedgerBuildError, diff --git a/crates/hypercolor-core/src/input/screen/plan.rs b/crates/hypercolor-core/src/input/screen/plan.rs index 56702fd71..9199b645a 100644 --- a/crates/hypercolor-core/src/input/screen/plan.rs +++ b/crates/hypercolor-core/src/input/screen/plan.rs @@ -551,6 +551,14 @@ impl ScreenNativeResourceBindingKey { pub(crate) const fn target_id(&self) -> NonZeroU64 { self.target_id } + + pub(crate) fn matches( + &self, + target_id: NonZeroU64, + descriptor: &ResolvedScreenPublicationDescriptor, + ) -> bool { + self.target_id == target_id && self.descriptor.as_ref() == descriptor + } } impl ScreenExactResource { @@ -656,6 +664,7 @@ struct ScreenResourceLifetimeInner { transaction_id: ScreenPlanTransactionId, worker_nonce: NonZeroU64, allocation_nonce: NonZeroU64, + finalization: Arc>, resource: ScreenExactResource, retirement_charge: Arc, admission_lease: OnceLock, @@ -708,6 +717,27 @@ impl ScreenResourceLifetime { && self.inner.demand_revision == other.inner.demand_revision && self.inner.transaction_id == other.inner.transaction_id && self.inner.worker_nonce == other.inner.worker_nonce + && Arc::ptr_eq(&self.inner.finalization, &other.inner.finalization) + } + + pub(crate) fn belongs_to_binding(&self, binding: &ScreenWorkerBinding) -> bool { + self.inner.source_id == *binding.source_id() + && self.inner.plan_generation == binding.plan_generation() + && self.inner.demand_revision == binding.demand_revision() + && self.inner.transaction_id == binding.transaction_id() + && self.inner.worker_nonce == binding.worker_nonce() + && Arc::ptr_eq(&self.inner.finalization, &binding.inner.finalization) + } + + pub(crate) fn matches_native_target( + &self, + target_id: NonZeroU64, + descriptor: &ResolvedScreenPublicationDescriptor, + ) -> bool { + self.inner + .resource + .native_binding() + .is_some_and(|binding| binding.matches(target_id, descriptor)) } pub(crate) fn is_final_owner(&self) -> bool { @@ -1123,6 +1153,7 @@ impl ScreenWorkerPreparationTicket { transaction_id: self.transaction_id, worker_nonce: self.worker_nonce, allocation_nonce, + finalization: Arc::clone(&self.finalization), resource: resource.clone(), retirement_charge: Arc::new(ScreenRetirementCharge::new( Arc::clone(&self.pending_retired_bytes), diff --git a/crates/hypercolor-core/src/input/screen/publication.rs b/crates/hypercolor-core/src/input/screen/publication.rs index 17574f0ea..305f8b1b8 100644 --- a/crates/hypercolor-core/src/input/screen/publication.rs +++ b/crates/hypercolor-core/src/input/screen/publication.rs @@ -879,6 +879,7 @@ pub struct ScreenNativeExecutionTarget { accepted_api: PlatformGpuApi, physical_gpu_device: ScreenPhysicalGpuDeviceIdentity, max_texture_dimension: NonZeroU32, + color_capabilities: ScreenColorTransformCapabilities, preparer: Arc, } @@ -897,10 +898,21 @@ impl ScreenNativeExecutionTarget { accepted_api, physical_gpu_device, max_texture_dimension, + color_capabilities: ScreenColorTransformCapabilities::NONE, preparer, } } + /// Attach the exact byte-changing color operations implemented by this target. + #[must_use] + pub const fn with_color_capabilities( + mut self, + color_capabilities: ScreenColorTransformCapabilities, + ) -> Self { + self.color_capabilities = color_capabilities; + self + } + /// Process-local renderer context identity. #[must_use] pub const fn id(&self) -> ScreenNativeExecutionTargetId { @@ -925,6 +937,12 @@ impl ScreenNativeExecutionTarget { self.max_texture_dimension } + /// Exact source-native color operations implemented end to end. + #[must_use] + pub const fn color_capabilities(&self) -> ScreenColorTransformCapabilities { + self.color_capabilities + } + fn validate_preparation_request( &self, descriptor: &ResolvedScreenPublicationDescriptor, @@ -1016,6 +1034,7 @@ impl fmt::Debug for ScreenNativeExecutionTarget { .field("accepted_api", &self.accepted_api) .field("physical_gpu_device", &self.physical_gpu_device) .field("max_texture_dimension", &self.max_texture_dimension) + .field("color_capabilities", &self.color_capabilities) .finish_non_exhaustive() } } @@ -1026,6 +1045,7 @@ impl PartialEq for ScreenNativeExecutionTarget { && self.accepted_api == other.accepted_api && self.physical_gpu_device == other.physical_gpu_device && self.max_texture_dimension == other.max_texture_dimension + && self.color_capabilities == other.color_capabilities } } @@ -1038,6 +1058,7 @@ impl Ord for ScreenNativeExecutionTarget { .then_with(|| platform_gpu_api_cmp(&self.accepted_api, &other.accepted_api)) .then_with(|| self.physical_gpu_device.cmp(&other.physical_gpu_device)) .then_with(|| self.max_texture_dimension.cmp(&other.max_texture_dimension)) + .then_with(|| self.color_capabilities.cmp(&other.color_capabilities)) } } diff --git a/crates/hypercolor-core/src/input/screen/wayland/tests.rs b/crates/hypercolor-core/src/input/screen/wayland/tests.rs index 0fa0ae723..284b58aef 100644 --- a/crates/hypercolor-core/src/input/screen/wayland/tests.rs +++ b/crates/hypercolor-core/src/input/screen/wayland/tests.rs @@ -488,7 +488,7 @@ fn exact_runtime_publishes_surface_and_zones_from_one_captured_frame() { assert_eq!(zones.rows(), NonZeroU32::MIN); assert_eq!(zones.colors().len(), 2); } - ScreenBranchPayload::GpuSurface(_) => { + ScreenBranchPayload::GpuSurface(_) | ScreenBranchPayload::NativeWork(_) => { panic!("Wayland exact CPU runtime cannot publish a GPU surface") } } @@ -668,7 +668,7 @@ fn exact_runtime_publishes_surface_and_zones_from_one_captured_frame() { ScreenBranchPayload::Zones(_) => { assert_eq!(publication.worker_plan_generation(), mixed_generation); } - ScreenBranchPayload::GpuSurface(_) => { + ScreenBranchPayload::GpuSurface(_) | ScreenBranchPayload::NativeWork(_) => { panic!("Wayland exact CPU runtime cannot publish a GPU surface") } } diff --git a/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs b/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs index 5b9e5dcbc..2511f764a 100644 --- a/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_publication_tests.rs @@ -833,7 +833,9 @@ fn mixed_fanout_materializes_retained_and_added_branch_bindings() { publication.worker_plan_generation(), runtime_binding.plan_generation() ), - ScreenBranchPayload::GpuSurface(_) => panic!("CPU fanout cannot publish GPU storage"), + ScreenBranchPayload::GpuSurface(_) | ScreenBranchPayload::NativeWork(_) => { + panic!("CPU fanout cannot publish GPU storage") + } } } assert_ne!(initial_plan.generation(), mixed_plan.generation()); diff --git a/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs b/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs index 95c436bd7..ca74584e5 100644 --- a/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs +++ b/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs @@ -15,13 +15,13 @@ use hypercolor_core::input::screen::{ ScreenExtentRequest, ScreenGpuSurfacePayload, ScreenInputGraphGeneration, ScreenLiveBranchReceipt, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativePreparationPayload, ScreenNativeTargetBindingError, ScreenNativeTargetPreparation, - ScreenNativeTargetResourceError, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, - ScreenProcessingProfile, ScreenPublicationColorimetry, ScreenPublicationExecutor, - ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationKind, ScreenPublicationMetadata, - ScreenPublicationRequest, ScreenPublicationSlotPolicy, ScreenResourceApi, ScreenResourceKind, - ScreenResourceLifetime, ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, - ScreenWorkerExactLedgerBuilder, SourceScale, + ScreenNativeTargetResourceError, ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, + ScreenPlanBuilder, ScreenProcessingProfile, ScreenPublicationColorimetry, + ScreenPublicationExecutor, ScreenPublicationExecutorRequest, ScreenPublicationHealth, + ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationKind, + ScreenPublicationMetadata, ScreenPublicationRequest, ScreenPublicationSlotPolicy, + ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, + ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerExactLedgerBuilder, SourceScale, }; #[path = "support/native_target.rs"] @@ -375,6 +375,7 @@ fn publish_gpu( sequence: u64, ) -> (Weak<()>, ScreenLiveBranchReceipt) { let (surface, owner) = gpu_surface(sequence); + let surface = fixture.bind_native_surface(surface); let receipt = fixture .hub .publish( @@ -428,6 +429,20 @@ impl Fixture { fn colorimetry(&self) -> ScreenPublicationColorimetry { ScreenPublicationColorimetry::new(self.descriptor.physical().color_pipeline().output()) } + + fn bind_native_surface(&self, surface: PlatformGpuSurface) -> PlatformGpuSurface { + self.target_preparation + .as_ref() + .expect("fixture retains its admitted native target") + .retain_on_surface_with_capture_allocation( + surface, + self.capture_lifetime + .as_ref() + .expect("fixture retains capture-plan accounting") + .clone(), + ) + .expect("capture and target allocations belong to one worker") + } } struct ReentrantBlockingOwner { @@ -485,7 +500,7 @@ fn gpu_owner_drop_can_reenter_while_other_publishers_take_the_runtime_lock() { release: Arc::clone(&release), }); let weak_owner = Arc::downgrade(&owner); - let surface = gpu_surface_with_owner(1, owner); + let surface = fixture.bind_native_surface(gpu_surface_with_owner(1, owner)); let receipt = fixture .hub .publish( @@ -582,6 +597,7 @@ fn reader_held_gpu_payload_defers_reaping_and_pool_capacity_recovers() { assert_eq!(publisher.reap_releasable_gpu_payloads(), 0); assert!(first_owner.upgrade().is_some()); let (pressured_surface, pressured_owner) = gpu_surface(3); + let pressured_surface = fixture.bind_native_surface(pressured_surface); let pressured = fixture.hub.publish( &publisher, ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new( @@ -612,6 +628,7 @@ fn abandoned_and_rejected_gpu_staging_releases_native_owners() { let fixture = Fixture::new(ScreenPublicationSlotPolicy::default()); let publisher = fixture.publisher(); let (abandoned_surface, abandoned_owner) = gpu_surface(1); + let abandoned_surface = fixture.bind_native_surface(abandoned_surface); let abandoned = fixture .hub .prepare_publication( @@ -631,6 +648,7 @@ fn abandoned_and_rejected_gpu_staging_releases_native_owners() { let (latest_owner, latest_receipt) = publish_gpu(&fixture, &publisher, 1); drop(latest_receipt); let (rejected_surface, rejected_owner) = gpu_surface(2); + let rejected_surface = fixture.bind_native_surface(rejected_surface); let rejected = fixture .hub .prepare_publication( @@ -878,6 +896,74 @@ fn reader_held_gpu_surface_retains_capture_and_renderer_bytes_after_plan_retirem assert!(renderer_payload_weak.upgrade().is_none()); } +#[test] +fn native_publications_reject_missing_capture_and_substituted_worker_lifetimes() { + let mut fixture = Fixture::new(ScreenPublicationSlotPolicy::default()); + let publisher = fixture.publisher(); + let target = fixture + .target_preparation + .take() + .expect("fixture retains its admitted native target"); + let (surface, _) = gpu_surface(1); + let target_only = target.retain_on_surface(surface); + let metadata = metadata(&fixture.descriptor, &publisher, 1); + assert!(matches!( + fixture.hub.publish( + &publisher, + ScreenBranchPayload::NativeWork(ScreenNativeWorkPayload::new( + fixture.colorimetry(), + &target_only, + )), + &metadata, + ), + Err(ScreenPublicationHubError::NativeCaptureLifetimeMismatch) + )); + assert!( + fixture + .hub + .lease(&fixture.descriptor) + .expect("native branch remains committed") + .read() + .is_none() + ); + + let mut substitute = Fixture::new(ScreenPublicationSlotPolicy::default()); + let substitute_target = substitute + .target_preparation + .take() + .expect("substitute fixture retains its admitted target"); + let (surface, _) = gpu_surface(2); + let substituted = substitute_target + .retain_on_surface_with_capture_allocation( + surface, + substitute + .capture_lifetime + .as_ref() + .expect("substitute fixture retains capture accounting") + .clone(), + ) + .expect("substitute target and capture belong together"); + assert!(matches!( + fixture.hub.publish( + &publisher, + ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new( + fixture.colorimetry(), + &substituted, + )), + &metadata, + ), + Err(ScreenPublicationHubError::NativeTargetLifetimeMismatch) + )); + assert!( + fixture + .hub + .lease(&fixture.descriptor) + .expect("native branch remains committed") + .read() + .is_none() + ); +} + #[test] fn retirement_releases_unread_latest_gpu_payload_before_stale_publisher_drops() { let mut fixture = Fixture::new(ScreenPublicationSlotPolicy::default()); diff --git a/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs b/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs index ae4c356fc..21df5aa36 100644 --- a/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs +++ b/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs @@ -56,6 +56,20 @@ fn target( ) } +#[test] +fn native_target_carries_its_exact_color_capabilities() { + let capabilities = ScreenColorTransformCapabilities::new( + true, + true, + true, + NonZeroU32::new(7).expect("test revision is nonzero"), + ); + let target = target(1, PlatformGpuApi::Direct3d11, gpu_device(1), 16_384) + .with_color_capabilities(capabilities); + + assert_eq!(target.color_capabilities(), capabilities); +} + struct CountingPreparer { calls: Arc, } diff --git a/crates/hypercolor-core/tests/screen_writable_publication_tests.rs b/crates/hypercolor-core/tests/screen_writable_publication_tests.rs index 07730927e..bd05526ca 100644 --- a/crates/hypercolor-core/tests/screen_writable_publication_tests.rs +++ b/crates/hypercolor-core/tests/screen_writable_publication_tests.rs @@ -13,13 +13,14 @@ use hypercolor_core::input::screen::{ ScreenColorTransformCapabilities, ScreenCursorCapabilities, ScreenExactResource, ScreenExactResourceLedger, ScreenExecutorColorCapabilities, ScreenExtentRequest, ScreenGpuSurfacePayload, ScreenInputGraphGeneration, ScreenLiveBranchReceipt, - ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenPayloadKind, - ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, ScreenPlanError, ScreenProcessingProfile, - ScreenPublicationColorimetry, ScreenPublicationExecutorRequest, ScreenPublicationHealth, - ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationKind, - ScreenPublicationMetadata, ScreenPublicationRequest, ScreenPublicationResidency, - ScreenPublicationSlotPolicy, ScreenResourceApi, ScreenResourceLifetime, ScreenSourceReflection, - ScreenSourceSelector, ScreenSurfacePayload, ScreenWorkerBinding, SourceScale, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativeWorkPayload, + ScreenPayloadKind, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, ScreenPlanError, + ScreenProcessingProfile, ScreenPublicationColorimetry, ScreenPublicationExecutorRequest, + ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, + ScreenPublicationKind, ScreenPublicationMetadata, ScreenPublicationRequest, + ScreenPublicationResidency, ScreenPublicationSlotPolicy, ScreenResourceApi, + ScreenResourceLifetime, ScreenSourceReflection, ScreenSourceSelector, ScreenSurfacePayload, + ScreenWorkerBinding, SourceScale, }; #[path = "support/native_target.rs"] @@ -410,7 +411,7 @@ fn writable_surface_slots_preserve_last_good_and_reuse_exact_bytes() { } #[test] -fn gpu_surface_publications_retain_native_ownership_and_reject_cpu_substitution() { +fn native_gpu_surface_rejects_unbound_ownership_and_cpu_substitution() { let source = gpu_source(2, 2); let resolved = demand(&source, ScreenPublicationKind::Surface, 60); let descriptor = resolved.descriptor().clone(); @@ -442,34 +443,33 @@ fn gpu_surface_publications_retain_native_ownership_and_reject_cpu_substitution( ScreenPublicationHealth::Healthy, ) .expect("test timeline is valid"); - hub.publish( - &publisher, - ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new(colorimetry, &surface)), - &metadata, - ) - .expect("native GPU surface publishes without readback"); + assert!(matches!( + hub.publish( + &publisher, + ScreenBranchPayload::GpuSurface(ScreenGpuSurfacePayload::new(colorimetry, &surface)), + &metadata, + ), + Err(ScreenPublicationHubError::NativeTargetLifetimeMismatch) + )); - let publication = hub - .lease(&descriptor) - .expect("GPU branch remains committed") - .read() - .expect("GPU branch has a last-good publication"); - assert_eq!( - publication.residency(), - ScreenPublicationResidency::PlatformGpu(PlatformGpuApi::Direct3d11) - ); - let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { - panic!("GPU source Surface branches retain opaque GPU payloads"); - }; - assert_eq!(payload.surface().handle_id(), 41); - assert_eq!( - payload - .surface() - .owner::() - .expect("native owner type remains recoverable") - .as_str(), - "shared-d3d11-texture" - ); + let second_metadata = ScreenPublicationMetadata::try_new( + descriptor.source_epoch().clone(), + binding.plan_generation(), + NonZeroU64::new(2).expect("test sequence is nonzero"), + now, + now, + now + Duration::from_secs(1), + ScreenPublicationHealth::Healthy, + ) + .expect("test timeline is valid"); + assert!(matches!( + hub.publish( + &publisher, + ScreenBranchPayload::NativeWork(ScreenNativeWorkPayload::new(colorimetry, &surface)), + &second_metadata, + ), + Err(ScreenPublicationHubError::NativeTargetLifetimeMismatch) + )); assert!(matches!( hub.prepare_writable_publication( @@ -509,13 +509,11 @@ fn gpu_surface_publications_retain_native_ownership_and_reject_cpu_substitution( ), Err(ScreenPublicationHubError::ResidencyMismatch { .. }) )); - assert_eq!( + assert!( hub.lease(&descriptor) .expect("GPU branch remains committed") .read() - .expect("rejected CPU substitution preserves last-good") - .native_sequence(), - NonZeroU64::MIN + .is_none() ); } From ddaaccb65588e0173d800e10581a73fd77750597 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 01:12:58 -0700 Subject: [PATCH 067/144] docs(macos): document native capture and input Replace the obsolete polling and unsupported-capture guidance with the ScreenCaptureKit picker, Core Graphics event taps, consent boundaries, and calibrated HDR controls now implemented by the macOS stack. Record the 15.2 deployment floor and pull-request platform gates so users choose compatible packages and understand when macOS prompts can appear. Co-Authored-By: Nova (GPT-5.6) --- README.md | 26 +++++++++-------- docs/ARCHITECTURE.md | 9 +++--- docs/content/guide/choose-your-install.md | 13 +++++---- docs/content/guide/configuration.md | 34 +++++++++++++++++++++-- docs/content/guide/input-capture.md | 17 +++++++++++- 5 files changed, 76 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index dc5ff2098..66ad5a22b 100644 --- a/README.md +++ b/README.md @@ -178,14 +178,15 @@ Effects can react to your keyboard and mouse. The input pipeline is consent-gate demand-driven: it is off by default, sources open devices only while an interactive effect is running, and input events ride a dedicated control-tier channel that never leaves the render pipeline. Native backends per platform: evdev on Linux, Raw Input on Windows, and a -polling bridge on macOS. +Core Graphics event tap on macOS. ### 🌊 And More - **Scene engine** with priority stacking, Oklab cross-fades, and automation rules - **Display faces** for LCD-equipped devices: clocks, sensor dashboards, now-playing panels - **Screen capture** input for ambient backlighting: Desktop Duplication on Windows - (works out of the box), Wayland portal on Linux (opt-in) + (works out of the box), Wayland portal on Linux (opt-in), and Apple's system picker on + macOS - **Portable device identity**: devices keep their identity across cable moves, IP churn, and BIOS renumbering, and layouts can be rebound after hardware swaps - **REST API + WebSocket** for full programmatic control @@ -322,7 +323,7 @@ Duplication and is enabled by default. The macOS DMGs (`Hypercolor--arm64.dmg` for Apple Silicon, `-x86_64.dmg` for Intel) are on the [GitHub releases page](https://github.com/hyperb1iss/hypercolor/releases). Drag the app -into `/Applications` and launch. Minimum macOS 11 (Big Sur). +into `/Applications` and launch. Minimum macOS 15.2 (Sequoia). Or via Homebrew Cask: @@ -333,8 +334,10 @@ brew install --cask hyperb1iss/tap/hypercolor-app > Current builds carry an ad-hoc signature rather than a notarized Developer ID one, so > Gatekeeper flags the first launch. Right-click the app and choose **Open** to confirm. -Hue, WLED, Nanoleaf, Govee, and USB-HID lighting all work out of the box. On first run, -macOS prompts for Microphone access if you enable audio-reactive effects. +Hue, WLED, Nanoleaf, Govee, and USB-HID lighting all work out of the box. Hypercolor asks +for Microphone, Screen Recording, or Input Monitoring access only when you explicitly +enable the matching audio, screen, or keyboard feature. Pointer-only effects do not need +Input Monitoring. ### What works where @@ -343,17 +346,18 @@ macOS prompts for Microphone access if you enable audio-reactive effects. | Effects, devices, web UI, TUI, CLI | ✓ | ✓ | ✓ | | Audio-reactive (microphone) | ✓ | ✓ | ✓ | | Audio-reactive (system audio) | ✓ native monitor | loopback device¹ | loopback device¹ | -| Screen capture | Wayland portal, opt-in | Desktop Duplication, default on | not available | -| Keyboard/mouse input | evdev | Raw Input² | polling bridge | +| Screen capture | Wayland portal, opt-in | Desktop Duplication, default on | ScreenCaptureKit system picker | +| Keyboard/mouse input | evdev | Raw Input² | Core Graphics event tap³ | | Motherboard / DRAM RGB (SMBus) | `i2c-dev` | PawnIO helper | not available | | Session and power integration | logind + screensaver | not yet | not yet | -| Background service | systemd user service | Windows service³ | launchd agent | +| Background service | systemd user service | Windows service⁴ | launchd agent | ¹ System-audio reactivity needs a loopback input the OS exposes: Stereo Mix or a virtual cable on Windows, BlackHole or Loopback on macOS. ² A daemon installed as a Windows service cannot see host input across the session boundary; run it in your session for interactive effects. -³ Or per-user autostart via the desktop app. +³ Keyboard listening needs Input Monitoring. Pointer-only effects do not. +⁴ Or per-user autostart via the desktop app. ### Run @@ -508,8 +512,8 @@ instance running on real hardware. Worth knowing before you install: -- macOS has no screen capture path yet, and SMBus (motherboard/DRAM RGB) is Linux and - Windows only. The "What works where" table above has the full picture. +- SMBus (motherboard/DRAM RGB) is Linux and Windows only. The "What works where" table + above has the full picture. - Session and power integration (idle dim, sleep/resume device rescan) is Linux-only today. - Windows and macOS binaries are not yet code-signed, so expect a SmartScreen or Gatekeeper speed bump on first launch. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a387b5380..8867e9f93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,10 +195,11 @@ Events are history. High-frequency data streams are latest value. All three platforms ship installers: Linux gets a tarball, a `.deb`, an AUR package, and a Homebrew formula; Windows gets a per-machine NSIS installer; macOS gets DMGs for both architectures plus a Homebrew cask. CI gates Linux -and Windows on every push, while macOS compiles only on release tags. -Linux-specific runtime integration (udev rules, PipeWire portal capture, -systemd user services, logind session events) has no Windows or macOS -equivalent yet, and macOS has no screen capture or SMBus support. +and Windows on every push. Pull requests also compile, lint, and exercise +platform fixtures on Apple Silicon and Intel macOS runners. Linux-specific +runtime integration (udev rules, PipeWire portal capture, systemd user +services, logind session events) has native counterparts where required. +macOS screen capture uses ScreenCaptureKit; SMBus remains unsupported there. Application, driver, and domain crates inherit `unsafe_code = "forbid"`. The current opt-outs are the audited platform crates plus the app shell: diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 4805da429..77ad5f2fe 100644 --- a/docs/content/guide/choose-your-install.md +++ b/docs/content/guide/choose-your-install.md @@ -9,9 +9,9 @@ Not every install path is right for every person. This page routes you to the co {% callout(type="info") %} Linux, Windows, and macOS are all supported install platforms. Linux additionally gets udev, systemd, and session integration (idle dim, lock and -suspend behavior). Platform limits to know up front: macOS has no screen -capture and no SMBus motherboard/DRAM RGB, and session integration is -Linux-only today. +suspend behavior). macOS supports screen capture and native host input, but it +has no SMBus motherboard/DRAM RGB path. Session integration is Linux-only +today. {% end %} ## Decide in 30 seconds @@ -96,13 +96,16 @@ USB-HID lighting (Razer, Corsair, Lian Li, and others) and network devices (Hue, Download `Hypercolor--arm64.dmg` (Apple Silicon) or `-x86_64.dmg` (Intel) from the [download page](@/download.md), drag the app into -`/Applications`, and launch. Minimum macOS 11 (Big Sur). +`/Applications`, and launch. Minimum macOS 15.2 (Sequoia). {% callout(type="warning") %} Current builds are ad-hoc signed but not notarized, so Gatekeeper will block the app on first launch. Right-click the app and choose **Open** to confirm. {% end %} -macOS supports audio-reactive effects (see [Audio setup](@/guide/audio-setup.md) for the loopback-device requirement) but has no screen capture, so screen-reactive effects are unavailable there. +macOS supports screen-reactive effects through ScreenCaptureKit and Apple's +system picker. Screen Recording permission is requested only after an explicit +capture action. Audio-reactive effects also work; system audio needs a loopback +device as described in [Audio setup](@/guide/audio-setup.md). ### Homebrew {#homebrew} diff --git a/docs/content/guide/configuration.md b/docs/content/guide/configuration.md index 78f3d7177..cde30ec5e 100644 --- a/docs/content/guide/configuration.md +++ b/docs/content/guide/configuration.md @@ -218,7 +218,14 @@ Audio config changes applied via `config set --live` or the REST API take effect ## `[capture]` -Screen capture for ambient lighting effects. On Windows it is on by default: DXGI Desktop Duplication asks for no permission, shows no picker, and draws no capture indicator, so an ambient effect works immediately. On Linux it is opt-in: Wayland capture goes through the XDG desktop portal and PipeWire, which opens a picker, and answering it on your behalf at daemon start would be an ambush. X11 sessions have no capture path. macOS has no screen capture at all, and setting `capture.enabled = true` there is rejected by config validation. +Screen capture for ambient lighting effects. On Windows it is on by default: +DXGI Desktop Duplication asks for no permission, shows no picker, and draws no +capture indicator, so an ambient effect works immediately. On Linux it is +opt-in: Wayland capture goes through the XDG desktop portal and PipeWire, which +opens a picker, and answering it on your behalf at daemon start would be an +ambush. X11 sessions have no capture path. On macOS, ScreenCaptureKit uses +Apple's system picker and Screen Recording permission. Hypercolor presents the +picker only after an explicit action. ```toml [capture] @@ -234,12 +241,35 @@ letterbox_threshold = 0.02 # Luminance threshold for bar detection saturation = 1.0 # Saturation boost applied to zone colors brightness = 1.0 # Brightness multiplier applied to zone colors gamma = 1.0 # Gamma shaping (1.0 = neutral, >1 darkens midtones) +target_led_white_x = 0.3127 # LED white point in CIE xy space +target_led_white_y = 0.3290 +target_led_reference_white_nits = 203.0 +target_led_peak_nits = 406.0 +exposure_ev = 0.0 # HDR exposure adjustment in stops (-8 to 8) # publication_memory_bytes # Optional byte budget; unset snapshots host memory at startup ``` **`enabled`** grants permission and nothing more. The capture backend opens on demand and stays closed until a screen-reactive effect actually asks for pixels. -**`source`** must be `"auto"` on Linux: the XDG desktop portal owns the selection, and the chosen source is persisted in `restore_token` (written automatically) so it survives daemon restarts without re-prompting. On Windows the value addresses a display directly, either `"auto"` for the primary output or a monitor selector such as `monitor:`. A bare number or `display:` is accepted as a legacy output index and rewritten to its stable form once resolved. +**`source`** must be `"auto"` on Linux: the XDG desktop portal owns the +selection, and the chosen source is persisted in `restore_token` (written +automatically) so it survives daemon restarts without re-prompting. On Windows +the value addresses a display directly, either `"auto"` for the primary output +or a monitor selector such as `monitor:`. A bare number or +`display:` is accepted as a legacy output index and rewritten to its stable +form once resolved. + +On macOS, use `"auto"`, `"primary_display"`, or +`"display:"`. A window, application, or multi-window +choice is stored as `"session_scoped"` and requires a new picker choice after +the owning process relaunches. A missing display UUID enters a needs-selection +state instead of silently capturing another display. + +The LED white point, reference white, peak luminance, and exposure values form +one calibrated HDR tone-mapping profile. The white point must lie inside the +CIE xy triangle, reference white must be from 1 to 5000 nits, peak must be from +1 to 10000 nits and above reference white, and exposure accepts -8 to 8 stops. +Calibration changes take effect together at a frame boundary. **`letterbox`** is off by default. Ambient lighting almost always mirrors a desktop rather than a letterboxed film, and dark desktop content trips the bar detector into cropping real picture away. Turn it on when you are mirroring video that genuinely has bars. diff --git a/docs/content/guide/input-capture.md b/docs/content/guide/input-capture.md index 11a476fdb..bf9199d9d 100644 --- a/docs/content/guide/input-capture.md +++ b/docs/content/guide/input-capture.md @@ -102,7 +102,22 @@ An RDP session is a legitimate interactive session with its own desktop, and Hyp ## macOS -macOS still uses a polling bridge that samples held keys rather than observing events, so press timing and pointer position are unavailable there. A native backend is planned. +macOS uses native Core Graphics session event taps. Keyboard and pointer +capture are independent, event-driven sources. The keyboard source reports +physical key locations, modifiers, media keys, repeats, and releases. The +pointer source reports global position, motion, buttons, exact wheel units, +trackpad phases, and momentum. + +Keyboard listening requires **Input Monitoring** permission. Hypercolor first +checks the current grant without prompting. Only an explicit authorization +action may open the system prompt. Pointer-only effects do not request Input +Monitoring, and Hypercolor does not request Accessibility or Apple Events +access for host input. + +A permission loss, secure-input gap, session lock, disabled tap, or source +restart releases every held key and button before capture resumes. This keeps +interactive effects from retaining phantom input across a protected desktop +transition. --- From 0e57f9f8953beda66d351eb19443c2546daa41c0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 01:13:46 -0700 Subject: [PATCH 068/144] docs(macos): correct privacy packaging inventory Record the live microphone and screen-capture purpose strings, the absent Apple Events declaration, and the exact daemon entitlement profile. Co-Authored-By: Nova (GPT-5.6) --- docs/specs/67-macos-installer.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/67-macos-installer.md b/docs/specs/67-macos-installer.md index 011c1ec02..f8a0fb56d 100644 --- a/docs/specs/67-macos-installer.md +++ b/docs/specs/67-macos-installer.md @@ -25,7 +25,8 @@ Local and CI builds both produce per-arch DMG + `.app` artifacts via Tauri 2's b |---|---|---| | Tauri bundle config (icons, identifier, hardened runtime, DMG layout) | `crates/hypercolor-app/tauri.conf.json` | Live | | macOS entitlements (JIT, USB, network, audio-input) | `crates/hypercolor-app/entitlements.plist` | Live | -| `Info.plist` with NSMicrophoneUsageDescription + NSAppleEventsUsageDescription | `crates/hypercolor-app/Info.plist` | Live | +| `Info.plist` with microphone and screen-capture purpose strings; no Apple Events string | `crates/hypercolor-app/Info.plist` | Live | +| Exact six-key daemon hardened-runtime entitlement profile | `packaging/macos/daemon.entitlements.plist` | Live | | Sidecar staging (daemon + CLI under `target/bundle-stage/binaries/`) | `scripts/stage-app-bundle-assets.sh` | Live | | Per-arch CI build matrix (`macos-arm64`, `macos-x64`) | `.github/workflows/ci.yml` § `build-native-app` | Live, currently `--no-sign` | | DMG artifact name normalization to `Hypercolor--.dmg` | `.github/workflows/ci.yml` § Normalize macOS DMG | Live | From 4a11708c0808e0d19e16769c419e5a0abcdd0865 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 02:49:44 -0700 Subject: [PATCH 069/144] feat(macos): add privacy-safe input ownership diagnostics Add bounded diagnostic tools for redacted native input and signed TCC owner evidence. Both tools require explicit flags before prompting, keep protected content out of default output, and never overwrite evidence files. Collect their unit tests as real example targets and keep the non-macOS all-target lint surface clean for shared CI. Co-Authored-By: Nova (GPT-5) --- crates/hypercolor-macos-input/Cargo.toml | 8 + .../examples/dump_macos_input.rs | 361 +++++++++++++ .../examples/probe_macos_tcc_owner.rs | 491 ++++++++++++++++++ 3 files changed, 860 insertions(+) create mode 100644 crates/hypercolor-macos-input/examples/dump_macos_input.rs create mode 100644 crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs diff --git a/crates/hypercolor-macos-input/Cargo.toml b/crates/hypercolor-macos-input/Cargo.toml index 114c88b3c..98cc8c8cc 100644 --- a/crates/hypercolor-macos-input/Cargo.toml +++ b/crates/hypercolor-macos-input/Cargo.toml @@ -30,3 +30,11 @@ objc2-core-graphics = { workspace = true, features = [ "CGEventTypes", ] } mach2 = { workspace = true } + +[[example]] +name = "dump_macos_input" +test = true + +[[example]] +name = "probe_macos_tcc_owner" +test = true diff --git a/crates/hypercolor-macos-input/examples/dump_macos_input.rs b/crates/hypercolor-macos-input/examples/dump_macos_input.rs new file mode 100644 index 000000000..7a8316597 --- /dev/null +++ b/crates/hypercolor-macos-input/examples/dump_macos_input.rs @@ -0,0 +1,361 @@ +use std::{env, process::ExitCode}; + +#[cfg(target_os = "macos")] +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + mpsc, + }, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "macos")] +use hypercolor_macos_input::{ + MacosInputBatch, MacosInputConfig, MacosInputEvent, MacosInputSession, + input_monitoring_granted, request_input_monitoring, +}; + +const DEFAULT_EVENTS: usize = 25; +const DEFAULT_SECONDS: u64 = 10; +const MAX_EVENTS: usize = 10_000; +const MAX_SECONDS: u64 = 300; +#[cfg(target_os = "macos")] +const BATCH_CAPACITY: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Args { + keyboard: bool, + pointer: bool, + authorize: bool, + events: usize, + seconds: u64, +} + +impl Default for Args { + fn default() -> Self { + Self { + keyboard: false, + pointer: true, + authorize: false, + events: DEFAULT_EVENTS, + seconds: DEFAULT_SECONDS, + } + } +} + +#[derive(Debug)] +#[cfg(target_os = "macos")] +struct DiagnosticBatch { + epoch: u64, + at_ms: u64, + events: Vec, + origin_x: f64, + origin_y: f64, + width: f64, + height: f64, + topology_generation: u64, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("dump_macos_input: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let Some(args) = parse_args(env::args().skip(1))? else { + print_help(); + return Ok(()); + }; + + #[cfg(not(target_os = "macos"))] + { + let Args { + keyboard, + pointer, + authorize, + events, + seconds, + } = args; + let _ = (keyboard, pointer, authorize, events, seconds); + Err("requires macOS 15.2 or newer".to_owned()) + } + + #[cfg(target_os = "macos")] + { + if args.keyboard && !input_monitoring_granted() { + if !args.authorize { + return Err( + "keyboard capture needs Input Monitoring; rerun with --authorize to open the macOS permission flow" + .to_owned(), + ); + } + if !request_input_monitoring() { + return Err( + "Input Monitoring was not granted; no keyboard session was started".to_owned(), + ); + } + } + + let started = Instant::now(); + let clock = + Arc::new(move || u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)); + let (sender, receiver) = mpsc::sync_channel(BATCH_CAPACITY); + let delivery_drops = Arc::new(AtomicU64::new(0)); + let callback_delivery_drops = Arc::clone(&delivery_drops); + let mut session = MacosInputSession::start( + MacosInputConfig { + keyboard: args.keyboard, + pointer: args.pointer, + epoch: 1, + clock, + }, + move |batch| { + if matches!( + sender.try_send(owned_batch(batch)), + Err(mpsc::TrySendError::Full(_)) + ) { + callback_delivery_drops.fetch_add(1, Ordering::Relaxed); + } + }, + ) + .map_err(|error| error.to_string())?; + + let masks = session.effective_masks(); + println!( + "session keyboard={} pointer={} keyboard_mask=0x{:x} pointer_mask=0x{:x}", + args.keyboard, args.pointer, masks.keyboard, masks.pointer + ); + + let deadline = Instant::now() + Duration::from_secs(args.seconds); + let mut seen = 0_usize; + while seen < args.events { + let now = Instant::now(); + if now >= deadline { + break; + } + let wait = deadline.saturating_duration_since(now); + match receiver.recv_timeout(wait) { + Ok(batch) => { + print_batch(&batch, args.events.saturating_sub(seen)); + seen = seen.saturating_add(batch.events.len()); + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err( + "native input worker stopped before the diagnostic completed".to_owned(), + ); + } + } + } + + session.stop(); + let diagnostics = session.diagnostics(); + println!( + "summary events={} state={:?} capture_dropped={} diagnostic_delivery_dropped={} tap_disables={} unsupported_system={} invalid_scroll_phase={}", + seen.min(args.events), + session.worker_state(), + diagnostics.dropped_events, + delivery_drops.load(Ordering::Relaxed), + diagnostics.tap_disable_count, + diagnostics.unsupported_system_events, + diagnostics.invalid_scroll_phases, + ); + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn owned_batch(batch: MacosInputBatch<'_>) -> DiagnosticBatch { + DiagnosticBatch { + epoch: batch.epoch, + at_ms: batch.at_ms, + events: batch.events.to_vec(), + origin_x: batch.virtual_desktop.origin_x, + origin_y: batch.virtual_desktop.origin_y, + width: batch.virtual_desktop.width, + height: batch.virtual_desktop.height, + topology_generation: batch.virtual_desktop.topology_generation, + } +} + +#[cfg(target_os = "macos")] +fn print_batch(batch: &DiagnosticBatch, remaining: usize) { + println!( + "batch epoch={} at_ms={} topology_generation={} desktop=({:.3},{:.3}) {:.3}x{:.3}", + batch.epoch, + batch.at_ms, + batch.topology_generation, + batch.origin_x, + batch.origin_y, + batch.width, + batch.height, + ); + for event in batch.events.iter().take(remaining) { + match event { + MacosInputEvent::Key { + virtual_keycode, + pressed, + autorepeat, + } => println!( + "event key physical_code={} pressed={} repeat={}", + virtual_keycode, pressed, autorepeat + ), + MacosInputEvent::ModifierFlags { + virtual_keycode, + flags, + } => println!( + "event modifiers physical_code={} flags=0x{:x}", + virtual_keycode, + flags.bits() + ), + MacosInputEvent::Button { button, pressed } => { + println!("event button kind={button:?} pressed={pressed}"); + } + MacosInputEvent::Motion { + x, + y, + delta_x, + delta_y, + } => println!("event motion global=({x:.3},{y:.3}) delta=({delta_x:.3},{delta_y:.3})"), + MacosInputEvent::Wheel { + fixed_delta_x, + fixed_delta_y, + unit, + phase, + momentum_phase, + } => println!( + "event wheel fixed=({fixed_delta_x},{fixed_delta_y}) unit={unit:?} phase={phase:?} momentum={momentum_phase:?}" + ), + MacosInputEvent::MediaKey { + nx_key_type, + pressed, + repeat, + } => println!( + "event media physical_type={} pressed={} repeat={}", + nx_key_type, pressed, repeat + ), + MacosInputEvent::StateGap { reason } => { + println!("event state_gap reason={reason:?}"); + } + } + } +} + +fn parse_args(args: impl IntoIterator) -> Result, String> { + let mut parsed = Args::default(); + let mut kinds_explicit = false; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(None), + "--keyboard" => { + if !kinds_explicit { + parsed.pointer = false; + kinds_explicit = true; + } + parsed.keyboard = true; + } + "--pointer" => { + if !kinds_explicit { + parsed.pointer = false; + kinds_explicit = true; + } + parsed.pointer = true; + } + "--authorize" => parsed.authorize = true, + "--events" => { + let value = args + .next() + .ok_or_else(|| "--events requires a value".to_owned())?; + parsed.events = parse_bounded("--events", &value, 1, MAX_EVENTS)?; + } + "--seconds" => { + let value = args + .next() + .ok_or_else(|| "--seconds requires a value".to_owned())?; + parsed.seconds = parse_bounded("--seconds", &value, 1, MAX_SECONDS)?; + } + _ => return Err(format!("unknown argument {arg:?}; use --help")), + } + } + if !parsed.keyboard && !parsed.pointer { + return Err("at least one of --keyboard or --pointer must be enabled".to_owned()); + } + if parsed.authorize && !parsed.keyboard { + return Err("--authorize is valid only with --keyboard".to_owned()); + } + Ok(Some(parsed)) +} + +fn parse_bounded(name: &str, value: &str, min: T, max: T) -> Result +where + T: std::str::FromStr + Copy + PartialOrd + std::fmt::Display, +{ + let parsed = value + .parse::() + .map_err(|_| format!("{name} must be an integer"))?; + if parsed < min || parsed > max { + return Err(format!("{name} must be from {min} through {max}")); + } + Ok(parsed) +} + +fn print_help() { + println!( + "dump_macos_input [--keyboard] [--pointer] [--authorize] [--events N] [--seconds N]\n\ + \n\ + Prints redacted native event kinds, physical codes, pointer geometry,\n\ + generations, and health counters. It never prints logical text.\n\ + \n\ + The default is pointer-only, 25 events, and a 10-second deadline.\n\ + --authorize may open the Input Monitoring flow and is valid only with\n\ + --keyboard. No other option presents system UI." + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_bounded_pointer_only_capture() { + assert_eq!( + parse_args([]).expect("defaults parse"), + Some(Args::default()) + ); + } + + #[test] + fn explicit_kinds_compose_without_implicit_pointer() { + let keyboard = parse_args(["--keyboard".to_owned()]) + .expect("keyboard parses") + .expect("not help"); + assert!(keyboard.keyboard); + assert!(!keyboard.pointer); + + let both = parse_args(["--keyboard".to_owned(), "--pointer".to_owned()]) + .expect("both parse") + .expect("not help"); + assert!(both.keyboard); + assert!(both.pointer); + } + + #[test] + fn authorization_is_keyboard_scoped() { + assert!(parse_args(["--authorize".to_owned()]).is_err()); + assert!(parse_args(["--keyboard".to_owned(), "--authorize".to_owned()]).is_ok()); + } + + #[test] + fn limits_are_closed_and_finite() { + assert!(parse_args(["--events".to_owned(), "0".to_owned()]).is_err()); + assert!(parse_args(["--events".to_owned(), "10001".to_owned()]).is_err()); + assert!(parse_args(["--seconds".to_owned(), "301".to_owned()]).is_err()); + } +} diff --git a/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs b/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs new file mode 100644 index 000000000..7858c96de --- /dev/null +++ b/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs @@ -0,0 +1,491 @@ +use std::{env, path::PathBuf, process::ExitCode}; + +#[cfg(target_os = "macos")] +use std::{fs::OpenOptions, io::Write, path::Path, process::Command}; + +#[cfg(target_os = "macos")] +use hypercolor_macos_input::{ + current_process_audit_token_identity, input_monitoring_granted, request_input_monitoring, +}; + +#[cfg(any(target_os = "macos", test))] +const MAX_TOOL_OUTPUT_BYTES: usize = 16 * 1024; +const MAX_IDENTITY_FIELD_BYTES: usize = 8 * 1024; + +#[cfg(target_os = "macos")] +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + safe fn CGPreflightScreenCaptureAccess() -> bool; + safe fn CGRequestScreenCaptureAccess() -> bool; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Topology { + AppSidecar, + DirectLaunchd, + Homebrew, + Standalone, + CaptureBroker, +} + +impl Topology { + #[cfg(target_os = "macos")] + const fn as_str(self) -> &'static str { + match self { + Self::AppSidecar => "app_sidecar", + Self::DirectLaunchd => "direct_launchd", + Self::Homebrew => "homebrew", + Self::Standalone => "standalone", + Self::CaptureBroker => "capture_broker", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Args { + topology: Topology, + authorize_input: bool, + authorize_screen: bool, + prompt_text: Option, + system_settings_entry: Option, + output: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("probe_macos_tcc_owner: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let Some(args) = parse_args(env::args().skip(1))? else { + print_help(); + return Ok(()); + }; + + #[cfg(not(target_os = "macos"))] + { + let Args { + topology, + authorize_input, + authorize_screen, + prompt_text, + system_settings_entry, + output, + } = args; + let _ = ( + topology, + authorize_input, + authorize_screen, + prompt_text, + system_settings_entry, + output, + ); + Err("requires macOS 15.2 or newer".to_owned()) + } + + #[cfg(target_os = "macos")] + { + let executable = env::current_exe() + .map_err(|error| format!("failed to resolve current executable: {error}"))?; + let codesign = inspect_codesign(&executable)?; + let input_before = input_monitoring_granted(); + let screen_before = CGPreflightScreenCaptureAccess(); + let input_request_result = args.authorize_input.then(request_input_monitoring); + let screen_request_result = args + .authorize_screen + .then(|| CGRequestScreenCaptureAccess()); + let input_after = input_monitoring_granted(); + let screen_after = CGPreflightScreenCaptureAccess(); + let spctl = assess_notarization(&executable)?; + + let evidence = format!( + "schema_version=1\n\ + topology={}\n\ + pid={}\n\ + audit_token={}\n\ + executable_path={}\n\ + executable_slice={}\n\ + host_architecture={}\n\ + translated_process={}\n\ + bundle_identifier={}\n\ + team_identifier={}\n\ + designated_requirement={}\n\ + codesign_valid={}\n\ + notarization_accepted={}\n\ + input_monitoring_before={}\n\ + input_monitoring_request={}\n\ + input_monitoring_after={}\n\ + screen_recording_before={}\n\ + screen_recording_request={}\n\ + screen_recording_after={}\n\ + prompt_text={}\n\ + system_settings_entry={}\n", + args.topology.as_str(), + std::process::id(), + sanitize_field( + ¤t_process_audit_token_identity().map_err(|error| error.to_string())? + ), + sanitize_field(&executable.display().to_string()), + env::consts::ARCH, + host_architecture()?, + sysctl_flag("sysctl.proc_translated")?, + sanitize_field(&codesign.identifier), + sanitize_field(codesign.team_identifier.as_deref().unwrap_or("absent")), + sanitize_field(&codesign.designated_requirement), + codesign.valid, + spctl, + input_before, + optional_bool(input_request_result), + input_after, + screen_before, + optional_bool(screen_request_result), + screen_after, + sanitize_field(args.prompt_text.as_deref().unwrap_or("not_observed")), + sanitize_field( + args.system_settings_entry + .as_deref() + .unwrap_or("not_observed") + ), + ); + + print!("{evidence}"); + if let Some(path) = args.output { + eprintln!( + "PRIVACY WARNING: writing signed process identity and TCC state to {}", + path.display() + ); + write_new(&path, evidence.as_bytes())?; + } + Ok(()) + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodesignEvidence { + identifier: String, + team_identifier: Option, + designated_requirement: String, + valid: bool, +} + +#[cfg(target_os = "macos")] +fn inspect_codesign(executable: &Path) -> Result { + let details = bounded_command( + "/usr/bin/codesign", + &["-d", "--verbose=4"], + Some(executable), + )?; + let requirement = bounded_command("/usr/bin/codesign", &["-d", "-r-"], Some(executable))?; + let verification = bounded_command( + "/usr/bin/codesign", + &["--verify", "--strict", "--verbose=4"], + Some(executable), + )?; + let identifier = parse_value(&details.stderr, "Identifier=")?; + let team_identifier = parse_optional_value(&details.stderr, "TeamIdentifier=")?; + let designated_requirement = parse_designated_requirement(&requirement.stdout)?; + Ok(CodesignEvidence { + identifier, + team_identifier, + designated_requirement, + valid: details.success && requirement.success && verification.success, + }) +} + +#[cfg(target_os = "macos")] +fn assess_notarization(executable: &Path) -> Result { + bounded_command( + "/usr/sbin/spctl", + &["--assess", "--type", "execute", "--verbose=4"], + Some(executable), + ) + .map(|output| output.success) +} + +#[cfg(target_os = "macos")] +fn host_architecture() -> Result<&'static str, String> { + if sysctl_flag("hw.optional.arm64")? || sysctl_flag("sysctl.proc_translated")? { + Ok("apple_silicon") + } else { + Ok("intel") + } +} + +#[cfg(target_os = "macos")] +fn sysctl_flag(name: &str) -> Result { + let output = bounded_command("/usr/sbin/sysctl", &["-in", name], None)?; + parse_sysctl_flag(name, output.success, &output.stdout) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_sysctl_flag(name: &str, success: bool, stdout: &[u8]) -> Result { + let value = std::str::from_utf8(stdout) + .map_err(|_| format!("sysctl {name} returned non-UTF-8 output"))? + .trim(); + if !success || value.is_empty() { + return Ok(false); + } + match value { + "0" => Ok(false), + "1" => Ok(true), + _ => Err(format!("sysctl {name} returned unexpected value {value:?}")), + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct BoundedCommandOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +#[cfg(target_os = "macos")] +fn bounded_command( + program: &str, + args: &[&str], + trailing_path: Option<&Path>, +) -> Result { + let mut command = Command::new(program); + command.args(args); + if let Some(path) = trailing_path { + command.arg(path); + } + let output = command + .output() + .map_err(|error| format!("failed to execute {program}: {error}"))?; + if output.stdout.len() > MAX_TOOL_OUTPUT_BYTES || output.stderr.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(format!("{program} output exceeds 16 KiB")); + } + Ok(BoundedCommandOutput { + success: output.status.success(), + stdout: output.stdout, + stderr: output.stderr, + }) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_designated_requirement(stdout: &[u8]) -> Result { + let stdout = bounded_utf8(stdout, "codesign designated requirement")?; + let value = stdout.lines().find_map(|line| { + line.strip_prefix("designated => ") + .or_else(|| line.strip_prefix("# designated => ")) + }); + validate_identity_field( + value.ok_or_else(|| "codesign omitted its designated requirement".to_owned())?, + "designated requirement", + ) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_value(bytes: &[u8], prefix: &str) -> Result { + parse_optional_value(bytes, prefix)?.ok_or_else(|| format!("codesign omitted {prefix}")) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_optional_value(bytes: &[u8], prefix: &str) -> Result, String> { + let text = bounded_utf8(bytes, "codesign details")?; + text.lines() + .find_map(|line| line.strip_prefix(prefix)) + .map(|value| validate_identity_field(value, prefix)) + .transpose() +} + +#[cfg(any(target_os = "macos", test))] +fn bounded_utf8<'a>(bytes: &'a [u8], label: &str) -> Result<&'a str, String> { + if bytes.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(format!("{label} exceeds 16 KiB")); + } + std::str::from_utf8(bytes).map_err(|_| format!("{label} is not UTF-8")) +} + +fn validate_identity_field(value: &str, label: &str) -> Result { + if value.is_empty() || value.len() > MAX_IDENTITY_FIELD_BYTES { + return Err(format!("{label} is empty or exceeds 8 KiB")); + } + if value.chars().any(char::is_control) { + return Err(format!("{label} contains control characters")); + } + Ok(value.to_owned()) +} + +#[cfg(target_os = "macos")] +fn sanitize_field(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +#[cfg(target_os = "macos")] +fn optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "granted", + Some(false) => "denied", + None => "not_requested", + } +} + +#[cfg(target_os = "macos")] +fn write_new(path: &Path, contents: &[u8]) -> Result<(), String> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| format!("failed to create {}: {error}", path.display()))?; + file.write_all(contents) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +fn parse_args(args: impl IntoIterator) -> Result, String> { + let mut topology = None; + let mut authorize_input = false; + let mut authorize_screen = false; + let mut prompt_text = None; + let mut system_settings_entry = None; + let mut output = None; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(None), + "--topology" => { + topology = Some(parse_topology(&next_arg(&mut args, "--topology")?)?); + } + "--authorize-input" => authorize_input = true, + "--authorize-screen" => authorize_screen = true, + "--prompt-text" => { + prompt_text = Some(validate_identity_field( + &next_arg(&mut args, "--prompt-text")?, + "prompt text", + )?); + } + "--system-settings-entry" => { + system_settings_entry = Some(validate_identity_field( + &next_arg(&mut args, "--system-settings-entry")?, + "System Settings entry", + )?); + } + "--output" => output = Some(PathBuf::from(next_arg(&mut args, "--output")?)), + _ => return Err(format!("unknown argument {arg:?}; use --help")), + } + } + Ok(Some(Args { + topology: topology.ok_or_else(|| "--topology is required".to_owned())?, + authorize_input, + authorize_screen, + prompt_text, + system_settings_entry, + output, + })) +} + +fn next_arg(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .ok_or_else(|| format!("{name} requires a value")) +} + +fn parse_topology(value: &str) -> Result { + match value { + "app-sidecar" => Ok(Topology::AppSidecar), + "direct-launchd" => Ok(Topology::DirectLaunchd), + "homebrew" => Ok(Topology::Homebrew), + "standalone" => Ok(Topology::Standalone), + "capture-broker" => Ok(Topology::CaptureBroker), + _ => Err(format!( + "unknown topology {value:?}; expected app-sidecar, direct-launchd, homebrew, standalone, or capture-broker" + )), + } +} + +fn print_help() { + println!( + "probe_macos_tcc_owner --topology TOPOLOGY [--authorize-input] [--authorize-screen]\n\ + \x20 [--prompt-text TEXT] [--system-settings-entry LABEL]\n\ + \x20 [--output PATH]\n\ + \n\ + Records bounded current-process audit, code-signing, architecture,\n\ + notarization, and TCC evidence. No prompt appears unless an explicit\n\ + --authorize-input or --authorize-screen flag is present.\n\ + \n\ + TOPOLOGY is app-sidecar, direct-launchd, homebrew, standalone, or\n\ + capture-broker. --output creates a new file and never overwrites." + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn topology_is_required_and_closed() { + assert!(parse_args([]).is_err()); + assert!(parse_args(["--topology".to_owned(), "future".to_owned()]).is_err()); + assert_eq!( + parse_args(["--topology".to_owned(), "app-sidecar".to_owned()]) + .expect("arguments should parse") + .expect("not help") + .topology, + Topology::AppSidecar + ); + } + + #[test] + fn authorization_is_explicit() { + let args = parse_args([ + "--topology".to_owned(), + "standalone".to_owned(), + "--authorize-screen".to_owned(), + ]) + .expect("arguments should parse") + .expect("not help"); + assert!(!args.authorize_input); + assert!(args.authorize_screen); + } + + #[test] + fn codesign_parsers_accept_signed_and_adhoc_shapes() { + assert_eq!( + parse_designated_requirement(b"designated => identifier \"tech.hyperbliss\"\n") + .expect("signed requirement should parse"), + "identifier \"tech.hyperbliss\"" + ); + assert_eq!( + parse_designated_requirement(b"# designated => cdhash H\"0123\"\n") + .expect("ad-hoc requirement should parse"), + "cdhash H\"0123\"" + ); + assert_eq!( + parse_value(b"Identifier=tech.hyperbliss.hypercolor\n", "Identifier=") + .expect("identifier should parse"), + "tech.hyperbliss.hypercolor" + ); + } + + #[test] + fn identity_fields_reject_control_characters_and_oversize() { + assert!(validate_identity_field("line\nbreak", "field").is_err()); + assert!( + validate_identity_field(&"x".repeat(MAX_IDENTITY_FIELD_BYTES + 1), "field").is_err() + ); + } + + #[test] + fn absent_architecture_flags_mean_false_on_intel() { + assert!(!parse_sysctl_flag("hw.optional.arm64", true, b"").expect("empty OID")); + assert!(!parse_sysctl_flag("sysctl.proc_translated", false, b"").expect("missing OID")); + assert!(parse_sysctl_flag("hw.optional.arm64", true, b"1\n").expect("set flag")); + } +} From 0abd5e4302c5cb033bf2042322eddfa03d99e9d5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 02:50:01 -0700 Subject: [PATCH 070/144] feat(cli): expose explicit macOS protected-source actions Add click-equivalent CLI commands for Input Monitoring, Screen Recording, and source selection without starting a daemon or presenting implicit UI. Render the authoritative macOS daemon owner, epoch, conflict, and recovery state in the status surface. Co-Authored-By: Nova (GPT-5) --- crates/hypercolor-cli/src/commands/access.rs | 108 +++++++++++++++++++ crates/hypercolor-cli/src/commands/mod.rs | 1 + crates/hypercolor-cli/src/commands/status.rs | 93 ++++++++++++++++ crates/hypercolor-cli/src/lib.rs | 5 + 4 files changed, 207 insertions(+) create mode 100644 crates/hypercolor-cli/src/commands/access.rs diff --git a/crates/hypercolor-cli/src/commands/access.rs b/crates/hypercolor-cli/src/commands/access.rs new file mode 100644 index 000000000..664f528ce --- /dev/null +++ b/crates/hypercolor-cli/src/commands/access.rs @@ -0,0 +1,108 @@ +//! Explicit protected-input and screen-capture actions. + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::client::DaemonClient; +use crate::output::{OutputContext, OutputFormat}; + +/// Explicit user actions for protected host input and screen capture. +#[derive(Debug, Args)] +pub struct AccessArgs { + #[command(subcommand)] + pub command: AccessCommand, +} + +/// Protected-source actions. No command prompts implicitly at startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Subcommand)] +pub enum AccessCommand { + /// Ask the active macOS owner for Input Monitoring authorization. + AuthorizeInputMonitoring, + /// Ask the active macOS owner for Screen Recording authorization. + AuthorizeScreenRecording, + /// Present the platform screen-source picker when this owner can show UI. + ChooseScreenSource, +} + +impl AccessCommand { + const fn route(self) -> &'static str { + match self { + Self::AuthorizeInputMonitoring => "/input/authorize", + Self::AuthorizeScreenRecording => "/capture/authorize", + Self::ChooseScreenSource => "/capture/source/pick", + } + } + + const fn success_message(self) -> &'static str { + match self { + Self::AuthorizeInputMonitoring => "Input Monitoring request completed", + Self::AuthorizeScreenRecording => "Screen Recording request completed", + Self::ChooseScreenSource => "Screen source picker completed", + } + } +} + +/// Execute one explicit protected-source action. +/// +/// # Errors +/// +/// Returns an error when the daemon is unavailable or the active topology +/// cannot execute the requested action. Headless picker failures preserve the +/// daemon's typed `requires_app_ui` response. +pub async fn execute(args: &AccessArgs, client: &DaemonClient, ctx: &OutputContext) -> Result<()> { + let response = client + .post(args.command.route(), &serde_json::json!({})) + .await?; + if ctx.format == OutputFormat::Json { + ctx.print_json(&response)?; + } else { + ctx.success(args.command.success_message()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::AccessCommand; + use crate::{Cli, Commands}; + + #[test] + fn protected_actions_use_only_explicit_daemon_routes() { + assert_eq!( + AccessCommand::AuthorizeInputMonitoring.route(), + "/input/authorize" + ); + assert_eq!( + AccessCommand::AuthorizeScreenRecording.route(), + "/capture/authorize" + ); + assert_eq!( + AccessCommand::ChooseScreenSource.route(), + "/capture/source/pick" + ); + } + + #[test] + fn protected_actions_parse_as_explicit_subcommands() { + for (name, expected) in [ + ( + "authorize-input-monitoring", + AccessCommand::AuthorizeInputMonitoring, + ), + ( + "authorize-screen-recording", + AccessCommand::AuthorizeScreenRecording, + ), + ("choose-screen-source", AccessCommand::ChooseScreenSource), + ] { + let cli = Cli::try_parse_from(["hypercolor", "access", name]) + .expect("protected-source command should parse"); + let Commands::Access(args) = cli.command else { + panic!("access command should preserve its top-level group"); + }; + assert_eq!(args.command, expected); + } + } +} diff --git a/crates/hypercolor-cli/src/commands/mod.rs b/crates/hypercolor-cli/src/commands/mod.rs index 54841bce2..a895e7e7c 100644 --- a/crates/hypercolor-cli/src/commands/mod.rs +++ b/crates/hypercolor-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ //! CLI subcommand modules. +pub mod access; pub mod audio; pub mod brightness; pub mod completions; diff --git a/crates/hypercolor-cli/src/commands/status.rs b/crates/hypercolor-cli/src/commands/status.rs index 94da65d8a..8f420e1d3 100644 --- a/crates/hypercolor-cli/src/commands/status.rs +++ b/crates/hypercolor-cli/src/commands/status.rs @@ -102,6 +102,7 @@ fn status_table_lines(data: &serde_json::Value, p: &Painter) -> Vec { let mut lines = Vec::with_capacity(16); lines.push(String::new()); + lines.push(format!(" {}", p.help_banner_title())); lines.push(format!(" {}", p.muted(&"\u{2500}".repeat(21)))); lines.push(String::new()); @@ -133,6 +134,52 @@ fn status_table_lines(data: &serde_json::Value, p: &Painter) -> Vec { lines.push(String::new()); // ── Effect ────────────────────────────────────────────────────── + if let Some(ownership) = data.get("macos_daemon_ownership") { + let owner = ownership + .get("active_owner") + .and_then(serde_json::Value::as_str) + .map(humanize_macos_owner) + .unwrap_or_else(|| "unknown".to_owned()); + let epoch = ownership + .get("owner_epoch") + .and_then(serde_json::Value::as_u64) + .map(|epoch| format!("epoch {epoch}")) + .unwrap_or_else(|| "epoch pending".to_owned()); + lines.push(format!( + " {} {} {}", + p.muted(&pad("macOS owner", 10)), + p.name(&owner), + p.muted(&epoch), + )); + + if let Some(conflict) = ownership.get("conflict") { + let contender = conflict + .get("contender") + .and_then(serde_json::Value::as_str) + .map(humanize_macos_owner) + .unwrap_or_else(|| "unknown contender".to_owned()); + lines.push(format!( + " {} {}", + p.muted(&pad("", 10)), + p.warning(&format!("{contender} also attempted startup")), + )); + } + + if let Some(recovery) = ownership.get("recovery_required") { + let phase = recovery + .get("phase") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown_phase") + .replace('_', " "); + lines.push(format!( + " {} {}", + p.muted(&pad("", 10)), + p.warning(&format!("owner recovery required at {phase}")), + )); + } + lines.push(String::new()); + } + let effect_name = str_field(data, "active_effect", "off"); lines.push(format!( " {} {}", @@ -446,6 +493,16 @@ fn str_field<'a>(v: &'a serde_json::Value, key: &str, default: &'a str) -> &'a s .unwrap_or(default) } +fn humanize_macos_owner(owner: &str) -> String { + match owner { + "app_sidecar" => "Hypercolor.app sidecar".to_owned(), + "launchd_service" | "direct_launchd" => "launchd service".to_owned(), + "homebrew_service" | "homebrew" => "Homebrew service".to_owned(), + "standalone" => "terminal daemon".to_owned(), + value => value.replace('_', " "), + } +} + fn format_scene_summary(data: &serde_json::Value, p: &Painter) -> Option { let scene = data .get("active_scene") @@ -506,6 +563,20 @@ mod tests { "active_scene_snapshot_locked": true, "device_count": 5, "effect_count": 18, + "macos_daemon_ownership": { + "active_owner": "launchd_service", + "owner_epoch": 7, + "conflict": { + "active": "launchd_service", + "contender": "homebrew_service", + "observed_at_ms": 42 + }, + "recovery_required": { + "requested_owner": "homebrew_service", + "prior_owner": "launchd_service", + "phase": "requested_owner_started" + } + }, "latest_frame": { "frame_token": 77, "compositor_backend": "gpu_fallback", @@ -535,6 +606,14 @@ mod tests { let painter = Painter::plain(); let lines = status_table_lines(&data, &painter); let joined = lines.join("\n"); + let running_index = lines + .iter() + .position(|line| line.contains("running")) + .expect("daemon state should render"); + let owner_index = lines + .iter() + .position(|line| line.contains("macOS owner")) + .expect("macOS owner should render"); assert!(joined.contains("Breakthrough"), "effect name present"); assert!(joined.contains("Movie Night"), "scene name present"); @@ -560,5 +639,19 @@ mod tests { ); assert!(joined.contains("5 devices"), "device count present"); assert!(joined.contains("18 effects"), "effect count present"); + assert!(joined.contains("launchd service"), "owner name present"); + assert!(joined.contains("epoch 7"), "owner epoch present"); + assert!( + owner_index > running_index, + "ownership should follow the daemon header" + ); + assert!( + joined.contains("Homebrew service also attempted startup"), + "owner conflict present" + ); + assert!( + joined.contains("owner recovery required at requested owner started"), + "owner recovery present" + ); } } diff --git a/crates/hypercolor-cli/src/lib.rs b/crates/hypercolor-cli/src/lib.rs index 106263bfa..887bba34a 100644 --- a/crates/hypercolor-cli/src/lib.rs +++ b/crates/hypercolor-cli/src/lib.rs @@ -165,6 +165,10 @@ pub enum Commands { #[command(display_order = 14)] Audio(commands::audio::AudioArgs), + /// Explicit host-input and screen-capture permission actions + #[command(display_order = 15)] + Access(commands::access::AccessArgs), + // ── Library ─────────────────────────────────────────────── /// Favorites, presets, and playlists #[command(display_order = 20)] @@ -276,6 +280,7 @@ pub async fn run_with_extensions(extensions: &[&dyn CliExtension]) -> Result<()> Commands::Layouts(args) => commands::layouts::execute(args, &client, &ctx).await, Commands::Brightness(args) => commands::brightness::execute(args, &client, &ctx).await, Commands::Audio(args) => commands::audio::execute(args, &client, &ctx).await, + Commands::Access(args) => commands::access::execute(args, &client, &ctx).await, Commands::Server(args) => commands::server::execute(args, &client, &ctx).await, Commands::Config(args) => commands::config::execute(args, &client, &ctx).await, Commands::Service(args) => commands::service::execute(args, &ctx).await, From 178de497bb91723e8267e994418a9392eef5e193 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 02:50:21 -0700 Subject: [PATCH 071/144] feat(ui): surface native macOS capture ownership Render exact macOS input, screen, permission, selection, and daemon-owner state without polling. Route every mutation through an explicit user action, keep browser sessions read-only, and bind restart requests to the current owner epoch. Add live LED calibration controls, topology-specific offline remedies, and WebSocket-triggered reconciliation across ownership changes and reconnects. Co-Authored-By: Nova (GPT-5) --- crates/hypercolor-ui/src/api/system.rs | 27 +- crates/hypercolor-ui/src/app.rs | 7 +- .../src/components/settings_sections.rs | 382 +++++++++++++++- .../src/components/settings_sections/input.rs | 380 +++++++++++++++- .../components/settings_sections/session.rs | 430 +++++++++++++++++- crates/hypercolor-ui/src/pages/settings.rs | 14 +- crates/hypercolor-ui/src/tauri_bridge.rs | 286 +++++++++++- crates/hypercolor-ui/src/ws/connection.rs | 13 +- crates/hypercolor-ui/src/ws/messages.rs | 17 +- crates/hypercolor-ui/src/ws/mod.rs | 2 +- .../hypercolor-ui/tests/input_access_tests.rs | 34 +- 11 files changed, 1563 insertions(+), 29 deletions(-) diff --git a/crates/hypercolor-ui/src/api/system.rs b/crates/hypercolor-ui/src/api/system.rs index ad7d76c1a..110b168f5 100644 --- a/crates/hypercolor-ui/src/api/system.rs +++ b/crates/hypercolor-ui/src/api/system.rs @@ -89,6 +89,16 @@ pub struct MacosDaemonOwnershipStatus { pub active_owner: Option, pub owner_epoch: Option, pub conflict: Option, + pub recovery_required: Option, +} + +/// Path-free recovery state for an interrupted local owner handover. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct MacosDaemonOwnerRecoveryRequiredStatus { + pub requested_owner: Option, + pub prior_owner: Option, + pub phase: Option, } /// Persistability and redacted content style of a macOS screen selection. @@ -237,7 +247,8 @@ mod tests { use super::{ InputSourcePlatformStatus, InputSourceStatus, MacosDaemonOwnerConflictStatus, - MacosDaemonOwnershipStatus, MacosSelectionStatus, MacosTahoeSelectionStatus, + MacosDaemonOwnerRecoveryRequiredStatus, MacosDaemonOwnershipStatus, MacosSelectionStatus, + MacosTahoeSelectionStatus, }; #[test] @@ -251,6 +262,12 @@ mod tests { "observed_at_ms": 1_725_000_000_789_u64, "future_conflict_field": true }, + "recovery_required": { + "requested_owner": "homebrew_service", + "prior_owner": "app_sidecar", + "phase": "requested_owner_started", + "future_recovery_field": true + }, "future_owner_field": { "available": true } })) .expect("macOS daemon ownership should decode"); @@ -265,6 +282,14 @@ mod tests { observed_at_ms: Some(1_725_000_000_789), }) ); + assert_eq!( + ownership.recovery_required, + Some(MacosDaemonOwnerRecoveryRequiredStatus { + requested_owner: Some("homebrew_service".to_owned()), + prior_owner: Some("app_sidecar".to_owned()), + phase: Some("requested_owner_started".to_owned()), + }) + ); let partial: MacosDaemonOwnershipStatus = serde_json::from_value(json!({})) .expect("partial macOS daemon ownership should decode"); diff --git a/crates/hypercolor-ui/src/app.rs b/crates/hypercolor-ui/src/app.rs index fda452a8e..2c74bbb30 100644 --- a/crates/hypercolor-ui/src/app.rs +++ b/crates/hypercolor-ui/src/app.rs @@ -42,8 +42,8 @@ use crate::ws::messages::scene_event_affects_active_effect; use crate::ws::{ AudioLevel, BackpressureNotice, CanvasFrame, ControlSurfaceEventHint, DeviceEventHint, EffectErrorHint, ExtensionEventHint, InputInjectEdge, InputSourceStatusEventHint, - InteractivePreviewLifecycle, InteractivePreviewRequest, PerformanceMetrics, SceneEventHint, - ScreenZonesFrame, WsManager, + InteractivePreviewLifecycle, InteractivePreviewRequest, MacosDaemonOwnershipEventHint, + PerformanceMetrics, SceneEventHint, ScreenZonesFrame, WsManager, }; mod effect_state; @@ -103,6 +103,8 @@ pub struct WsContext { /// Latest safe source-health transition, used only to invalidate the /// canonical REST status snapshot. pub last_input_source_status_event: ReadSignal>, + /// Latest daemon-owner transition, used to invalidate canonical status. + pub last_macos_daemon_ownership_event: ReadSignal>, /// Increments each time the daemon socket (re)opens. Fold into fetcher /// epochs to refetch REST mirrors after a reconnect gap, since bus /// events are not replayed. @@ -528,6 +530,7 @@ pub fn app_view(ext: UiExtensions) -> impl IntoView { last_control_surface_event: ws.last_control_surface_event, last_extension_event: ws.last_extension_event, last_input_source_status_event: ws.last_input_source_status_event, + last_macos_daemon_ownership_event: ws.last_macos_daemon_ownership_event, connection_generation: ws.connection_generation, layer_health: ws.layer_health, audio_level: ws.audio_level, diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index d5058b926..3765817d5 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -9,9 +9,14 @@ use leptos_icons::Icon; use crate::components::settings_controls::*; use crate::icons::*; +use crate::input_access::{input_status_epoch, primary_input_source_issue}; use crate::render_presets::{ CANVAS_PRESETS, MAX_CUSTOM_CANVAS_HEIGHT, MAX_CUSTOM_CANVAS_WIDTH, canvas_preset_key, }; +use crate::{ + api::{InputSourcePlatformStatus, InputSourceStatus, InputStatus, SystemStatus}, + app::WsContext, +}; mod about; mod audio; @@ -122,6 +127,7 @@ pub fn CaptureSection( on_change: Callback<(String, serde_json::Value)>, on_reset: Callback, ) -> impl IntoView { + let ws = expect_context::(); let enabled = Signal::derive(move || read_config(config, |cfg| cfg.capture.enabled)); let source = Signal::derive(move || read_config(config, |cfg| cfg.capture.source.clone())); let capture_fps = @@ -163,6 +169,18 @@ pub fn CaptureSection( let reset_calibration = Callback::new(move |()| { on_reset.run("capture.calibration".to_owned()); }); + let capture_status = LocalResource::new(move || { + let connection_generation = ws.connection_generation.get(); + let source_event = ws.last_input_source_status_event.get(); + let owner_event = ws.last_macos_daemon_ownership_event.get(); + let epoch = config.with(|current| { + input_status_epoch(connection_generation, source_event, current.as_ref()) + }); + async move { + let _ = (epoch, owner_event); + crate::api::fetch_status().await + } + }); // Monitor picker data. Empty means the platform's backend owns source // selection (the XDG portal on Linux), so the portal button renders @@ -194,16 +212,34 @@ pub fn CaptureSection( }); let (picking, set_picking) = signal(false); + let (authorizing, set_authorizing) = signal(false); + let (action_error, set_action_error) = signal(None::); let pick_source = move |_| { if picking.get_untracked() { return; } set_picking.set(true); + set_action_error.set(None); leptos::task::spawn_local(async move { if let Err(e) = crate::api::pick_capture_source().await { - leptos::logging::warn!("Capture source pick failed: {e}"); + set_action_error.set(Some(e)); } set_picking.set(false); + capture_status.refetch(); + }); + }; + let authorize_screen = move |_| { + if authorizing.get_untracked() { + return; + } + set_authorizing.set(true); + set_action_error.set(None); + leptos::task::spawn_local(async move { + if let Err(error) = crate::api::authorize_screen_recording().await { + set_action_error.set(Some(error)); + } + set_authorizing.set(false); + capture_status.refetch(); }); }; @@ -217,6 +253,40 @@ pub fn CaptureSection( value=enabled on_change=on_change /> + +
+
+
"Screen Recording"
+
+ "Authorize the active macOS capture owner before choosing content." +
+
+ +
+
+ {move || capture_status + .get() + .and_then(Result::ok) + .and_then(|status| macos_screen_restart_coordinates(&status)) + .map(|(owner, epoch)| view! { + + })} + {move || action_error.get().map(|error| view! { +
+ {error} +
+ })} + +
+
+ "Capture health" +
+ {move || match capture_status.get() { + None => view! { +
"Reading capture health…"
+ }.into_any(), + Some(Err(error)) => view! { +
{format!("Capture health unavailable: {error}")}
+ }.into_any(), + Some(Ok(status)) => screen_status_view(status.input).into_any(), + }} +
@@ -398,6 +488,296 @@ pub fn CaptureSection( } } +fn macos_screen_needs_authorization(status: &InputStatus) -> bool { + status.sources.iter().any(|source| { + if source.retired { + return false; + } + let Some(InputSourcePlatformStatus::MacosScreen { state, tcc, .. }) = + source.platform.as_ref() + else { + return false; + }; + matches!( + state.as_deref(), + Some("needs_user_action" | "permission_denied" | "revoked") + ) || matches!( + tcc.as_deref(), + Some("not_determined" | "denied" | "revoked") + ) + }) +} + +fn macos_screen_needs_restart(status: &InputStatus) -> bool { + status.sources.iter().any(|source| { + if source.retired { + return false; + } + matches!( + source.platform.as_ref(), + Some(InputSourcePlatformStatus::MacosScreen { state, .. }) + if state.as_deref() == Some("needs_process_restart") + ) + }) +} + +fn macos_screen_restart_coordinates(status: &SystemStatus) -> Option<(String, u64)> { + macos_screen_needs_restart(&status.input) + .then_some(status.macos_daemon_ownership.as_ref()) + .flatten() + .and_then(|ownership| { + Some(( + validate_macos_restart_owner(ownership.active_owner.as_deref()?)?, + ownership.owner_epoch?, + )) + }) +} + +pub(super) fn validate_macos_restart_owner(owner: &str) -> Option { + match owner { + "app_sidecar" | "launchd_service" | "homebrew_service" | "standalone" => { + Some(owner.to_owned()) + } + _ => None, + } +} + +#[component] +pub(super) fn MacosCaptureOwnerRestartAction( + owner: String, + epoch: u64, + on_complete: Callback<()>, +) -> impl IntoView { + let native_available = crate::tauri_bridge::is_tauri_available(); + let owner_for_action = StoredValue::new(owner.clone()); + let (restarting, set_restarting) = signal(false); + let (result_message, set_result_message) = signal(None::); + let restart = move |_| { + if restarting.get_untracked() || !native_available { + return; + } + set_restarting.set(true); + set_result_message.set(None); + let owner = owner_for_action.get_value(); + leptos::task::spawn_local(async move { + match crate::tauri_bridge::restart_macos_capture_owner(&owner, epoch).await { + Ok(Some(crate::tauri_bridge::MacosCaptureOwnerRestartOutcome::Restarted { + owner, + .. + })) => { + let message = format!("{} restarted.", input::humanize(&owner)); + crate::toasts::toast_success(&message); + set_result_message.set(Some(message)); + } + Ok(Some( + crate::tauri_bridge::MacosCaptureOwnerRestartOutcome::UserActionRequired { + remedy, + .. + }, + )) => set_result_message.set(Some(match remedy { + crate::tauri_bridge::MacosOwnerRemedy::StopStandaloneOwner { pid } => { + format!("Stop standalone process {pid}, then retry.") + } + _ => "The active owner requires a local user action.".to_owned(), + })), + Ok(Some(crate::tauri_bridge::MacosCaptureOwnerRestartOutcome::Unknown)) => { + set_result_message.set(Some( + "A newer Hypercolor app returned an unknown restart result.".to_owned(), + )); + } + Ok(None) => set_result_message.set(Some( + "requires_app_ui: open Hypercolor.app to restart the capture owner.".to_owned(), + )), + Err(error) => { + set_result_message.set(Some(format!("Capture owner restart failed: {error}"))) + } + } + set_restarting.set(false); + on_complete.run(()); + }); + }; + + view! { +
+
+
"Restart capture owner"
+
+ {format!( + "The grant is active, but {} must restart before capture can resume.", + input::humanize(&owner), + )} +
+ +
+ "requires_app_ui: open Hypercolor.app to restart this process." +
+
+ {move || result_message.get().map(|message| view! { +
{message}
+ })} +
+ +
+ } +} + +fn screen_status_view(status: InputStatus) -> impl IntoView { + let screens = status + .sources + .into_iter() + .filter(|source| { + !source.retired + && (source.kind == "screen" + || matches!( + source.platform, + Some(InputSourcePlatformStatus::MacosScreen { .. }) + )) + }) + .collect::>(); + if screens.is_empty() { + view! { +
+ "No screen-capture source is registered for this platform session." +
+ } + .into_any() + } else { + view! { +
+ {screens.into_iter().map(screen_source_view).collect_view()} +
+ } + .into_any() + } +} + +fn screen_source_view(source: InputSourceStatus) -> impl IntoView { + let issue = primary_input_source_issue(&source); + let warning = issue.is_some() + || matches!(source.state.as_str(), "failed" | "degraded" | "unavailable") + || (source.demanded && source.freshness == "stale"); + let state_class = if warning { + "text-status-warning" + } else if source.demanded && source.state == "live" { + "text-status-success" + } else { + "text-fg-tertiary" + }; + let issue_message = issue.map(|issue| issue.message.clone()); + let source_remediation = issue.and_then(|issue| issue.remediation.clone()); + let demand = if source.demanded { + "active demand" + } else { + "no active consumers" + }; + + view! { +
+
+
+
{source.source_id}
+
{format!("{} · {demand}", source.backend)}
+
+ + {input::humanize(&source.state)} + +
+ {issue_message.map(|message| view! { +
{message}
+ })} + {source_remediation.map(|message| view! { +
{message}
+ })} + {source.platform.map(input::platform_status_view)} +
+ } +} + +#[cfg(test)] +mod macos_capture_tests { + use crate::api::{ + InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, + SystemStatus, + }; + + use super::{macos_screen_restart_coordinates, validate_macos_restart_owner}; + + fn system_status( + input: InputStatus, + macos_daemon_ownership: Option, + ) -> SystemStatus { + SystemStatus { + running: true, + version: "test".to_owned(), + config_path: String::new(), + uptime_seconds: 1, + device_count: 0, + effect_count: 0, + active_effect: None, + active_scene: None, + active_scene_snapshot_locked: false, + global_brightness: 100, + compositor_acceleration: crate::api::RenderAccelerationStatus::default(), + render_loop: crate::api::RenderLoopStatus::default(), + capabilities: Vec::new(), + input, + macos_daemon_ownership, + } + } + + #[test] + fn screen_restart_coordinates_require_exact_restart_state() { + let mut status = system_status( + InputStatus { + sources: vec![InputSourceStatus { + kind: "screen".to_owned(), + platform: Some(InputSourcePlatformStatus::MacosScreen { + state: Some("needs_process_restart".to_owned()), + tcc: Some("authorized".to_owned()), + owner: Some("launchd_service".to_owned()), + selection: None, + tahoe_selection: None, + owner_conflict: None, + }), + ..InputSourceStatus::default() + }], + ..InputStatus::default() + }, + Some(MacosDaemonOwnershipStatus { + active_owner: Some("launchd_service".to_owned()), + owner_epoch: Some(31), + ..MacosDaemonOwnershipStatus::default() + }), + ); + + assert_eq!( + macos_screen_restart_coordinates(&status), + Some(("launchd_service".to_owned(), 31)) + ); + status.input.sources[0].retired = true; + assert_eq!(macos_screen_restart_coordinates(&status), None); + status.input.sources[0].retired = false; + status.input.sources.clear(); + assert_eq!(macos_screen_restart_coordinates(&status), None); + } + + #[test] + fn owner_command_names_are_closed() { + assert_eq!( + validate_macos_restart_owner("homebrew_service").as_deref(), + Some("homebrew_service") + ); + assert_eq!(validate_macos_restart_owner("future_owner"), None); + } +} + // ── Network ──────────────────────────────────────────────────────────────── #[component] diff --git a/crates/hypercolor-ui/src/components/settings_sections/input.rs b/crates/hypercolor-ui/src/components/settings_sections/input.rs index d0f614df0..4950bc542 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/input.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/input.rs @@ -1,8 +1,8 @@ use hypercolor_types::config::{HypercolorConfig, InteractionRoutePolicy}; use leptos::prelude::*; -use super::read_config; -use crate::api::{self, InputSourceStatus, InputStatus}; +use super::{MacosCaptureOwnerRestartAction, read_config, validate_macos_restart_owner}; +use crate::api::{self, InputSourcePlatformStatus, InputSourceStatus, InputStatus}; use crate::app::WsContext; use crate::components::settings_controls::{ AdvancedDisclosure, SectionHeader, SectionReset, SettingDropdown, SettingToggle, @@ -48,14 +48,31 @@ pub fn InputSection( let input_status = LocalResource::new(move || { let connection_generation = ws.connection_generation.get(); let source_event = ws.last_input_source_status_event.get(); + let owner_event = ws.last_macos_daemon_ownership_event.get(); let epoch = config.with(|current| { input_status_epoch(connection_generation, source_event, current.as_ref()) }); async move { - let _ = epoch; - api::fetch_status().await.map(|status| status.input) + let _ = (epoch, owner_event); + api::fetch_status().await } }); + let (authorizing, set_authorizing) = signal(false); + let (authorization_error, set_authorization_error) = signal(None::); + let authorize_keyboard = move |_| { + if authorizing.get_untracked() { + return; + } + set_authorizing.set(true); + set_authorization_error.set(None); + leptos::task::spawn_local(async move { + match api::authorize_input_monitoring().await { + Ok(()) => input_status.refetch(), + Err(error) => set_authorization_error.set(Some(error)), + } + set_authorizing.set(false); + }); + }; view! {
@@ -100,6 +117,46 @@ pub fn InputSection( /> + +
+
+
"Input Monitoring"
+
+ "Keyboard effects need this macOS permission. Pointer-only effects do not." +
+
+ +
+
+ {move || input_status + .get() + .and_then(Result::ok) + .and_then(|status| macos_keyboard_restart_coordinates(&status)) + .map(|(owner, epoch)| view! { + + })} + {move || authorization_error.get().map(|error| view! { +
+ {error} +
+ })} +
"Source health" @@ -111,7 +168,7 @@ pub fn InputSection( Some(Err(error)) => view! {
{format!("Input health unavailable: {error}")}
}.into_any(), - Some(Ok(status)) => input_status_view(status).into_any(), + Some(Ok(status)) => input_status_view(status.input).into_any(), }}
impl IntoView { let sources = status .sources .into_iter() - .filter(|source| !source.retired) + .filter(|source| !source.retired && !is_screen_source(source)) .collect::>(); view! { @@ -188,7 +245,7 @@ fn input_status_view(status: InputStatus) -> impl IntoView { } } -fn input_source_view(source: InputSourceStatus) -> impl IntoView { +pub(super) fn input_source_view(source: InputSourceStatus) -> impl IntoView { let issue = primary_input_source_issue(&source); let issue_message = issue.map(|issue| issue.message.clone()); let source_remediation = issue.and_then(|issue| issue.remediation.clone()); @@ -217,6 +274,7 @@ fn input_source_view(source: InputSourceStatus) -> impl IntoView { .last_sample_age_ms .map(|age| format!(" · sample {age} ms ago")) .unwrap_or_default(); + let platform = source.platform.clone(); view! {
@@ -243,10 +301,174 @@ fn input_source_view(source: InputSourceStatus) -> impl IntoView { {source_remediation.map(|message| view! {
{message}
})} + {platform.map(platform_status_view)}
} } +pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl IntoView { + match platform { + InputSourcePlatformStatus::MacosInput { + keyboard, + pointer, + keyboard_tcc, + keyboard_owner, + pointer_owner, + owner_conflict, + } => { + let state = format!( + "Keyboard {} · pointer {} · Input Monitoring {}", + humanize_optional(keyboard.as_deref()), + humanize_optional(pointer.as_deref()), + humanize_optional(keyboard_tcc.as_deref()), + ); + let owners = format!( + "Keyboard owner {} · pointer owner {}", + humanize_optional(keyboard_owner.as_deref()), + humanize_optional(pointer_owner.as_deref()), + ); + view! { +
+
{state}
+
{owners}
+ {owner_conflict.map(|conflict| view! { +
+ {format!( + "Owner conflict: {} is active; {} also attempted startup.", + humanize_optional(conflict.active.as_deref()), + humanize_optional(conflict.contender.as_deref()), + )} +
+ })} +
+ } + .into_any() + } + InputSourcePlatformStatus::MacosScreen { + state, + tcc, + owner, + selection, + tahoe_selection, + owner_conflict, + } => { + let selection = selection + .as_ref() + .map(screen_selection_label) + .unwrap_or_else(|| "Unknown selection".to_owned()); + let range = tahoe_selection + .and_then(|capabilities| capabilities.hdr_capture) + .map_or( + "Dynamic range pending", + |hdr| if hdr { "HDR" } else { "SDR" }, + ); + view! { +
+
{format!( + "Screen {} · Screen Recording {} · owner {}", + humanize_optional(state.as_deref()), + humanize_optional(tcc.as_deref()), + humanize_optional(owner.as_deref()), + )}
+
{format!("{selection} · {range}")}
+ {owner_conflict.map(|conflict| view! { +
+ {format!( + "Owner conflict: {} is active; {} also attempted startup.", + humanize_optional(conflict.active.as_deref()), + humanize_optional(conflict.contender.as_deref()), + )} +
+ })} +
+ } + .into_any() + } + InputSourcePlatformStatus::Unknown => view! { +
+ "Platform details require a newer Hypercolor app." +
+ } + .into_any(), + } +} + +fn screen_selection_label(selection: &crate::api::MacosSelectionStatus) -> String { + match selection { + crate::api::MacosSelectionStatus::None => "No source selected".to_owned(), + crate::api::MacosSelectionStatus::Display { source_id } => { + source_id.as_deref().map_or_else( + || "Display selected".to_owned(), + |id| format!("Display {id}"), + ) + } + crate::api::MacosSelectionStatus::SessionScoped { content_style } => { + content_style.as_deref().map_or_else( + || "Session-scoped source".to_owned(), + |style| format!("Session-scoped {}", humanize(style)), + ) + } + crate::api::MacosSelectionStatus::Unknown => "Unknown selection".to_owned(), + } +} + +fn humanize_optional(value: Option<&str>) -> String { + value.map_or_else(|| "unknown".to_owned(), humanize) +} + +pub(super) fn macos_keyboard_needs_authorization(status: &InputStatus) -> bool { + status.sources.iter().any(|source| { + if source.retired { + return false; + } + let Some(InputSourcePlatformStatus::MacosInput { + keyboard, + keyboard_tcc, + .. + }) = source.platform.as_ref() + else { + return false; + }; + matches!( + keyboard.as_deref(), + Some("needs_user_action" | "permission_denied" | "revoked") + ) || matches!( + keyboard_tcc.as_deref(), + Some("not_determined" | "denied" | "revoked") + ) + }) +} + +fn macos_keyboard_restart_coordinates(status: &crate::api::SystemStatus) -> Option<(String, u64)> { + let needs_restart = status.input.sources.iter().any(|source| { + if source.retired { + return false; + } + matches!( + source.platform.as_ref(), + Some(InputSourcePlatformStatus::MacosInput { keyboard, .. }) + if keyboard.as_deref() == Some("needs_process_restart") + ) + }); + needs_restart + .then_some(status.macos_daemon_ownership.as_ref()) + .flatten() + .and_then(|ownership| { + Some(( + validate_macos_restart_owner(ownership.active_owner.as_deref()?)?, + ownership.owner_epoch?, + )) + }) +} + +fn is_screen_source(source: &InputSourceStatus) -> bool { + source.kind == "screen" + || matches!( + source.platform, + Some(InputSourcePlatformStatus::MacosScreen { .. }) + ) +} + fn route_value(route: InteractionRoutePolicy) -> String { match route { InteractionRoutePolicy::Host => "host", @@ -256,10 +478,152 @@ fn route_value(route: InteractionRoutePolicy) -> String { .to_owned() } -fn humanize(value: &str) -> String { +pub(super) fn humanize(value: &str) -> String { let mut words = value.replace('_', " "); if let Some(first) = words.get_mut(0..1) { first.make_ascii_uppercase(); } words } + +#[cfg(test)] +mod tests { + use crate::api::{ + InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, + SystemStatus, + }; + + use super::{macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates}; + + fn system_status( + input: InputStatus, + macos_daemon_ownership: Option, + ) -> SystemStatus { + SystemStatus { + running: true, + version: "test".to_owned(), + config_path: String::new(), + uptime_seconds: 1, + device_count: 0, + effect_count: 0, + active_effect: None, + active_scene: None, + active_scene_snapshot_locked: false, + global_brightness: 100, + compositor_acceleration: crate::api::RenderAccelerationStatus::default(), + render_loop: crate::api::RenderLoopStatus::default(), + capabilities: Vec::new(), + input, + macos_daemon_ownership, + } + } + + #[test] + fn keyboard_authorization_action_tracks_only_protected_keyboard_state() { + let mut status = InputStatus { + sources: vec![InputSourceStatus { + platform: Some(InputSourcePlatformStatus::MacosInput { + keyboard: Some("needs_user_action".to_owned()), + pointer: Some("live".to_owned()), + keyboard_tcc: Some("not_determined".to_owned()), + keyboard_owner: Some("app_sidecar".to_owned()), + pointer_owner: Some("app_sidecar".to_owned()), + owner_conflict: None, + }), + ..InputSourceStatus::default() + }], + ..InputStatus::default() + }; + assert!(macos_keyboard_needs_authorization(&status)); + + status.sources[0].retired = true; + assert!(!macos_keyboard_needs_authorization(&status)); + status.sources[0].retired = false; + + status.sources[0].platform = Some(InputSourcePlatformStatus::MacosInput { + keyboard: Some("live".to_owned()), + pointer: Some("live".to_owned()), + keyboard_tcc: Some("authorized".to_owned()), + keyboard_owner: Some("app_sidecar".to_owned()), + pointer_owner: Some("app_sidecar".to_owned()), + owner_conflict: None, + }); + assert!(!macos_keyboard_needs_authorization(&status)); + } + + #[test] + fn screen_authorization_action_tracks_only_screen_recording_state() { + let mut status = InputStatus { + sources: vec![InputSourceStatus { + kind: "screen".to_owned(), + platform: Some(InputSourcePlatformStatus::MacosScreen { + state: Some("permission_denied".to_owned()), + tcc: Some("denied".to_owned()), + owner: Some("app_sidecar".to_owned()), + selection: None, + tahoe_selection: None, + owner_conflict: None, + }), + ..InputSourceStatus::default() + }], + ..InputStatus::default() + }; + assert!(super::super::macos_screen_needs_authorization(&status)); + + status.sources[0].retired = true; + assert!(!super::super::macos_screen_needs_authorization(&status)); + status.sources[0].retired = false; + + status.sources[0].platform = Some(InputSourcePlatformStatus::MacosScreen { + state: Some("live".to_owned()), + tcc: Some("authorized".to_owned()), + owner: Some("app_sidecar".to_owned()), + selection: None, + tahoe_selection: None, + owner_conflict: None, + }); + assert!(!super::super::macos_screen_needs_authorization(&status)); + } + + #[test] + fn restart_coordinates_require_exact_state_owner_and_epoch() { + let mut status = system_status( + InputStatus { + sources: vec![InputSourceStatus { + platform: Some(InputSourcePlatformStatus::MacosInput { + keyboard: Some("needs_process_restart".to_owned()), + pointer: Some("live".to_owned()), + keyboard_tcc: Some("authorized".to_owned()), + keyboard_owner: Some("homebrew_service".to_owned()), + pointer_owner: Some("homebrew_service".to_owned()), + owner_conflict: None, + }), + ..InputSourceStatus::default() + }], + ..InputStatus::default() + }, + Some(MacosDaemonOwnershipStatus { + active_owner: Some("homebrew_service".to_owned()), + owner_epoch: Some(29), + ..MacosDaemonOwnershipStatus::default() + }), + ); + + assert_eq!( + macos_keyboard_restart_coordinates(&status), + Some(("homebrew_service".to_owned(), 29)) + ); + status.input.sources[0].retired = true; + assert_eq!(macos_keyboard_restart_coordinates(&status), None); + status.input.sources[0].retired = false; + status.input.sources[0].platform = Some(InputSourcePlatformStatus::MacosInput { + keyboard: Some("permission_denied".to_owned()), + pointer: Some("live".to_owned()), + keyboard_tcc: Some("denied".to_owned()), + keyboard_owner: Some("homebrew_service".to_owned()), + pointer_owner: Some("homebrew_service".to_owned()), + owner_conflict: None, + }); + assert_eq!(macos_keyboard_restart_coordinates(&status), None); + } +} diff --git a/crates/hypercolor-ui/src/components/settings_sections/session.rs b/crates/hypercolor-ui/src/components/settings_sections/session.rs index 12f966533..3bc6163f1 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/session.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/session.rs @@ -3,9 +3,14 @@ use leptos_icons::Icon; use hypercolor_types::config::HypercolorConfig; +use crate::api::{self, MacosDaemonOwnershipStatus}; +use crate::app::WsContext; use crate::components::settings_controls::*; use crate::icons::*; -use crate::tauri_bridge::{self, WindowsDaemonServiceStatus, windows_daemon_service_conflict}; +use crate::tauri_bridge::{ + self, MacosDaemonOwnerChoice, MacosOwnerCoordinatorOutcome, MacosOwnerRemedy, + WindowsDaemonServiceStatus, windows_daemon_service_conflict, +}; use crate::toasts; use super::{off_output_behavior_value, read_config, sleep_behavior_value}; @@ -62,6 +67,7 @@ pub fn SessionSection(
+ impl IntoView { + let ws = expect_context::(); + let native_available = tauri_bridge::is_tauri_available(); + let ownership = LocalResource::new(move || { + let generation = ws.connection_generation.get(); + let event = ws.last_macos_daemon_ownership_event.get(); + async move { + let _ = (generation, event); + api::fetch_status() + .await + .map(|status| status.macos_daemon_ownership) + } + }); + let offline = LocalResource::new(tauri_bridge::macos_daemon_owner_offline_status); + let (switching, set_switching) = signal(None::); + let (result_message, set_result_message) = signal(None::); + let (starting_offline, set_starting_offline) = signal(false); + let (offline_message, set_offline_message) = signal(None::); + let choose_owner = Callback::new(move |owner: MacosDaemonOwnerChoice| { + if switching.get_untracked().is_some() { + return; + } + set_switching.set(Some(owner)); + set_result_message.set(None); + leptos::task::spawn_local(async move { + let result = tauri_bridge::choose_macos_daemon_owner(owner).await; + match result { + Ok(Some(outcome)) => { + let message = macos_owner_outcome_message(&outcome); + if matches!(outcome, MacosOwnerCoordinatorOutcome::Active { .. }) { + toasts::toast_success(&message); + } + set_result_message.set(Some(message)); + } + Ok(None) => set_result_message.set(Some( + "requires_app_ui: open this page in Hypercolor.app to change daemon ownership." + .to_owned(), + )), + Err(error) => { + set_result_message.set(Some(format!("Daemon owner change failed: {error}"))) + } + } + set_switching.set(None); + ownership.refetch(); + offline.refetch(); + }); + }); + let start_offline_owner = Callback::new(move |remedy: MacosOwnerRemedy| { + if starting_offline.get_untracked() { + return; + } + set_starting_offline.set(true); + set_offline_message.set(None); + leptos::task::spawn_local(async move { + match tauri_bridge::execute_macos_daemon_owner_offline_remedy(&remedy).await { + Ok(Some(outcome)) => { + let message = format!( + "{} started successfully.", + humanize_owner(&outcome.owner), + ); + toasts::toast_success(&message); + set_offline_message.set(Some(message)); + } + Ok(None) => set_offline_message.set(Some( + "requires_app_ui: open this page in Hypercolor.app to start the selected owner." + .to_owned(), + )), + Err(error) => set_offline_message.set(Some(format!( + "Selected daemon owner could not start: {error}" + ))), + } + set_starting_offline.set(false); + ownership.refetch(); + offline.refetch(); + }); + }); + + view! { + {move || match ownership.get() { + Some(Ok(Some(status))) => view! { + + }.into_any(), + _ => ().into_any(), + }} + {move || match offline.get() { + Some(Ok(Some(status))) => view! { + + }.into_any(), + Some(Err(error)) if native_available => view! { + +
+ {format!("Daemon owner status unavailable: {error}")} +
+
+ }.into_any(), + _ => ().into_any(), + }} + } +} + +#[component] +fn MacosDaemonOwnerOfflinePanel( + status: tauri_bridge::MacosDaemonOwnerOfflineStatus, + native_available: bool, + #[prop(into)] starting: Signal, + #[prop(into)] result_message: Signal>, + on_start: Callback, +) -> impl IntoView { + let remedy = status.remedy.clone(); + let actionable = matches!( + remedy, + MacosOwnerRemedy::StartLaunchdService | MacosOwnerRemedy::StartHomebrewService + ); + let button_label = owner_remedy_button_label(&remedy); + let remedy_for_action = StoredValue::new(remedy.clone()); + + view! { + +
+
+ +
+
"Selected daemon owner is offline"
+
+ {format!( + "{} is selected. {}", + humanize_owner(&status.selected_owner), + owner_remedy_label(&remedy), + )} +
+
+
+ + + +
+ {move || result_message.get().map(|message| view! { +
{message}
+ })} +
+ } +} + +#[component] +fn MacosDaemonOwnershipStatusPanel( + status: MacosDaemonOwnershipStatus, + native_available: bool, + #[prop(into)] switching: Signal>, + #[prop(into)] result_message: Signal>, + on_choose: Callback, +) -> impl IntoView { + let owner = status + .active_owner + .as_deref() + .map_or_else(|| "Unknown owner".to_owned(), humanize_owner); + let epoch = status + .owner_epoch + .map(|epoch| format!("epoch {epoch}")) + .unwrap_or_else(|| "epoch pending".to_owned()); + let conflict = status.conflict.clone(); + let choices = macos_owner_choices(&status); + let has_choices = !choices.is_empty(); + let recovery = status.recovery_required.clone(); + + view! { + +
+
+
+
"macOS daemon owner"
+
{format!("{owner} · {epoch}")}
+
+ + "local only" + +
+ {conflict.map(|conflict| view! { +
+ {format!( + "{} is active; {} also attempted startup.", + conflict.active.as_deref().map_or_else( + || "Unknown".to_owned(), + humanize_owner, + ), + conflict.contender.as_deref().map_or_else( + || "Unknown".to_owned(), + humanize_owner, + ), + )} +
+ })} + {recovery.map(|recovery| view! { +
+ {format!( + "Owner recovery is pending at {} while moving from {} to {}.", + recovery.phase.as_deref().map_or("an unknown phase".to_owned(), humanize_owner), + recovery.prior_owner.as_deref().map_or("an unknown owner".to_owned(), humanize_owner), + recovery.requested_owner.as_deref().map_or("an unknown owner".to_owned(), humanize_owner), + )} +
+ })} + +
+ {choices.clone().into_iter().map(|choice| { + let label = owner_choice_label(choice); + view! { + + } + }).collect_view()} +
+ +
+ "requires_app_ui: open Hypercolor.app to choose the active daemon owner." +
+
+
+ {move || result_message.get().map(|message| view! { +
{message}
+ })} +
+
+ } +} + +fn macos_owner_choices(status: &MacosDaemonOwnershipStatus) -> Vec { + let mut choices = Vec::new(); + for owner in [ + status.active_owner.as_deref(), + status + .conflict + .as_ref() + .and_then(|conflict| conflict.contender.as_deref()), + ] + .into_iter() + .flatten() + { + if let Some(choice) = macos_owner_choice(owner) + && !choices.contains(&choice) + { + choices.push(choice); + } + } + choices +} + +fn macos_owner_choice(owner: &str) -> Option { + match owner { + "app_sidecar" => Some(MacosDaemonOwnerChoice::AppSidecar), + "launchd_service" | "direct_launchd" => Some(MacosDaemonOwnerChoice::DirectLaunchd), + "homebrew_service" | "homebrew" => Some(MacosDaemonOwnerChoice::Homebrew), + _ => None, + } +} + +const fn owner_choice_label(owner: MacosDaemonOwnerChoice) -> &'static str { + match owner { + MacosDaemonOwnerChoice::AppSidecar => "Use Hypercolor.app", + MacosDaemonOwnerChoice::DirectLaunchd => "Use launchd service", + MacosDaemonOwnerChoice::Homebrew => "Use Homebrew service", + MacosDaemonOwnerChoice::Standalone => "Use terminal daemon", + } +} + +fn macos_owner_outcome_message(outcome: &MacosOwnerCoordinatorOutcome) -> String { + match outcome { + MacosOwnerCoordinatorOutcome::Active { owner, owner_epoch } => format!( + "{} now owns the daemon at epoch {owner_epoch}.", + humanize_owner(owner), + ), + MacosOwnerCoordinatorOutcome::PendingStandalone { + requested_owner, + remedy, + } => format!( + "{} is pending. {}", + humanize_owner(requested_owner), + owner_remedy_label(remedy), + ), + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner, + failure, + } => format!( + "The handover failed and {} was restored: {failure}", + humanize_owner(prior_owner), + ), + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner, + prior_owner, + phase, + } => format!( + "Recovery is required at {} while moving from {} to {}.", + humanize_owner(phase), + humanize_owner(prior_owner), + humanize_owner(requested_owner), + ), + MacosOwnerCoordinatorOutcome::Unknown => { + "The native app returned a newer owner result. Refresh status for the authoritative state." + .to_owned() + } + } +} + +fn owner_remedy_label(remedy: &MacosOwnerRemedy) -> String { + match remedy { + MacosOwnerRemedy::StopStandaloneOwner { pid } => { + format!("Stop standalone process {pid}, then retry the handover.") + } + MacosOwnerRemedy::StartAppSidecar => "Start Hypercolor.app.".to_owned(), + MacosOwnerRemedy::StartLaunchdService => "Start the launchd service.".to_owned(), + MacosOwnerRemedy::StartHomebrewService => "Start the Homebrew service.".to_owned(), + MacosOwnerRemedy::Unknown => { + "Follow the action shown by a newer Hypercolor app.".to_owned() + } + } +} + +const fn owner_remedy_button_label(remedy: &MacosOwnerRemedy) -> &'static str { + match remedy { + MacosOwnerRemedy::StartLaunchdService => "Start launchd service", + MacosOwnerRemedy::StartHomebrewService => "Start Homebrew service", + MacosOwnerRemedy::StopStandaloneOwner { .. } + | MacosOwnerRemedy::StartAppSidecar + | MacosOwnerRemedy::Unknown => "Unavailable", + } +} + +fn humanize_owner(owner: &str) -> String { + match owner { + "app_sidecar" => "Hypercolor.app sidecar".to_owned(), + "launchd_service" | "direct_launchd" => "launchd service".to_owned(), + "homebrew_service" | "homebrew" => "Homebrew service".to_owned(), + "standalone" => "terminal daemon".to_owned(), + value => { + let mut value = value.replace('_', " "); + if let Some(first) = value.get_mut(0..1) { + first.make_ascii_uppercase(); + } + value + } + } +} + #[component] fn NativeStartupPanel() -> impl IntoView { let native_available = tauri_bridge::is_tauri_available(); @@ -341,3 +719,53 @@ fn WindowsDaemonServiceStatusPanel( } } + +#[cfg(test)] +mod tests { + use crate::api::{MacosDaemonOwnerConflictStatus, MacosDaemonOwnershipStatus}; + use crate::tauri_bridge::MacosDaemonOwnerChoice; + + use super::{humanize_owner, macos_owner_choices}; + + #[test] + fn owner_choices_follow_only_the_published_conflict() { + let status = MacosDaemonOwnershipStatus { + active_owner: Some("app_sidecar".to_owned()), + owner_epoch: Some(8), + conflict: Some(MacosDaemonOwnerConflictStatus { + active: Some("app_sidecar".to_owned()), + contender: Some("homebrew_service".to_owned()), + observed_at_ms: Some(42), + }), + recovery_required: None, + }; + + assert_eq!( + macos_owner_choices(&status), + vec![ + MacosDaemonOwnerChoice::AppSidecar, + MacosDaemonOwnerChoice::Homebrew, + ] + ); + } + + #[test] + fn standalone_owner_is_named_but_never_offered_as_a_managed_target() { + let status = MacosDaemonOwnershipStatus { + active_owner: Some("standalone".to_owned()), + owner_epoch: Some(3), + conflict: Some(MacosDaemonOwnerConflictStatus { + active: Some("standalone".to_owned()), + contender: Some("launchd_service".to_owned()), + observed_at_ms: Some(43), + }), + recovery_required: None, + }; + + assert_eq!(humanize_owner("standalone"), "terminal daemon"); + assert_eq!( + macos_owner_choices(&status), + vec![MacosDaemonOwnerChoice::DirectLaunchd] + ); + } +} diff --git a/crates/hypercolor-ui/src/pages/settings.rs b/crates/hypercolor-ui/src/pages/settings.rs index eb6f23b19..f7d594f0a 100644 --- a/crates/hypercolor-ui/src/pages/settings.rs +++ b/crates/hypercolor-ui/src/pages/settings.rs @@ -156,14 +156,12 @@ pub fn SettingsPage() -> impl IntoView { .lock() .expect("config apply tracker lock poisoned") .finish_if_current(&key, generation); - if is_current { - if let Some(previous) = previous { - set_config.update(|cfg| { - if let Some(cfg) = cfg { - apply_config_key(cfg, &key, &previous); - } - }); - } + if is_current && let Some(previous) = previous { + set_config.update(|cfg| { + if let Some(cfg) = cfg { + apply_config_key(cfg, &key, &previous); + } + }); } } else { config_applies diff --git a/crates/hypercolor-ui/src/tauri_bridge.rs b/crates/hypercolor-ui/src/tauri_bridge.rs index 797fbd4de..de3696433 100644 --- a/crates/hypercolor-ui/src/tauri_bridge.rs +++ b/crates/hypercolor-ui/src/tauri_bridge.rs @@ -92,6 +92,99 @@ pub struct WindowsDaemonServiceStatus { pub reuse_recommended: bool, } +/// Local macOS daemon topology selectable through the native app coordinator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosDaemonOwnerChoice { + AppSidecar, + DirectLaunchd, + Homebrew, + Standalone, +} + +impl MacosDaemonOwnerChoice { + #[cfg(target_arch = "wasm32")] + const fn invoke_value(self) -> &'static str { + match self { + Self::AppSidecar => "app_sidecar", + Self::DirectLaunchd => "direct_launchd", + Self::Homebrew => "homebrew", + Self::Standalone => "standalone", + } + } +} + +/// Topology-specific local action attached to an owner-coordinator outcome. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MacosOwnerRemedy { + StopStandaloneOwner { + pid: u32, + }, + StartAppSidecar, + StartLaunchdService, + StartHomebrewService, + #[serde(other)] + Unknown, +} + +/// Synchronous result from the native daemon-owner coordinator. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MacosOwnerCoordinatorOutcome { + Active { + owner: String, + owner_epoch: u64, + }, + PendingStandalone { + requested_owner: String, + remedy: MacosOwnerRemedy, + }, + RolledBack { + prior_owner: String, + failure: String, + }, + RecoveryRequired { + requested_owner: String, + prior_owner: String, + phase: String, + }, + #[serde(other)] + Unknown, +} + +/// Native app status for a selected external daemon that is offline. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct MacosDaemonOwnerOfflineStatus { + pub code: String, + pub selected_owner: String, + pub remedy: MacosOwnerRemedy, +} + +/// Successful execution of a selected external owner's local start remedy. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct MacosDaemonOwnerOfflineRemedyOutcome { + pub status: String, + pub owner: String, +} + +/// Result from explicitly restarting the active macOS protected-source owner. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MacosCaptureOwnerRestartOutcome { + Restarted { + owner: String, + previous_owner_epoch: u64, + owner_epoch: u64, + }, + UserActionRequired { + owner: String, + owner_epoch: u64, + remedy: MacosOwnerRemedy, + }, + #[serde(other)] + Unknown, +} + /// Returns true when the UI is running inside a Tauri WebView. #[must_use] #[cfg(target_arch = "wasm32")] @@ -162,6 +255,120 @@ pub async fn detect_windows_daemon_service() -> Result Result, String> { + let Some(invoke) = tauri_invoke() else { + return Ok(None); + }; + + let args = string_arg_to_js("requestedOwner", owner.invoke_value())?; + let value = invoke_command(&invoke, "choose_daemon_owner", Some(args)).await?; + serde_json_from_js_value(value).map(Some) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn choose_macos_daemon_owner( + _owner: MacosDaemonOwnerChoice, +) -> Result, String> { + Ok(None) +} + +/// Read app-local status for a selected external macOS daemon that is offline. +/// +/// # Errors +/// +/// Returns an error when the native command rejects or returns malformed data. +#[cfg(target_arch = "wasm32")] +pub async fn macos_daemon_owner_offline_status() +-> Result, String> { + let Some(invoke) = tauri_invoke() else { + return Ok(None); + }; + + let value = invoke_command(&invoke, "macos_daemon_owner_offline_status", None).await?; + serde_json_from_js_value(value) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn macos_daemon_owner_offline_status() +-> Result, String> { + Ok(None) +} + +/// Execute the exact app-local start remedy published for an offline owner. +/// +/// `Ok(None)` means the browser UI has no local process authority. +/// +/// # Errors +/// +/// Returns an error when the remedy is stale, mismatched, unsupported, or the +/// selected service cannot be started. +#[cfg(target_arch = "wasm32")] +pub async fn execute_macos_daemon_owner_offline_remedy( + remedy: &MacosOwnerRemedy, +) -> Result, String> { + let Some(invoke) = tauri_invoke() else { + return Ok(None); + }; + + let args = macos_owner_remedy_to_js(remedy)?; + let value = invoke_command( + &invoke, + "execute_macos_daemon_owner_offline_remedy", + Some(args), + ) + .await?; + serde_json_from_js_value(value).map(Some) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn execute_macos_daemon_owner_offline_remedy( + _remedy: &MacosOwnerRemedy, +) -> Result, String> { + Ok(None) +} + +/// Restart the exact active macOS owner after a positive grant requires it. +/// +/// `Ok(None)` means the browser UI has no local process authority. +/// +/// # Errors +/// +/// Returns an error when owner identity changed, the epoch is stale, or the +/// managed owner cannot complete the restart. +#[cfg(target_arch = "wasm32")] +pub async fn restart_macos_capture_owner( + active_owner: &str, + owner_epoch: u64, +) -> Result, String> { + let Some(invoke) = tauri_invoke() else { + return Ok(None); + }; + + let args = macos_capture_owner_restart_to_js(active_owner, owner_epoch)?; + let value = invoke_command(&invoke, "restart_macos_capture_owner", Some(args)).await?; + serde_json_from_js_value(value).map(Some) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn restart_macos_capture_owner( + _active_owner: &str, + _owner_epoch: u64, +) -> Result, String> { + Ok(None) +} + #[cfg(not(target_arch = "wasm32"))] pub async fn detect_windows_daemon_service() -> Result, String> { Ok(None) @@ -379,6 +586,46 @@ fn pawnio_helper_options_to_js(options: PawnIoHelperOptions) -> Result Result { + let kind = match remedy { + MacosOwnerRemedy::StartLaunchdService => "start_launchd_service", + MacosOwnerRemedy::StartHomebrewService => "start_homebrew_service", + MacosOwnerRemedy::StopStandaloneOwner { .. } + | MacosOwnerRemedy::StartAppSidecar + | MacosOwnerRemedy::Unknown => { + return Err("offline owner remedy cannot be executed by this action".to_owned()); + } + }; + let root = js_sys::Object::new(); + let inner = js_sys::Object::new(); + js_sys::Reflect::set(&inner, &JsValue::from_str("kind"), &JsValue::from_str(kind)) + .map_err(js_error_string)?; + js_sys::Reflect::set(&root, &JsValue::from_str("remedy"), &inner).map_err(js_error_string)?; + Ok(root.into()) +} + +#[cfg(target_arch = "wasm32")] +fn macos_capture_owner_restart_to_js( + active_owner: &str, + owner_epoch: u64, +) -> Result { + let root = js_sys::Object::new(); + js_sys::Reflect::set( + &root, + &JsValue::from_str("activeOwner"), + &JsValue::from_str(active_owner), + ) + .map_err(js_error_string)?; + js_sys::Reflect::set( + &root, + &JsValue::from_str("ownerEpoch"), + &JsValue::from_f64(owner_epoch as f64), + ) + .map_err(js_error_string)?; + Ok(root.into()) +} + #[cfg(target_arch = "wasm32")] fn set_bool(target: &js_sys::Object, key: &str, value: bool) -> Result<(), String> { js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_bool(value)) @@ -411,8 +658,9 @@ fn js_error_string(value: JsValue) -> String { #[cfg(test)] mod tests { use super::{ - PawnIoModuleStatus, PawnIoSupportStatus, ServiceSupportStatus, bundled_payload_ready, - smbus_support_ready, windows_daemon_service_conflict, + MacosOwnerCoordinatorOutcome, MacosOwnerRemedy, PawnIoModuleStatus, PawnIoSupportStatus, + ServiceSupportStatus, bundled_payload_ready, smbus_support_ready, + windows_daemon_service_conflict, }; #[test] @@ -458,6 +706,40 @@ mod tests { assert!(!windows_daemon_service_conflict(&status)); } + #[test] + fn macos_owner_outcomes_decode_closed_native_shapes() { + let active: MacosOwnerCoordinatorOutcome = serde_json::from_value(serde_json::json!({ + "status": "active", + "owner": "homebrew", + "owner_epoch": 9 + })) + .expect("active owner outcome should decode"); + assert_eq!( + active, + MacosOwnerCoordinatorOutcome::Active { + owner: "homebrew".to_owned(), + owner_epoch: 9, + } + ); + + let pending: MacosOwnerCoordinatorOutcome = serde_json::from_value(serde_json::json!({ + "status": "pending_standalone", + "requested_owner": "app_sidecar", + "remedy": { + "kind": "stop_standalone_owner", + "pid": 412 + } + })) + .expect("pending standalone outcome should decode"); + assert!(matches!( + pending, + MacosOwnerCoordinatorOutcome::PendingStandalone { + remedy: MacosOwnerRemedy::StopStandaloneOwner { pid: 412 }, + .. + } + )); + } + fn status() -> PawnIoSupportStatus { PawnIoSupportStatus { platform_supported: true, diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index 385050db9..a80e54024 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -34,9 +34,9 @@ use super::interactive_preview::{ use super::messages::{ AudioLevel, BackpressureNotice, CanvasFrame, ConnectionState, ControlSurfaceEventHint, DeviceEventHint, EffectErrorHint, ExtensionEventHint, InputSourceStatusEventHint, - PerformanceMetrics, PreviewBinaryDecoder, PreviewBinaryMessage, PreviewFrameChannel, - SceneEventHint, ScreenZonesFrame, handle_json_message, interactive_preview_supported, - is_resync_required, + MacosDaemonOwnershipEventHint, PerformanceMetrics, PreviewBinaryDecoder, PreviewBinaryMessage, + PreviewFrameChannel, SceneEventHint, ScreenZonesFrame, handle_json_message, + interactive_preview_supported, is_resync_required, }; use super::preview::{ DEFAULT_PREVIEW_FPS_CAP, PreviewSubscriptionRequest, clear_preview_subscription, @@ -142,6 +142,9 @@ pub struct WsManager { /// Latest safe input-source health transition. REST remains canonical; /// consumers use this only to invalidate their status resources. pub last_input_source_status_event: ReadSignal>, + /// Latest authoritative macOS daemon-owner transition. REST remains + /// canonical; consumers use this only to invalidate their snapshots. + pub last_macos_daemon_ownership_event: ReadSignal>, /// Increments each time the daemon socket (re)opens. Bus events fired /// while the socket was down are not replayed, so resources mirroring /// daemon state over REST should fold this into their fetcher epochs @@ -209,6 +212,8 @@ impl WsManager { let (last_extension_event, set_last_extension_event) = signal(None::); let (last_input_source_status_event, set_last_input_source_status_event) = signal(None::); + let (last_macos_daemon_ownership_event, set_last_macos_daemon_ownership_event) = + signal(None::); let (last_scene_event, set_last_scene_event) = signal(None::); let (last_effect_error, set_last_effect_error) = signal(None::); let (last_control_surface_event, set_last_control_surface_event) = @@ -503,6 +508,7 @@ impl WsManager { &set_last_control_surface_event, &set_last_extension_event, &set_last_input_source_status_event, + &set_last_macos_daemon_ownership_event, &set_layer_health, &set_audio_level, &set_engine_preview_target, @@ -837,6 +843,7 @@ impl WsManager { last_control_surface_event, last_extension_event, last_input_source_status_event, + last_macos_daemon_ownership_event, connection_generation, layer_health, audio_level, diff --git a/crates/hypercolor-ui/src/ws/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index 14f460186..0716a17de 100644 --- a/crates/hypercolor-ui/src/ws/messages.rs +++ b/crates/hypercolor-ui/src/ws/messages.rs @@ -24,7 +24,7 @@ use hypercolor_types::sensor::SystemSnapshot; use leptos::prelude::*; use serde::Deserialize; -use crate::api::DeviceMetricsSnapshot; +use crate::api::{DeviceMetricsSnapshot, MacosDaemonOwnershipStatus}; // ── Connection State ──────────────────────────────────────────────────────── @@ -487,6 +487,9 @@ pub struct InputSourceStatusEventHint { pub retired: bool, } +/// Authoritative macOS daemon-owner snapshot used to invalidate REST status. +pub type MacosDaemonOwnershipEventHint = MacosDaemonOwnershipStatus; + #[derive(Debug, Clone, PartialEq)] pub struct ControlSurfaceEventHint { pub event_type: String, @@ -819,6 +822,7 @@ pub(super) fn handle_json_message( set_last_control_surface_event: &WriteSignal>, set_last_extension_event: &WriteSignal>, set_last_input_source_status_event: &WriteSignal>, + set_last_macos_daemon_ownership_event: &WriteSignal>, set_layer_health: &WriteSignal>, set_audio_level: &WriteSignal, set_engine_preview_target: &WriteSignal, @@ -986,6 +990,10 @@ pub(super) fn handle_json_message( let data = msg.get("data").unwrap_or(&serde_json::Value::Null); set_last_input_source_status_event .set(extract_input_source_status_event_hint(data)); + } else if event_type == "macos_daemon_ownership_changed" { + let data = msg.get("data").unwrap_or(&serde_json::Value::Null); + set_last_macos_daemon_ownership_event + .set(extract_macos_daemon_ownership_event_hint(data)); } else if DEVICE_LIFECYCLE_EVENTS.contains(&event_type) && let Some(hint) = extract_device_event_hint(event_type, msg.get("data")) { @@ -1011,6 +1019,13 @@ pub fn extract_input_source_status_event_hint( (!hint.source_id.is_empty()).then_some(hint) } +pub fn extract_macos_daemon_ownership_event_hint( + data: &serde_json::Value, +) -> Option { + let hint = MacosDaemonOwnershipEventHint::deserialize(data).ok()?; + (hint.active_owner.is_some() && hint.owner_epoch.is_some()).then_some(hint) +} + pub fn extract_control_surface_event_hint( event_type: &str, data: &serde_json::Value, diff --git a/crates/hypercolor-ui/src/ws/mod.rs b/crates/hypercolor-ui/src/ws/mod.rs index b698ddc52..88001c53e 100644 --- a/crates/hypercolor-ui/src/ws/mod.rs +++ b/crates/hypercolor-ui/src/ws/mod.rs @@ -16,6 +16,6 @@ pub use interactive_preview::{InteractivePreviewLifecycle, InteractivePreviewReq pub use messages::{ AudioLevel, BackpressureNotice, CanvasFrame, CanvasPixelFormat, ControlSurfaceEventHint, DeviceEventHint, EffectErrorHint, ExtensionEventHint, InputSourceStatusEventHint, - PerformanceMetrics, SceneEventHint, ScreenZonesFrame, + MacosDaemonOwnershipEventHint, PerformanceMetrics, SceneEventHint, ScreenZonesFrame, }; pub use preview::DEFAULT_PREVIEW_FPS_CAP; diff --git a/crates/hypercolor-ui/tests/input_access_tests.rs b/crates/hypercolor-ui/tests/input_access_tests.rs index f5e885840..a1ef33163 100644 --- a/crates/hypercolor-ui/tests/input_access_tests.rs +++ b/crates/hypercolor-ui/tests/input_access_tests.rs @@ -3,7 +3,9 @@ use hypercolor_ui::input_access::{ InputAccessRemedy, InputPipelineState, input_access_remedy, input_pipeline_state, input_status_epoch, input_status_remediation, primary_input_source_issue, }; -use hypercolor_ui::ws::messages::extract_input_source_status_event_hint; +use hypercolor_ui::ws::messages::{ + extract_input_source_status_event_hint, extract_macos_daemon_ownership_event_hint, +}; fn input(enabled: bool, opened: usize, denied: usize) -> InputStatus { InputStatus { @@ -151,6 +153,36 @@ fn input_source_status_event_decodes_as_a_refetch_hint() { assert_eq!(hint.lifecycle_issue_code.as_deref(), Some("worker_exited")); } +#[test] +fn macos_daemon_ownership_event_decodes_as_a_refetch_hint() { + let hint = extract_macos_daemon_ownership_event_hint(&serde_json::json!({ + "active_owner": "app_sidecar", + "owner_epoch": 17, + "conflict": null, + "recovery_required": { + "requested_owner": "homebrew_service", + "prior_owner": "app_sidecar", + "phase": "requested_owner_started" + }, + "future_field": true + })) + .expect("ownership event should decode"); + + assert_eq!(hint.active_owner.as_deref(), Some("app_sidecar")); + assert_eq!(hint.owner_epoch, Some(17)); + assert!(hint.recovery_required.is_some()); +} + +#[test] +fn macos_daemon_ownership_event_requires_identity() { + assert!( + extract_macos_daemon_ownership_event_hint(&serde_json::json!({ + "owner_epoch": 17 + })) + .is_none() + ); +} + #[test] fn system_status_tolerates_missing_input_object() { let status: SystemStatus = serde_json::from_value(serde_json::json!({ From 3c677059abfbd39b12463cae403ee9d9afa8b042 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 02:54:49 -0700 Subject: [PATCH 072/144] feat(screen): decode exact macOS capture formats on CPU Retain native macOS planes through one validated scalar source and decode BGRA8, RGBA16Float, ARGB2101010, YUV420, and YUV44410 without format masquerading or early quantization. Unify transfer, primaries, HLG/PQ tone mapping, exact sampling, and CPU fanout publication under one shared color contract. Preserve native work truthfulness while bounding plane access and keeping publication atomic. Co-Authored-By: Nova (OpenAI Codex) --- .../src/input/screen/fanout.rs | 161 +++- .../hypercolor-core/src/input/screen/frame.rs | 39 +- .../hypercolor-core/src/input/screen/macos.rs | 760 ++++++++++++++++-- .../src/input/screen/materialize.rs | 28 + .../hypercolor-core/src/input/screen/mod.rs | 2 +- .../src/input/screen/process.rs | 9 + .../src/input/screen/publication.rs | 9 +- .../src/input/screen/reducer.rs | 221 +++-- .../src/input/screen/sampling.rs | 174 ++++ .../src/input/screen/tone_map.rs | 182 ++++- .../tests/capture_color_contract_tests.rs | 31 +- .../screen_cpu_branch_processing_tests.rs | 14 + .../tests/screen_cpu_reducer_tests.rs | 28 + .../examples/dump_macos_frame.rs | 19 +- crates/hypercolor-macos-capture/src/cpu.rs | 513 ++++++++---- .../src/diagnostics.rs | 5 +- crates/hypercolor-macos-capture/src/frame.rs | 200 +++-- crates/hypercolor-macos-capture/src/lib.rs | 1 + .../tests/capture_contract_tests.rs | 387 ++++++++- 19 files changed, 2396 insertions(+), 387 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/fanout.rs b/crates/hypercolor-core/src/input/screen/fanout.rs index 1dff67f34..146482cf9 100644 --- a/crates/hypercolor-core/src/input/screen/fanout.rs +++ b/crates/hypercolor-core/src/input/screen/fanout.rs @@ -9,7 +9,7 @@ use thiserror::Error; use super::reducer::branch_requires_materialization; use super::{ CaptureCadence, CaptureCadenceError, CaptureFrame, CapturePacer, CaptureTransferFunction, - CpuReductionError, CpuReductionExecutor, CpuSurfaceMaterializationError, + CpuReductionError, CpuReductionExecutor, CpuScalarSource, CpuSurfaceMaterializationError, CpuZoneMaterializationError, LedToneMapCurveTransition, PixelExtent, PreparedCpuMaterializationWorkspace, PreparedCpuReductionBatch, PreparedCpuSurfaceMaterializer, PreparedCpuZoneMaterializer, PreparedLedToneMap, PreparedScreenPublication, RawCaptureSurface, @@ -657,6 +657,52 @@ impl PreparedCpuPublicationFanout { self.publish_due_inner(hub, frame, now, health, None) } + /// Publish due branches from one retained native scalar decoder. + /// + /// The native frame remains the exact format and lifetime authority. RGB + /// samples stay full precision until the prepared reducer writes its final + /// requested RGBA8 or BGRA8 destination. Only the scalar reduction runs + /// inside `with_source`; hub reservation and atomic finalization do not. + /// + /// # Errors + /// + /// Preserves [`Self::publish_due`] errors and rejects a scalar decoder whose + /// extent or native format differs from the resolved source. + pub fn publish_due_scalar( + &mut self, + hub: &ScreenPublicationHub, + frame: &CaptureFrame, + now: Instant, + health: ScreenPublicationHealth, + with_source: impl FnOnce( + &mut dyn FnMut(&dyn CpuScalarSource) -> Result<(), CpuPublicationFanoutError>, + ) -> Result<(), CpuPublicationFanoutError>, + ) -> Result { + self.observe_deadlines(now)?; + let mut report = self.prepare_due_inner(hub, frame, now, None)?; + let mut source_was_provided = false; + let source_result = { + let mut execute = |samples: &dyn CpuScalarSource| { + if source_was_provided { + return Err(CpuPublicationFanoutError::ScalarSourceProvidedTwice); + } + source_was_provided = true; + self.execute_due_scalar(frame, samples) + }; + with_source(&mut execute) + }; + if let Err(error) = source_result { + self.clear_pending_publications(); + return Err(error); + } + if !source_was_provided { + self.clear_pending_publications(); + return Err(CpuPublicationFanoutError::ScalarSourceNotProvided); + } + self.finalize_due_inner(hub, frame, now, health, None, &mut report)?; + Ok(report) + } + /// Publish only physical routes selected by an immutable preparation mask. /// /// Deadlines still advance for every logical branch so GPU-reduced routes @@ -716,6 +762,19 @@ impl PreparedCpuPublicationFanout { ..CpuPublicationFanoutReport::default() }); }; + let mut report = self.prepare_due_inner(hub, frame, now, physical_mask)?; + self.execute_due_bytes(frame)?; + self.finalize_due_inner(hub, frame, now, health, physical_mask, &mut report)?; + Ok(report) + } + + fn prepare_due_inner( + &mut self, + hub: &ScreenPublicationHub, + frame: &CaptureFrame, + now: Instant, + physical_mask: Option<&[bool]>, + ) -> Result { let sequence = frame.metadata().sequence; let native_sequence = NonZeroU64::new(sequence).ok_or(CpuPublicationFanoutError::NativeSequenceZero)?; @@ -731,7 +790,6 @@ impl PreparedCpuPublicationFanout { return Err(CpuPublicationFanoutError::ExecutionNotAttached); } let mut report = CpuPublicationFanoutReport::default(); - let plan_generation = self.batch.plan_generation(); self.reservations.clear(); self.publications.clear(); self.direct_batch_indices.clear(); @@ -811,6 +869,13 @@ impl PreparedCpuPublicationFanout { } } self.sample_tone_map_transitions(frame.metadata().captured_at); + Ok(report) + } + + fn execute_due_bytes( + &mut self, + frame: &CaptureFrame, + ) -> Result<(), CpuPublicationFanoutError> { let executor = self .executor .as_ref() @@ -819,22 +884,73 @@ impl PreparedCpuPublicationFanout { .workspace .as_mut() .expect("attached fanout retains its workspace"); - if let Err(error) = executor.execute_aligned_publications( - &self.batch, - frame, - workspace, - &self.workspace_schedule, - &self.direct_batch_indices, - &self.tone_map_overrides, - &mut self.publications, - ) { - clear_pending_publications( - &mut self.reservations, + executor + .execute_aligned_publications( + &self.batch, + frame, + workspace, + &self.workspace_schedule, + &self.direct_batch_indices, + &self.tone_map_overrides, &mut self.publications, - &mut self.direct_batch_indices, - ); - return Err(error.into()); - } + ) + .map(|_| ()) + .map_err(CpuPublicationFanoutError::from) + .inspect_err(|_| self.clear_pending_publications()) + } + + fn execute_due_scalar( + &mut self, + frame: &CaptureFrame, + samples: &dyn CpuScalarSource, + ) -> Result<(), CpuPublicationFanoutError> { + let executor = self + .executor + .as_ref() + .expect("attached fanout retains its executor"); + let workspace = self + .workspace + .as_mut() + .expect("attached fanout retains its workspace"); + executor + .execute_aligned_scalar_publications( + &self.batch, + frame, + samples, + workspace, + &self.workspace_schedule, + &self.direct_batch_indices, + &self.tone_map_overrides, + &mut self.publications, + ) + .map(|_| ()) + .map_err(CpuPublicationFanoutError::from) + .inspect_err(|_| self.clear_pending_publications()) + } + + fn clear_pending_publications(&mut self) { + clear_pending_publications( + &mut self.reservations, + &mut self.publications, + &mut self.direct_batch_indices, + ); + } + + fn finalize_due_inner( + &mut self, + hub: &ScreenPublicationHub, + frame: &CaptureFrame, + now: Instant, + health: ScreenPublicationHealth, + physical_mask: Option<&[bool]>, + report: &mut CpuPublicationFanoutReport, + ) -> Result<(), CpuPublicationFanoutError> { + let sequence = frame.metadata().sequence; + let plan_generation = self.batch.plan_generation(); + let workspace = self + .workspace + .as_mut() + .expect("attached fanout retains its workspace"); let (physical_routes, reservations, publications) = ( &mut self.physical, @@ -920,7 +1036,7 @@ impl PreparedCpuPublicationFanout { &mut self.direct_batch_indices, ); report.needs_source |= self.any_pending(physical_mask); - Ok(report) + Ok(()) } /// Publish one already-reduced physical RGBA plane to its due logical @@ -1813,6 +1929,15 @@ pub enum CpuPublicationFanoutError { /// Hub metadata requires positive native sequence identity. #[error("CPU publication fanout received native sequence zero")] NativeSequenceZero, + /// A retained scalar source rejected access before reduction. + #[error("CPU scalar source access failed: {0}")] + ScalarSourceAccessFailed(String), + /// The retained source provider did not expose one scalar source. + #[error("CPU scalar source provider did not expose a source")] + ScalarSourceNotProvided, + /// The retained source provider exposed more than one scalar source. + #[error("CPU scalar source provider exposed more than one source")] + ScalarSourceProvidedTwice, /// A runtime physical selection mask belongs to another prepared shape. #[error("CPU fanout physical mask has {actual} entries; expected {expected}")] PhysicalMaskLengthMismatch { expected: usize, actual: usize }, diff --git a/crates/hypercolor-core/src/input/screen/frame.rs b/crates/hypercolor-core/src/input/screen/frame.rs index 88179cbce..d1e1da6ce 100644 --- a/crates/hypercolor-core/src/input/screen/frame.rs +++ b/crates/hypercolor-core/src/input/screen/frame.rs @@ -333,6 +333,10 @@ pub enum CaptureTransferFunction { Srgb, /// Linear light. Linear, + /// ITU-R BT.709 opto-electronic transfer function. + Rec709, + /// ITU-R BT.2020 opto-electronic transfer function. + Rec2020, /// SMPTE ST 2084 perceptual quantizer. Pq, /// Hybrid log-gamma. @@ -611,7 +615,9 @@ fn validate_transfer_range( let contradictory = matches!( (transfer_function, dynamic_range), ( - CaptureTransferFunction::Srgb, + CaptureTransferFunction::Srgb + | CaptureTransferFunction::Rec709 + | CaptureTransferFunction::Rec2020, Some(CaptureDynamicRange::High) ) | ( CaptureTransferFunction::Pq | CaptureTransferFunction::Hlg, @@ -813,12 +819,27 @@ pub enum CapturePixelFormat { Rgba8, /// Blue, green, red, alpha bytes. Bgra8, + /// Little-endian A2R10G10B10 packed pixels (`l10r`). + Argb2101010, + /// Little-endian RGBA binary16 components (`RGhA`). + Rgba16Float, + /// Bi-planar 8-bit 4:2:0 video-range YUV (`420v`). + Yuv420VideoRange, + /// Bi-planar 8-bit 4:2:0 full-range YUV (`420f`). + Yuv420FullRange, + /// Bi-planar MSB-aligned 10-bit 4:4:4 YUV (`xf44`). + Yuv44410BiPlanar, } impl CapturePixelFormat { - const fn bytes_per_pixel(self) -> usize { + pub(crate) const fn rgba8_bytes_per_pixel(self) -> Option { match self { - Self::Rgba8 | Self::Bgra8 => 4, + Self::Rgba8 | Self::Bgra8 => Some(4), + Self::Argb2101010 + | Self::Rgba16Float + | Self::Yuv420VideoRange + | Self::Yuv420FullRange + | Self::Yuv44410BiPlanar => None, } } } @@ -932,9 +953,10 @@ impl CpuCaptureStorage { } pub(crate) fn tightly_packed_rgba8(&self, extent: PixelExtent) -> Option<&[u8]> { + let bytes_per_pixel = self.format.rgba8_bytes_per_pixel()?; let row_bytes = usize::try_from(extent.width) .ok()? - .checked_mul(self.format.bytes_per_pixel())?; + .checked_mul(bytes_per_pixel)?; let expected = row_bytes.checked_mul(usize::try_from(extent.height).ok()?)?; if self.format != CapturePixelFormat::Rgba8 || self.row_stride != i64::try_from(row_bytes).ok()? @@ -947,9 +969,13 @@ impl CpuCaptureStorage { } fn validate(&self, extent: PixelExtent) -> Result<(), CaptureFrameError> { + let bytes_per_pixel = self + .format + .rgba8_bytes_per_pixel() + .ok_or(CaptureFrameError::UnsupportedCpuStorageFormat(self.format))?; let row_bytes = usize::try_from(extent.width) .ok() - .and_then(|width| width.checked_mul(self.format.bytes_per_pixel())) + .and_then(|width| width.checked_mul(bytes_per_pixel)) .ok_or(CaptureFrameError::StorageSizeOverflow)?; let row_bytes_i64 = i64::try_from(row_bytes).map_err(|_| CaptureFrameError::StorageSizeOverflow)?; @@ -1853,6 +1879,9 @@ pub enum CaptureFrameError { /// CPU stride cannot address one complete row. #[error("CPU stride {stride} is smaller than the {minimum}-byte row")] InvalidCpuStride { stride: i64, minimum: usize }, + /// Packed and multi-plane native formats require their scalar decoder. + #[error("pixel format {0:?} cannot be represented by one RGBA8 CPU plane")] + UnsupportedCpuStorageFormat(CapturePixelFormat), /// CPU row addressing escaped the supplied allocation. #[error( "CPU storage ({buffer_len} bytes, row0 {row0_offset}, stride {stride}) cannot hold {extent:?}" diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index aeb9117d1..18f829a8b 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -7,9 +7,9 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ MacosCaptureContentStyle, MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, - MacosCaptureSelection, MacosColorPrimaries, MacosDisplayClock, MacosFrameEvent, - MacosFrameMailbox, MacosFrameStatus, MacosProtectedSourceState as NativeProtectedSourceState, - MacosTransferFunction, + MacosCaptureSelection, MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, + MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosProtectedSourceState as NativeProtectedSourceState, MacosTransferFunction, }; use tokio::sync::oneshot; @@ -24,21 +24,21 @@ use super::{ CaptureDamage, CaptureDynamicRange, CaptureEpoch, CaptureFrame, CaptureFrameMetadata, CaptureLuminanceContext, CapturePixelFormat, CapturePlanePool, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, - CpuExactReductionWorkPlan, CpuReductionExecutor, LedToneMapCalibration, PixelExtent, PixelRect, - PlatformGpuApi, PlatformGpuSurface, PreparedCpuPublicationFanout, - PreparedCpuPublicationFanoutCandidate, RawCaptureSurface, RegisteredScreenBranchDemand, - ResolvedScreenBranchDemand, ResolvedScreenPublicationDescriptor, ResolvedScreenSource, - ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, - ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, ScreenBranchPayload, - ScreenBranchPublisher, ScreenByteAdmissionCoordinator, ScreenCaptureBackend, - ScreenCaptureDemand, ScreenCaptureInput, ScreenCursorCapabilities, - ScreenExecutorColorCapabilities, ScreenGpuSurfacePayload, ScreenNativePreparationPayload, - ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPreparedWorkerToken, - ScreenPublicationColorimetry, ScreenPublicationExecutor, ScreenPublicationExecutorRequest, - ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, - ScreenPublicationMetadata, ScreenPublicationRequest, ScreenRequiredResourceMinimum, - ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, - ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, + CpuExactReductionWorkPlan, CpuPublicationFanoutError, CpuReductionExecutor, CpuSamplingError, + CpuScalarSource, LedToneMapCalibration, PixelExtent, PixelRect, PlatformGpuApi, + PlatformGpuSurface, PreparedCpuPublicationFanout, PreparedCpuPublicationFanoutCandidate, + RawCaptureSurface, RegisteredScreenBranchDemand, ResolvedScreenBranchDemand, + ResolvedScreenPublicationDescriptor, ResolvedScreenSource, ResolvedScreenSourceConfig, + ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, + ScreenBackendResourceIdentity, ScreenBranchPayload, ScreenBranchPublisher, + ScreenByteAdmissionCoordinator, ScreenCaptureBackend, ScreenCaptureDemand, ScreenCaptureInput, + ScreenCursorCapabilities, ScreenExecutorColorCapabilities, ScreenGpuSurfacePayload, + ScreenNativePreparationPayload, ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, + ScreenPreparedWorkerToken, ScreenPublicationColorimetry, ScreenPublicationExecutor, + ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, + ScreenPublicationHubError, ScreenPublicationMetadata, ScreenPublicationRequest, + ScreenRequiredResourceMinimum, ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, + ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; @@ -404,20 +404,15 @@ impl MacosPublicationSource { } } - fn cpu_source(&self, selector: ScreenSourceSelector) -> anyhow::Result { - if self.pixel_format != MacosCapturePixelFormat::Bgra8 { - return Err(anyhow!( - "macOS CPU publication requires a byte-addressable BGRA source" - )); - } - Ok(ResolvedScreenSource::new( + fn cpu_source(&self, selector: ScreenSourceSelector) -> ResolvedScreenSource { + ResolvedScreenSource::new( selector, self.epoch.clone(), ResolvedScreenSourceConfig::new_with_cursor_capabilities( self.geometry, self.logical_extent, ScreenSourceReflection::None, - CapturePixelFormat::Bgra8, + capture_pixel_format(self.pixel_format), self.colorimetry, self.cursor_capabilities(), ScreenBackendResourceIdentity::new( @@ -427,7 +422,7 @@ impl MacosPublicationSource { self.resource_generation, ), ), - )) + ) } fn gpu_source( @@ -444,14 +439,7 @@ impl MacosPublicationSource { "macOS capture received a zero Metal registry identity" )); } - let pixel_format = match self.pixel_format { - MacosCapturePixelFormat::Bgra8 => CapturePixelFormat::Bgra8, - _ => { - return Err(anyhow!( - "macOS native capture format is not implemented yet" - )); - } - }; + let pixel_format = capture_pixel_format(self.pixel_format); Ok(ResolvedScreenSource::new( selector, self.epoch.clone(), @@ -1253,7 +1241,7 @@ fn resolve_macos_publication_branch( ScreenPublicationExecutorRequest::Cpu ) { return Ok(Some(demand.resolve_with_color_capabilities( - &source.cpu_source(selector)?, + &source.cpu_source(selector), capabilities, )?)); } @@ -1278,13 +1266,14 @@ fn resolve_macos_publication_branch( } Ok(Some(demand.resolve_with_color_capabilities( - &source.cpu_source(selector)?, + &source.cpu_source(selector), capabilities, )?)) } fn macos_native_descriptor_is_identity(descriptor: &ResolvedScreenPublicationDescriptor) -> bool { - descriptor.source().geometry().crop().is_none() + descriptor.source_pixel_format() == CapturePixelFormat::Bgra8 + && descriptor.source().geometry().crop().is_none() && descriptor.geometry().output_extent() == descriptor.source().geometry().storage_extent() && descriptor.physical().reduction_extent() == descriptor.source().geometry().storage_extent() @@ -1420,7 +1409,7 @@ fn prepare_macos_exact_runtime( (None, 0, 0) } else { let cpu_source = - source.cpu_source(ScreenSourceSelector::Exact(source.epoch.source_id.clone()))?; + source.cpu_source(ScreenSourceSelector::Exact(source.epoch.source_id.clone())); let batch_quote = executor.batch_allocation_quote(&cpu_source, &candidate)?; preflight_macos_scope_bytes(&mut ledger, &mut processing_minimum_remaining, batch_quote)?; let batch = executor.prepare_batch(&cpu_source, &candidate)?; @@ -1803,6 +1792,11 @@ fn publish_frame( exact, exact_runtimes, )?; + if exact_delivery.cpu { + let capture = + native_cpu_capture_frame(&frame, captured_at, fresh_until, &source, source_id.clone())?; + publish_macos_scalar_exact(&frame, &capture, &source, exact, exact_runtimes)?; + } if exact_delivery.native && !exact_delivery.cpu { if lock(publication).worker_generation == worker_generation { lock(publication).latest = None; @@ -1812,6 +1806,15 @@ fn publish_frame( } return Ok(()); } + if frame.pixel_format != MacosCapturePixelFormat::Bgra8 { + if lock(publication).worker_generation == worker_generation { + lock(publication).latest = None; + } + if let Some(status) = status_session.load() { + status.record_sample(captured_at, fresh_until, 1)?; + } + return Ok(()); + } let row_stride = usize::try_from(extent.width()) .ok() @@ -1874,7 +1877,9 @@ fn publish_frame( )), damage, )?; - publish_macos_cpu_exact(&capture, &source, exact, exact_runtimes)?; + if !exact_delivery.cpu { + publish_macos_cpu_exact(&capture, &source, exact, exact_runtimes)?; + } let snapshot = analyze_screen_frame(&mut prepared.analyzer, capture)?; if snapshot.geometry_frame().metadata().topology_generation != topology_generation { return Err(anyhow!("macOS analysis changed topology generation")); @@ -1896,6 +1901,66 @@ fn publish_frame( Ok(()) } +fn native_cpu_capture_frame( + frame: &Arc, + captured_at: Instant, + fresh_until: Instant, + source: &MacosPublicationSource, + source_id: CaptureSourceId, +) -> anyhow::Result> { + let sequence = frame + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; + let surface = PlatformGpuSurface::new( + PlatformGpuApi::Metal, + u64::from(frame.surface.iosurface_id), + source.geometry.storage_extent(), + capture_pixel_format(frame.pixel_format), + Arc::clone(frame), + )?; + Ok(CaptureFrame::new( + CaptureFrameMetadata { + source_id, + topology_generation: source.epoch.topology_generation, + session_generation: frame.epoch, + sequence, + captured_at, + fresh_until, + geometry: source.geometry, + colorimetry: source.colorimetry, + cursor: CaptureCursor { + visible: frame.cursor_composed, + position: None, + hotspot: None, + shape_extent: None, + shape_generation: None, + content: if frame.cursor_composed { + CaptureCursorContent::Composed + } else { + CaptureCursorContent::Hidden + }, + }, + }, + CaptureStorage::Gpu(surface), + CaptureDamage::new( + frame + .damage + .iter() + .map(|rect| { + Ok(PixelRect::new( + u32::try_from(rect.x)?, + u32::try_from(rect.y)?, + rect.width, + rect.height, + )?) + }) + .collect::>>()?, + Vec::new(), + ), + )?) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct MacosExactDelivery { native: bool, @@ -2013,6 +2078,39 @@ fn publish_macos_cpu_exact( Ok(()) } +fn publish_macos_scalar_exact( + native_frame: &MacosCaptureFrame, + frame: &CaptureFrame, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], +) -> anyhow::Result<()> { + let Some(hub) = exact.hub() else { + return Ok(()); + }; + let Some(runtime) = + bind_current_macos_exact_runtime(runtimes, source, &hub, frame.metadata().captured_at)? + else { + return Ok(()); + }; + if let Some(fanout) = runtime.fanout.as_mut() { + fanout.publish_due_scalar( + &hub, + frame, + Instant::now(), + ScreenPublicationHealth::Healthy, + |execute| { + native_frame + .with_cpu_source(|samples| execute(&samples)) + .map_err(|error| { + CpuPublicationFanoutError::ScalarSourceAccessFailed(error.to_string()) + })? + }, + )?; + } + Ok(()) +} + #[derive(Default)] struct TopologyState { descriptor: Option, @@ -2137,12 +2235,11 @@ fn capture_colorimetry(frame: &MacosCaptureFrame) -> anyhow::Result CaptureTransferFunction::Srgb, + MacosTransferFunction::Rec709 => CaptureTransferFunction::Rec709, + MacosTransferFunction::Rec2020 => CaptureTransferFunction::Rec2020, MacosTransferFunction::Linear => CaptureTransferFunction::Linear, MacosTransferFunction::Pq => CaptureTransferFunction::Pq, MacosTransferFunction::Hlg => CaptureTransferFunction::Hlg, - MacosTransferFunction::Rec709 | MacosTransferFunction::Rec2020 => { - CaptureTransferFunction::Unknown - } }; let delivered = frame.delivered_metadata(); let dynamic_range = if matches!( @@ -2194,6 +2291,35 @@ fn capture_colorimetry(frame: &MacosCaptureFrame) -> anyhow::Result CapturePixelFormat { + match format { + MacosCapturePixelFormat::Bgra8 => CapturePixelFormat::Bgra8, + MacosCapturePixelFormat::Argb2101010 => CapturePixelFormat::Argb2101010, + MacosCapturePixelFormat::Rgba16Float => CapturePixelFormat::Rgba16Float, + MacosCapturePixelFormat::Yuv420VideoRange => CapturePixelFormat::Yuv420VideoRange, + MacosCapturePixelFormat::Yuv420FullRange => CapturePixelFormat::Yuv420FullRange, + MacosCapturePixelFormat::Yuv44410BiPlanar => CapturePixelFormat::Yuv44410BiPlanar, + } +} + +impl CpuScalarSource for MacosCpuSourceView<'_> { + fn storage_extent(&self) -> PixelExtent { + let extent = (*self).extent(); + PixelExtent::new(extent.width, extent.height) + .expect("validated macOS CPU source has a non-empty extent") + } + + fn pixel_format(&self) -> CapturePixelFormat { + capture_pixel_format((*self).pixel_format()) + } + + fn sample_rgba32f(&self, x: u32, y: u32) -> Result<[f32; 4], CpuSamplingError> { + (*self) + .sample_rgba32f(x, y) + .map_err(|_| CpuSamplingError::ScalarSourceReadFailed { x, y }) + } +} + fn capture_origin(frame: &MacosCaptureFrame) -> anyhow::Result { let rect = frame .geometry @@ -2381,13 +2507,13 @@ mod tests { use super::*; use crate::input::screen::{ CpuReductionLayout, CpuReductionRequest, InputPublicationDemandRevision, - ResolvedScreenColorTransform, ScreenAdmissionCapacity, ScreenAspectPolicy, - ScreenBranchPublication, ScreenExtentRequest, ScreenHdrPolicy, ScreenInputGraphGeneration, - ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativeTargetPreparation, - ScreenNativeTargetPreparer, ScreenPlanBuilder, ScreenProcessingProfile, - ScreenProcessingProfileConfig, ScreenProfileScalar, ScreenPublicationKind, - ScreenPublicationRequest, ScreenReductionFilter, ScreenSceneCutPolicy, - ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, + PreparedLedToneMap, ResolvedScreenColorTransform, ScreenAdmissionCapacity, + ScreenAspectPolicy, ScreenBranchPublication, ScreenExtentRequest, ScreenHdrPolicy, + ScreenInputGraphGeneration, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, + ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPlanBuilder, + ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenProfileScalar, + ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, + ScreenSceneCutPolicy, ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, }; use hypercolor_macos_capture::{ MacosAttachment, MacosCaptureColorimetry, MacosCaptureSurface, MacosColorRange, @@ -2397,7 +2523,11 @@ mod tests { }; const BGRA8: u32 = 0x4247_5241; + const ARGB2101010: u32 = 0x6c31_3072; const RGBA16_FLOAT: u32 = 0x5247_6841; + const YUV420_VIDEO_RANGE: u32 = 0x3432_3076; + const YUV420_FULL_RANGE: u32 = 0x3432_3066; + const YUV44410_FULL_RANGE: u32 = 0x7866_3434; #[cfg(target_os = "macos")] #[test] @@ -2596,6 +2726,73 @@ mod tests { Arc::from(frame) } + fn frame_with_planes( + color: MacosCaptureColorimetry, + pixel_format_fourcc: u32, + planes: &[(&[u8], MacosPixelExtent, usize)], + delivered: Option, + ) -> Arc { + let extent = MacosPixelExtent::new(4, 2).expect("fixture extent is valid"); + let allocation_bytes = planes + .iter() + .try_fold(0_u64, |total, (bytes, _, _)| { + total.checked_add(u64::try_from(bytes.len()).ok()?) + }) + .expect("fixture allocation fits"); + let mut surface = MacosCaptureSurface::new_cpu_fixture( + 7, + allocation_bytes, + 1, + planes + .iter() + .map(|(bytes, _, _)| Arc::<[u8]>::from(*bytes)) + .collect(), + ) + .expect("fixture surface is valid"); + if let Some(delivered) = delivered { + surface = surface + .with_delivery_metadata(delivered) + .expect("fixture delivery metadata is valid"); + } + let sample = MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: extent, + planes: planes + .iter() + .enumerate() + .map(|(index, (bytes, extent, stride))| MacosRawCapturePlane { + index: u32::try_from(index).expect("fixture plane index fits"), + extent: *extent, + bytes_per_row: *stride, + length_bytes: u64::try_from(bytes.len()).expect("fixture length fits"), + }) + .collect(), + pixel_format_fourcc, + color, + cursor_composed: false, + surface, + }), + attachments: MacosRawFrameAttachments { + status: MacosAttachment::Value(0), + display_time: MacosAttachment::Value(1_000), + display_scale_factor: MacosAttachment::Value(1.0), + content_scale: MacosAttachment::Value(1.0), + content_rect: MacosAttachment::Value( + MacosPointRect::new(0.0, 0.0, 4.0, 2.0).expect("fixture content rect is valid"), + ), + dirty_rects: MacosAttachment::Missing, + screen_rect: MacosAttachment::Missing, + bounding_rect: MacosAttachment::Missing, + }, + }; + let mut decoder = MacosFrameDecoder::new(7); + let MacosFrameEvent::Frame(frame) = decoder.decode(sample).expect("fixture frame decodes") + else { + panic!("complete fixture sample produces a frame"); + }; + Arc::from(frame) + } + fn source(frame: &MacosCaptureFrame) -> MacosPublicationSource { MacosPublicationSource::from_frame( CaptureSourceId::new("display:test").expect("fixture source id is valid"), @@ -2807,6 +3004,46 @@ mod tests { ); } + fn publish_scalar_frame( + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + source: &MacosPublicationSource, + frame: &Arc, + captured_at: Instant, + ) { + let capture = native_cpu_capture_frame( + frame, + captured_at, + captured_at + Duration::from_secs(1), + source, + source.epoch.source_id.clone(), + ) + .expect("native scalar fixture envelope is valid"); + let hub = exact.hub().expect("fixture hub remains installed"); + let runtime = bind_current_macos_exact_runtime(runtimes, source, &hub, captured_at) + .expect("current macOS runtime binds") + .expect("committed runtime is current"); + let report = runtime + .fanout + .as_mut() + .expect("CPU runtime owns a fanout") + .publish_due_scalar( + &hub, + &capture, + captured_at, + ScreenPublicationHealth::Healthy, + |execute| { + frame + .with_cpu_source(|samples| execute(&samples)) + .map_err(|error| { + CpuPublicationFanoutError::ScalarSourceAccessFailed(error.to_string()) + })? + }, + ) + .expect("native scalar fanout publishes"); + assert!(report.published() > 0); + } + fn active_tone_map_transition_count( exact: &MacosExactPublicationShared, runtimes: &mut [MacosExactRuntime], @@ -3015,7 +3252,7 @@ mod tests { } #[test] - fn macos_cpu_resolves_p3_and_rejects_non_byte_addressable_hdr() { + fn macos_cpu_resolves_p3_and_full_precision_hdr() { let p3_color = MacosCaptureColorimetry { primaries: MacosColorPrimaries::DisplayP3, transfer: MacosTransferFunction::Linear, @@ -3067,7 +3304,17 @@ mod tests { ..ScreenProcessingProfileConfig::default() }) .with_led_tone_map(calibration); - assert!(resolve_macos_publication_branch(&hdr_source, &cpu_demand(hdr_profile)).is_err()); + let hdr = resolve_macos_publication_branch(&hdr_source, &cpu_demand(hdr_profile)) + .expect("full-precision HDR CPU demand resolves") + .expect("configured source owns HDR demand"); + assert_eq!( + hdr.descriptor().source_pixel_format(), + CapturePixelFormat::Rgba16Float + ); + assert!(matches!( + hdr.descriptor().physical().color_pipeline().transform(), + ResolvedScreenColorTransform::ToneMap(_) + )); } #[test] @@ -3612,6 +3859,13 @@ mod tests { } fn native_demand(target: &ScreenNativeExecutionTarget) -> RegisteredScreenBranchDemand { + native_demand_for_format(target, CapturePixelFormat::Bgra8) + } + + fn native_demand_for_format( + target: &ScreenNativeExecutionTarget, + format: CapturePixelFormat, + ) -> RegisteredScreenBranchDemand { RegisteredScreenBranchDemand::new( ScreenPublicationRequest::new( ScreenSourceSelector::Configured, @@ -3620,9 +3874,7 @@ mod tests { ScreenExtentRequest::Native, ScreenAspectPolicy::Contain, Arc::new(ScreenProcessingProfile::new( - ScreenProcessingProfileConfig::exact_encoded_identity( - CapturePixelFormat::Bgra8, - ), + ScreenProcessingProfileConfig::exact_encoded_identity(format), )), ), NonZeroU32::new(60).expect("nonzero cadence"), @@ -3737,6 +3989,404 @@ mod tests { assert!(surface.capture_resource_lifetime().is_some()); } + #[test] + fn every_extended_native_format_publishes_deferred_work_without_masquerading() { + let mappings = [ + ( + MacosCapturePixelFormat::Argb2101010, + CapturePixelFormat::Argb2101010, + ), + ( + MacosCapturePixelFormat::Rgba16Float, + CapturePixelFormat::Rgba16Float, + ), + ( + MacosCapturePixelFormat::Yuv420VideoRange, + CapturePixelFormat::Yuv420VideoRange, + ), + ( + MacosCapturePixelFormat::Yuv420FullRange, + CapturePixelFormat::Yuv420FullRange, + ), + ( + MacosCapturePixelFormat::Yuv44410BiPlanar, + CapturePixelFormat::Yuv44410BiPlanar, + ), + ]; + for (native, core) in mappings { + assert_eq!(capture_pixel_format(native), core); + let mut native_frame = (*frame()).clone(); + native_frame.pixel_format = native; + let native_frame = Arc::new(native_frame); + let mut native_source = source(&frame()); + native_source.pixel_format = native; + let demand = native_demand_for_format(&target(), core); + let resolved = resolve_macos_publication_branch(&native_source, &demand) + .expect("extended native demand resolves") + .expect("configured macOS source owns extended native demand"); + assert!(matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + )); + assert!(!macos_native_descriptor_is_identity(resolved.descriptor())); + let publication = publish_native_fixture(&native_frame, &native_source, resolved); + let ScreenBranchPayload::NativeWork(payload) = publication.payload() else { + panic!("extended native source must publish deferred work"); + }; + assert_eq!(payload.source().format(), core); + assert_eq!( + payload.source().extent(), + native_source.geometry.storage_extent() + ); + } + } + + #[test] + fn rec709_and_rec2020_transfer_metadata_remain_exact() { + for (native, core) in [ + ( + MacosTransferFunction::Rec709, + CaptureTransferFunction::Rec709, + ), + ( + MacosTransferFunction::Rec2020, + CaptureTransferFunction::Rec2020, + ), + ] { + let frame = frame_with_color( + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: native, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + BGRA8, + &[0, 0, 255, 255], + None, + ); + assert_eq!( + capture_colorimetry(&frame) + .expect("exact SDR transfer maps") + .transfer_function(), + core + ); + } + } + + #[test] + fn rgba16float_cpu_publication_matches_the_shared_scalar_oracle() { + let color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let headroom = 1_000.0 / 203.0; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + color, + Some(203.0), + Some(headroom), + ) + .expect("extended-linear HDR delivery metadata is valid"); + let encoded = [0x00, 0x38, 0x00, 0x3c, 0x00, 0x40, 0x00, 0x3c]; + let native_frame = frame_with_color(color, RGBA16_FLOAT, &encoded, Some(delivered)); + let native_source = source(&native_frame); + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + exact.replace_source(Some(native_source.clone())); + let resolved = + resolve_macos_publication_branch(&native_source, &cpu_demand(transition_profile(true))) + .expect("extended-linear CPU demand resolves") + .expect("configured source owns extended-linear CPU demand"); + let mut runtimes = Vec::new(); + let descriptor = commit_cpu_runtime( + &mut builder, + &exact, + &native_source, + resolved, + &mut runtimes, + ); + let captured_at = Instant::now() + Duration::from_millis(20); + publish_scalar_frame( + &exact, + &mut runtimes, + &native_source, + &native_frame, + captured_at, + ); + let output = published_surface_bytes(&exact, &descriptor); + let pipeline = descriptor.physical().color_pipeline(); + let prepared = PreparedLedToneMap::prepare( + pipeline + .effective_source() + .expect("managed pipeline retains source"), + pipeline + .output() + .try_known() + .expect("managed output is known"), + pipeline.calibration().expect("managed calibration exists"), + ) + .expect("shared scalar oracle prepares"); + let expected = prepared.encode(prepared.decode_and_map_source([0.5, 1.0, 2.0, 1.0])); + assert_eq!(&output[..4], &expected); + } + + #[test] + fn malformed_native_planes_fail_before_cpu_publication() { + let native_frame = frame(); + let native_source = source(&native_frame); + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + exact.replace_source(Some(native_source.clone())); + let resolved = resolve_macos_publication_branch( + &native_source, + &cpu_demand(ScreenProcessingProfile::default()), + ) + .expect("CPU demand resolves") + .expect("configured source owns CPU demand"); + let mut runtimes = Vec::new(); + let descriptor = commit_cpu_runtime( + &mut builder, + &exact, + &native_source, + resolved, + &mut runtimes, + ); + let mut malformed = (*native_frame).clone(); + let mut planes = malformed.planes.to_vec(); + planes[0].bytes_per_row = 1; + malformed.planes = planes.into(); + let captured_at = Instant::now() + Duration::from_millis(20); + let capture = native_cpu_capture_frame( + &Arc::new(malformed.clone()), + captured_at, + captured_at + Duration::from_secs(1), + &native_source, + native_source.epoch.source_id.clone(), + ) + .expect("malformed plane metadata does not alter native ownership envelope"); + assert!( + publish_macos_scalar_exact( + &malformed, + &capture, + &native_source, + &exact, + &mut runtimes, + ) + .is_err() + ); + let hub = exact.hub().expect("fixture hub remains installed"); + let lease = hub + .lease(&descriptor) + .expect("committed branch has a lease"); + assert!(lease.read().is_none()); + } + + #[test] + fn every_retained_format_cpu_publication_matches_the_shared_scalar_oracle() { + let sdr_rgb = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let hdr_linear = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let yuv_video = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(hypercolor_macos_capture::MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: Some(hypercolor_macos_capture::MacosChromaLocation::Left), + }; + let yuv_full = MacosCaptureColorimetry { + transfer: MacosTransferFunction::Hlg, + range: MacosColorRange::Full, + chroma_location: Some(hypercolor_macos_capture::MacosChromaLocation::Center), + ..yuv_video + }; + let hdr_delivery = |format, color| { + MacosDeliveredFrameMetadata::new(format, color, Some(203.0), Some(1_000.0 / 203.0)) + .expect("HDR delivery metadata is valid") + }; + let bgra = frame_with_planes( + sdr_rgb, + BGRA8, + &[( + &[32, 64, 128, 255].repeat(8), + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 16, + )], + None, + ); + let packed_l10r = (3_u32 << 30) | (512 << 20) | (256 << 10) | 128; + let l10r_bytes = packed_l10r.to_le_bytes().repeat(8); + let l10r = frame_with_planes( + hdr_linear, + ARGB2101010, + &[( + &l10r_bytes, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 16, + )], + Some(hdr_delivery( + MacosCapturePixelFormat::Argb2101010, + hdr_linear, + )), + ); + let rgba16_pixel = [0x00, 0x38, 0x00, 0x3c, 0x00, 0x40, 0x00, 0x3c]; + let rgba16_bytes = rgba16_pixel.repeat(8); + let rgba16 = frame_with_planes( + hdr_linear, + RGBA16_FLOAT, + &[( + &rgba16_bytes, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 32, + )], + Some(hdr_delivery( + MacosCapturePixelFormat::Rgba16Float, + hdr_linear, + )), + ); + let y_plane_video = vec![126; 8]; + let chroma_video = vec![96, 160, 96, 160]; + let yuv420v = frame_with_planes( + yuv_video, + YUV420_VIDEO_RANGE, + &[ + ( + &y_plane_video, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 4, + ), + ( + &chroma_video, + MacosPixelExtent::new(2, 1).expect("fixture extent is valid"), + 4, + ), + ], + Some(hdr_delivery( + MacosCapturePixelFormat::Yuv420VideoRange, + yuv_video, + )), + ); + let y_plane_full = vec![128; 8]; + let chroma_full = vec![96, 160, 96, 160]; + let yuv420f = frame_with_planes( + yuv_full, + YUV420_FULL_RANGE, + &[ + ( + &y_plane_full, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 4, + ), + ( + &chroma_full, + MacosPixelExtent::new(2, 1).expect("fixture extent is valid"), + 4, + ), + ], + Some(hdr_delivery( + MacosCapturePixelFormat::Yuv420FullRange, + yuv_full, + )), + ); + let yuv444_color = MacosCaptureColorimetry { + chroma_location: Some(hypercolor_macos_capture::MacosChromaLocation::TopLeft), + ..yuv_full + }; + let y10 = (512_u16 << 6).to_le_bytes(); + let cb10 = (384_u16 << 6).to_le_bytes(); + let cr10 = (640_u16 << 6).to_le_bytes(); + let y444 = y10.repeat(8); + let mut chroma444 = Vec::new(); + for _ in 0..8 { + chroma444.extend_from_slice(&cb10); + chroma444.extend_from_slice(&cr10); + } + let yuv444 = frame_with_planes( + yuv444_color, + YUV44410_FULL_RANGE, + &[ + ( + &y444, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 8, + ), + ( + &chroma444, + MacosPixelExtent::new(4, 2).expect("fixture extent is valid"), + 16, + ), + ], + Some(hdr_delivery( + MacosCapturePixelFormat::Yuv44410BiPlanar, + yuv444_color, + )), + ); + + for frame in [bgra, l10r, rgba16, yuv420v, yuv420f, yuv444] { + assert_scalar_publication_matches_oracle(&frame); + } + } + + fn assert_scalar_publication_matches_oracle(frame: &Arc) { + let native_source = source(frame); + let hdr = native_source.colorimetry.dynamic_range() == Some(CaptureDynamicRange::High); + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + *lock(&exact.hub) = Some(builder.publication_hub()); + exact.replace_source(Some(native_source.clone())); + let resolved = + resolve_macos_publication_branch(&native_source, &cpu_demand(transition_profile(hdr))) + .expect("native scalar CPU demand resolves") + .expect("configured source owns native scalar demand"); + let mut runtimes = Vec::new(); + let descriptor = commit_cpu_runtime( + &mut builder, + &exact, + &native_source, + resolved, + &mut runtimes, + ); + let source_sample = frame + .with_cpu_source(|samples| samples.sample_rgba32f(0, 0)) + .expect("native scalar source validates") + .expect("first source sample decodes"); + let captured_at = Instant::now() + Duration::from_millis(20); + publish_scalar_frame(&exact, &mut runtimes, &native_source, frame, captured_at); + let output = published_surface_bytes(&exact, &descriptor); + let pipeline = descriptor.physical().color_pipeline(); + let prepared = PreparedLedToneMap::prepare( + pipeline + .effective_source() + .expect("managed pipeline retains source"), + pipeline + .output() + .try_known() + .expect("managed output is known"), + pipeline.calibration().expect("managed calibration exists"), + ) + .expect("shared scalar oracle prepares"); + assert_eq!( + &output[..4], + &prepared.encode(prepared.decode_and_map_source(source_sample)) + ); + } + #[test] fn reduced_rgba_demand_falls_back_until_native_reducer_exists() { let frame = frame(); diff --git a/crates/hypercolor-core/src/input/screen/materialize.rs b/crates/hypercolor-core/src/input/screen/materialize.rs index 121630c7b..05d584aba 100644 --- a/crates/hypercolor-core/src/input/screen/materialize.rs +++ b/crates/hypercolor-core/src/input/screen/materialize.rs @@ -292,6 +292,13 @@ fn restore_surface_fill( ScreenLetterboxFill::Solid([red, green, blue, alpha]) => match pixel_format { CapturePixelFormat::Rgba8 => [red, green, blue, alpha], CapturePixelFormat::Bgra8 => [blue, green, red, alpha], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("native source formats cannot back reduced CPU surfaces") + } }, ScreenLetterboxFill::EdgeExtend => { let edge_x = @@ -355,6 +362,13 @@ fn read_surface_rgb(pixel: &[u8], pixel_format: CapturePixelFormat) -> [u8; 3] { match pixel_format { CapturePixelFormat::Rgba8 => [pixel[0], pixel[1], pixel[2]], CapturePixelFormat::Bgra8 => [pixel[2], pixel[1], pixel[0]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("native source formats cannot back reduced CPU surfaces") + } } } @@ -366,6 +380,13 @@ fn write_surface_rgb(pixel: &mut [u8], pixel_format: CapturePixelFormat, color: pixel[1] = color[1]; pixel[2] = color[0]; } + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("native source formats cannot back reduced CPU surfaces") + } } } @@ -1324,6 +1345,13 @@ impl PreparedCpuZoneMaterializer { match self.pixel_format { CapturePixelFormat::Rgba8 => [pixels[offset], pixels[offset + 1], pixels[offset + 2]], CapturePixelFormat::Bgra8 => [pixels[offset + 2], pixels[offset + 1], pixels[offset]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("native source formats cannot back reduced CPU surfaces") + } } } diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index c323605f3..11a2a5251 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -143,7 +143,7 @@ pub use reducer::{ pub(crate) use retained::{ExactBoxList, ExactBoxNode}; pub use sampling::{ CpuMappedSamplingPoint, CpuSamplingError, CpuSamplingPoint, CpuSamplingView, - CpuStorageCoordinate, + CpuScalarSamplingView, CpuScalarSource, CpuStorageCoordinate, }; pub use sector::{LetterboxBars, SectorGrid, proportional_sector_bounds}; pub use smooth::TemporalSmoother; diff --git a/crates/hypercolor-core/src/input/screen/process.rs b/crates/hypercolor-core/src/input/screen/process.rs index ef1a72cde..945779a46 100644 --- a/crates/hypercolor-core/src/input/screen/process.rs +++ b/crates/hypercolor-core/src/input/screen/process.rs @@ -347,6 +347,15 @@ fn read_pixel( Ok(match storage.format() { CapturePixelFormat::Rgba8 => [bytes[0], bytes[1], bytes[2], bytes[3]], CapturePixelFormat::Bgra8 => [bytes[2], bytes[1], bytes[0], bytes[3]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + return Err(CaptureFrameError::UnsupportedCpuStorageFormat( + storage.format(), + )); + } }) } diff --git a/crates/hypercolor-core/src/input/screen/publication.rs b/crates/hypercolor-core/src/input/screen/publication.rs index 305f8b1b8..bb65e0de4 100644 --- a/crates/hypercolor-core/src/input/screen/publication.rs +++ b/crates/hypercolor-core/src/input/screen/publication.rs @@ -2921,7 +2921,9 @@ fn resolve_hdr_color_pipeline( if source.dynamic_range() == CaptureDynamicRange::High && matches!( source.transfer_function(), - CaptureTransferFunction::Pq | CaptureTransferFunction::Linear + CaptureTransferFunction::Pq + | CaptureTransferFunction::Hlg + | CaptureTransferFunction::Linear ) && target.dynamic_range() == CaptureDynamicRange::Standard => { @@ -3258,5 +3260,10 @@ const fn pixel_format_rank(format: CapturePixelFormat) -> u8 { match format { CapturePixelFormat::Rgba8 => 0, CapturePixelFormat::Bgra8 => 1, + CapturePixelFormat::Argb2101010 => 2, + CapturePixelFormat::Rgba16Float => 3, + CapturePixelFormat::Yuv420VideoRange => 4, + CapturePixelFormat::Yuv420FullRange => 5, + CapturePixelFormat::Yuv44410BiPlanar => 6, } } diff --git a/crates/hypercolor-core/src/input/screen/reducer.rs b/crates/hypercolor-core/src/input/screen/reducer.rs index 72ffd3c25..9a478eb9d 100644 --- a/crates/hypercolor-core/src/input/screen/reducer.rs +++ b/crates/hypercolor-core/src/input/screen/reducer.rs @@ -12,8 +12,9 @@ use rayon::{ThreadPool, ThreadPoolBuilder}; use thiserror::Error; use super::sampling::{ - CpuAxisInterpolation, CpuSamplingError, CpuSamplingRow, CpuSamplingTransform, CpuSamplingView, - CpuStorageAxis, CpuStorageSpan, PreparedCpuSamplingPlan, + CpuAxisInterpolation, CpuSamplingError, CpuSamplingTransform, CpuSamplingView, + CpuScalarSamplingView, CpuScalarSource, CpuStorageAxis, CpuStorageSpan, + PreparedCpuSamplingPlan, PreparedCpuSamplingRow, PreparedCpuSamplingSource, }; use super::tone_map::{ LED_TONE_MAP_ALGORITHM_REVISION, PreparedLedToneMap, PreparedLedToneMapError, @@ -826,6 +827,30 @@ fn validate_aligned_schedule_disjoint( Ok(()) } +fn validate_aligned_publication_inputs( + batch: &PreparedCpuReductionBatch, + frame: &CaptureFrame, + workspace: &PreparedCpuMaterializationWorkspace, + workspace_indices: &[usize], + surface_batch_indices: &[Option], + tone_map_overrides: &[Option], + publications: &[PreparedScreenPublication], +) -> Result, CpuReductionError> { + if !Arc::ptr_eq(&batch.reductions, &workspace.reductions) { + return Err(CpuReductionError::WorkspaceBatchMismatch); + } + if tone_map_overrides.len() != batch.reductions.len() { + return Err(CpuReductionError::ToneMapOverrideCountMismatch { + expected: batch.reductions.len(), + actual: tone_map_overrides.len(), + }); + } + validate_workspace_schedule(workspace, workspace_indices)?; + validate_aligned_surface_schedule(batch, surface_batch_indices, publications)?; + validate_aligned_schedule_disjoint(workspace, workspace_indices, surface_batch_indices)?; + empty_cpu_batch_report(batch, frame) +} + fn prepared_cpu_sampling_view<'frame>( batch: &'frame PreparedCpuReductionBatch, frame: &'frame CaptureFrame, @@ -1284,22 +1309,80 @@ impl CpuReductionExecutor { tone_map_overrides: &[Option], publications: &mut [PreparedScreenPublication], ) -> Result { - if !Arc::ptr_eq(&batch.reductions, &workspace.reductions) { - return Err(CpuReductionError::WorkspaceBatchMismatch); - } - if tone_map_overrides.len() != batch.reductions.len() { - return Err(CpuReductionError::ToneMapOverrideCountMismatch { - expected: batch.reductions.len(), - actual: tone_map_overrides.len(), - }); - } - validate_workspace_schedule(workspace, workspace_indices)?; - validate_aligned_surface_schedule(batch, surface_batch_indices, publications)?; - validate_aligned_schedule_disjoint(workspace, workspace_indices, surface_batch_indices)?; - if let Some(report) = empty_cpu_batch_report(batch, frame)? { + if let Some(report) = validate_aligned_publication_inputs( + batch, + frame, + workspace, + workspace_indices, + surface_batch_indices, + tone_map_overrides, + publications, + )? { return Ok(report); } let view = prepared_cpu_sampling_view(batch, frame)?; + self.execute_aligned_publications_with_view( + batch, + frame, + &view, + workspace, + workspace_indices, + surface_batch_indices, + tone_map_overrides, + publications, + ) + } + + pub(super) fn execute_aligned_scalar_publications( + &self, + batch: &PreparedCpuReductionBatch, + frame: &CaptureFrame, + samples: &dyn CpuScalarSource, + workspace: &mut PreparedCpuMaterializationWorkspace, + workspace_indices: &[usize], + surface_batch_indices: &[Option], + tone_map_overrides: &[Option], + publications: &mut [PreparedScreenPublication], + ) -> Result { + if let Some(report) = validate_aligned_publication_inputs( + batch, + frame, + workspace, + workspace_indices, + surface_batch_indices, + tone_map_overrides, + publications, + )? { + return Ok(report); + } + let view = CpuScalarSamplingView::try_new(frame, &batch.source, samples)?; + self.execute_aligned_publications_with_view( + batch, + frame, + &view, + workspace, + workspace_indices, + surface_batch_indices, + tone_map_overrides, + publications, + ) + } + + #[expect( + clippy::too_many_arguments, + reason = "aligned execution retains every prevalidated publication slice without allocation" + )] + fn execute_aligned_publications_with_view( + &self, + batch: &PreparedCpuReductionBatch, + frame: &CaptureFrame, + view: &S, + workspace: &mut PreparedCpuMaterializationWorkspace, + workspace_indices: &[usize], + surface_batch_indices: &[Option], + tone_map_overrides: &[Option], + publications: &mut [PreparedScreenPublication], + ) -> Result { let source_sequence = frame.metadata().sequence; let mut output_bytes = 0_u64; let mut scheduled_tiles = 0_u64; @@ -1371,7 +1454,7 @@ impl CpuReductionExecutor { .color .with_tone_map_override(tone_map_overrides[plane.batch_index])?; reduce_prepared_in_pool( - &view, + view, reduction, color, self.inner.worker_count, @@ -1393,7 +1476,7 @@ impl CpuReductionExecutor { .color .with_tone_map_override(tone_map_overrides[batch_index])?; reduce_prepared_in_pool( - &view, + view, reduction, color, self.inner.worker_count, @@ -1613,6 +1696,7 @@ impl CpuReductionExecutor { request: CpuReductionRequest<'_>, output: &mut [u8], ) -> Result<(), CpuReductionError> { + validate_reduced_format(request.target_format, true)?; let expected = request.layout.target_byte_len_usize(); if output.len() != expected { return Err(CpuReductionError::OutputLengthMismatch { @@ -1642,6 +1726,7 @@ fn prepare_physical_reduction( descriptor: &ScreenPhysicalReductionDescriptor, sampling_transform: CpuSamplingTransform, ) -> Result { + validate_reduced_format(descriptor.target_pixel_format(), true)?; if descriptor.algorithm_revision() != LED_TONE_MAP_ALGORITHM_REVISION { return Err(CpuReductionError::AlgorithmRevisionMismatch { expected: LED_TONE_MAP_ALGORITHM_REVISION, @@ -1730,8 +1815,8 @@ fn reduce_request_in_pool( }) } -fn reduce_prepared_in_pool( - view: &CpuSamplingView<'_>, +fn reduce_prepared_in_pool( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, worker_count: NonZeroUsize, @@ -1811,8 +1896,8 @@ fn prepare_reduction_tiles( }) } -fn reduce_prepared_tile( - view: &CpuSamplingView<'_>, +fn reduce_prepared_tile( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, tile_index: usize, @@ -1846,8 +1931,8 @@ fn reduce_prepared_tile( Ok(()) } -fn reduce_prepared_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_row( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, target_y: u32, @@ -1867,8 +1952,8 @@ fn reduce_prepared_row( } } -fn reduce_prepared_nearest_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_nearest_row( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, target_y: u32, @@ -1881,20 +1966,20 @@ fn reduce_prepared_nearest_row( let source_row = view.storage_row(fixed)?; write_prepared_row(reduction, first_target_x, row, |target_x| { let sample = - source_row.read_rgba(reduction.sampling.logical_x_nearest(target_x))?; - Ok(color.encode(color.decode(sample))) + source_row.read_rgba32f(reduction.sampling.logical_x_nearest(target_x))?; + Ok(color.encode(color.decode_source(sample))) }) } CpuStorageAxis::Y => write_prepared_row(reduction, first_target_x, row, |target_x| { let source_row = view.storage_row(reduction.sampling.logical_x_nearest(target_x))?; - let sample = source_row.read_rgba(fixed)?; - Ok(color.encode(color.decode(sample))) + let sample = source_row.read_rgba32f(fixed)?; + Ok(color.encode(color.decode_source(sample))) }), } } -fn reduce_prepared_bilinear_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_bilinear_row( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, target_y: u32, @@ -1920,17 +2005,17 @@ fn reduce_prepared_bilinear_row( } } -fn sample_prepared_bilinear( - top: CpuSamplingRow<'_>, - bottom: CpuSamplingRow<'_>, +fn sample_prepared_bilinear( + top: R, + bottom: R, x: CpuAxisInterpolation, y: CpuAxisInterpolation, color: ReductionColor, ) -> Result<[u8; 4], CpuReductionError> { - let top_left = color.decode(top.read_rgba(x.lower())?); - let top_right = color.decode(top.read_rgba(x.upper())?); - let bottom_left = color.decode(bottom.read_rgba(x.lower())?); - let bottom_right = color.decode(bottom.read_rgba(x.upper())?); + let top_left = color.decode_source(top.read_rgba32f(x.lower())?); + let top_right = color.decode_source(top.read_rgba32f(x.upper())?); + let bottom_left = color.decode_source(bottom.read_rgba32f(x.lower())?); + let bottom_right = color.decode_source(bottom.read_rgba32f(x.upper())?); let mut output = [0.0; 4]; for channel in 0..4 { let top = lerp(top_left[channel], top_right[channel], x.upper_weight()); @@ -1944,8 +2029,8 @@ fn sample_prepared_bilinear( Ok(color.encode(output)) } -fn reduce_prepared_area_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_area_row( + view: &S, reduction: &PreparedCpuReduction, color: ReductionColor, target_y: u32, @@ -1993,8 +2078,8 @@ fn write_prepared_row( Ok(()) } -fn sample_prepared_area( - view: &CpuSamplingView<'_>, +fn sample_prepared_area( + view: &S, x_span: CpuStorageSpan, y_span: CpuStorageSpan, color: ReductionColor, @@ -2005,7 +2090,7 @@ fn sample_prepared_area( let row = view.storage_row(source_y)?; for source_x in x_span.start()..x_span.end() { let weight = x_span.normalized_weight(source_x) * y_weight; - let sample = color.decode(row.read_rgba(source_x)?); + let sample = color.decode_source(row.read_rgba32f(source_x)?); for channel in 0..4 { sums[channel] += sample[channel] * weight; } @@ -2035,6 +2120,12 @@ pub enum CpuReductionError { /// Source row addressing escapes retained CPU bytes. #[error("source plane addressing escapes its {buffer_len}-byte allocation")] SourceBufferOutOfBounds { buffer_len: usize }, + /// A native packed or multi-plane format reached the byte-plane decoder. + #[error("unsupported single-plane CPU source format: {0:?}")] + UnsupportedSourcePixelFormat(CapturePixelFormat), + /// CPU reduction destinations are canonical RGBA8 or BGRA8 surfaces. + #[error("unsupported CPU reduction destination format: {0:?}")] + UnsupportedTargetPixelFormat(CapturePixelFormat), /// The exact preserve path was paired with byte-changing work. #[error("encoded-sample preservation requires equal extents, format, and nearest filtering")] InexactEncodedSamplePreservation, @@ -2273,14 +2364,18 @@ impl ReductionColor { } fn decode(self, sample: [u8; 4]) -> [f64; 4] { + self.decode_source(sample.map(|channel| f32::from(channel) / 255.0)) + } + + fn decode_source(self, sample: [f32; 4]) -> [f64; 4] { match self { Self::Encoded => [ - f64::from(sample[0]) / 255.0, - f64::from(sample[1]) / 255.0, - f64::from(sample[2]) / 255.0, - f64::from(sample[3]) / 255.0, + f64::from(sample[0]), + f64::from(sample[1]), + f64::from(sample[2]), + f64::from(sample[3]), ], - Self::Managed(prepared) => prepared.decode_and_map(sample), + Self::Managed(prepared) => prepared.decode_and_map_source(sample), } } @@ -2329,6 +2424,7 @@ fn validate_source( source: &CpuCaptureStorage, layout: CpuReductionLayout, ) -> Result<(), CpuReductionError> { + validate_reduced_format(source.format(), false)?; let row_bytes = u64::from(layout.source_extent().width()) .checked_mul(CHANNELS_PER_PIXEL) .ok_or(CpuReductionError::GeometryOverflow { resource: "source" })?; @@ -2384,6 +2480,19 @@ fn validate_source( Ok(()) } +fn validate_reduced_format( + format: CapturePixelFormat, + target: bool, +) -> Result<(), CpuReductionError> { + if format.rgba8_bytes_per_pixel().is_some() { + Ok(()) + } else if target { + Err(CpuReductionError::UnsupportedTargetPixelFormat(format)) + } else { + Err(CpuReductionError::UnsupportedSourcePixelFormat(format)) + } +} + fn reduce_tile( request: CpuReductionRequest<'_>, color: ReductionColor, @@ -2600,6 +2709,15 @@ fn read_pixel(source: &CpuCaptureStorage, x: u32, y: u32) -> Result<[u8; 4], Cpu Ok(match source.format() { CapturePixelFormat::Rgba8 => [bytes[0], bytes[1], bytes[2], bytes[3]], CapturePixelFormat::Bgra8 => [bytes[2], bytes[1], bytes[0], bytes[3]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + return Err(CpuReductionError::UnsupportedSourcePixelFormat( + source.format(), + )); + } }) } @@ -2609,6 +2727,13 @@ fn write_pixel(target: &mut [u8], format: CapturePixelFormat, sample: [u8; 4]) { CapturePixelFormat::Bgra8 => { target.copy_from_slice(&[sample[2], sample[1], sample[0], sample[3]]); } + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("native source formats cannot be reduced CPU destinations") + } } } diff --git a/crates/hypercolor-core/src/input/screen/sampling.rs b/crates/hypercolor-core/src/input/screen/sampling.rs index 5d831f9b6..ff220b7e1 100644 --- a/crates/hypercolor-core/src/input/screen/sampling.rs +++ b/crates/hypercolor-core/src/input/screen/sampling.rs @@ -680,6 +680,103 @@ pub struct CpuSamplingView<'frame> { transform: CpuSamplingTransform, } +/// Full-precision scalar decoder over one retained native CPU-readable source. +/// +/// Implementations return RGB in the declared source transfer domain and +/// normalized alpha. The reducer owns transfer decoding, gamut conversion, +/// tone mapping, spatial accumulation, and final output quantization. +pub trait CpuScalarSource: Sync { + /// Native storage extent decoded by this source. + fn storage_extent(&self) -> PixelExtent; + + /// Exact native pixel format decoded by this source. + fn pixel_format(&self) -> CapturePixelFormat; + + /// Decode one stored pixel without output quantization. + /// + /// # Errors + /// + /// Returns a sampling error if the validated source cannot supply the + /// requested in-bounds coordinate. + fn sample_rgba32f(&self, x: u32, y: u32) -> Result<[f32; 4], CpuSamplingError>; +} + +/// Validated logical sampling lens over a retained native scalar decoder. +pub struct CpuScalarSamplingView<'frame> { + source: &'frame ResolvedScreenSource, + frame: &'frame CaptureFrame, + samples: &'frame dyn CpuScalarSource, +} + +impl<'frame> CpuScalarSamplingView<'frame> { + /// Bind a retained native frame and scalar decoder to one resolved CPU source. + /// + /// # Errors + /// + /// Rejects stale frames, mismatched geometry, color, format, extent, cursor + /// ownership, and non-native frame storage before any destination is touched. + pub fn try_new( + frame: &'frame CaptureFrame, + source: &'frame ResolvedScreenSource, + samples: &'frame dyn CpuScalarSource, + ) -> Result { + CpuSamplingTransform::try_from_source(source)?; + frame.validate_epoch(source.epoch())?; + let config = source.config(); + if frame.metadata().geometry != config.geometry() { + return Err(CpuSamplingError::SourceGeometryMismatch { + expected: config.geometry(), + actual: frame.metadata().geometry, + }); + } + if frame.metadata().colorimetry != config.colorimetry() { + return Err(CpuSamplingError::SourceColorimetryMismatch { + expected: config.colorimetry(), + actual: frame.metadata().colorimetry, + }); + } + let CaptureStorage::Gpu(storage) = frame.storage() else { + return Err(CpuSamplingError::ScalarSourceRequiresNativeStorage); + }; + if storage.format() != config.pixel_format() { + return Err(CpuSamplingError::SourcePixelFormatMismatch { + expected: config.pixel_format(), + actual: storage.format(), + }); + } + if samples.pixel_format() != config.pixel_format() { + return Err(CpuSamplingError::SourcePixelFormatMismatch { + expected: config.pixel_format(), + actual: samples.pixel_format(), + }); + } + if samples.storage_extent() != config.geometry().storage_extent() { + return Err(CpuSamplingError::ScalarSourceExtentMismatch { + expected: config.geometry().storage_extent(), + actual: samples.storage_extent(), + }); + } + validate_cursor_content(&frame.metadata().cursor.content, source)?; + Ok(Self { + source, + frame, + samples, + }) + } + + /// Native acquisition sequence borrowed by this view. + #[must_use] + pub const fn source_sequence(&self) -> u64 { + self.frame.metadata().sequence + } + + /// Cursor ownership metadata retained without composition. + #[must_use] + pub const fn cursor(&self) -> &CaptureCursor { + &self.frame.metadata().cursor + } +} + impl<'frame> CpuSamplingView<'frame> { /// Validate a raw CPU frame against one immutable resolved source. /// @@ -843,6 +940,52 @@ pub(crate) struct CpuSamplingRow<'frame> { format: CapturePixelFormat, } +pub(crate) trait PreparedCpuSamplingSource: Sync { + type Row<'row>: PreparedCpuSamplingRow + where + Self: 'row; + + fn storage_row(&self, y: u32) -> Result, CpuSamplingError>; +} + +pub(crate) trait PreparedCpuSamplingRow: Copy { + fn read_rgba32f(self, x: u32) -> Result<[f32; 4], CpuSamplingError>; +} + +impl PreparedCpuSamplingSource for CpuSamplingView<'_> { + type Row<'row> + = CpuSamplingRow<'row> + where + Self: 'row; + + fn storage_row(&self, y: u32) -> Result, CpuSamplingError> { + Self::storage_row(self, y) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct CpuScalarSamplingRow<'row> { + samples: &'row dyn CpuScalarSource, + y: u32, +} + +impl PreparedCpuSamplingSource for CpuScalarSamplingView<'_> { + type Row<'row> + = CpuScalarSamplingRow<'row> + where + Self: 'row; + + fn storage_row(&self, y: u32) -> Result, CpuSamplingError> { + if y >= self.source.config().geometry().storage_extent().height() { + return Err(CpuSamplingError::StorageAddressOverflow); + } + Ok(CpuScalarSamplingRow { + samples: self.samples, + y, + }) + } +} + impl CpuSamplingRow<'_> { pub(crate) fn read_rgba(self, x: u32) -> Result<[u8; 4], CpuSamplingError> { let pixel_offset = usize::try_from(x) @@ -862,10 +1005,29 @@ impl CpuSamplingRow<'_> { Ok(match self.format { CapturePixelFormat::Rgba8 => [pixel[0], pixel[1], pixel[2], pixel[3]], CapturePixelFormat::Bgra8 => [pixel[2], pixel[1], pixel[0], pixel[3]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + unreachable!("validated byte sampling views retain RGBA8 storage") + } }) } } +impl PreparedCpuSamplingRow for CpuSamplingRow<'_> { + fn read_rgba32f(self, x: u32) -> Result<[f32; 4], CpuSamplingError> { + Ok(self.read_rgba(x)?.map(|channel| f32::from(channel) / 255.0)) + } +} + +impl PreparedCpuSamplingRow for CpuScalarSamplingRow<'_> { + fn read_rgba32f(self, x: u32) -> Result<[f32; 4], CpuSamplingError> { + self.samples.sample_rgba32f(x, self.y) + } +} + #[derive(Clone, Copy, Debug)] struct WideRational { numerator: u128, @@ -1157,6 +1319,9 @@ pub enum CpuSamplingError { /// The frame contains an opaque GPU surface. #[error("CPU sampling cannot read GPU frame storage")] GpuFrameStorage, + /// Scalar native decoding requires a retained native surface owner. + #[error("scalar CPU sampling requires native frame storage")] + ScalarSourceRequiresNativeStorage, /// Frame geometry differs from the resolved source snapshot. #[error("frame geometry {actual:?} differs from resolved geometry {expected:?}")] SourceGeometryMismatch { @@ -1175,6 +1340,15 @@ pub enum CpuSamplingError { expected: CapturePixelFormat, actual: CapturePixelFormat, }, + /// Scalar decoder extent differs from the resolved native storage extent. + #[error("scalar source extent {actual:?} differs from resolved extent {expected:?}")] + ScalarSourceExtentMismatch { + expected: PixelExtent, + actual: PixelExtent, + }, + /// A validated scalar decoder failed to supply an in-bounds stored pixel. + #[error("scalar source failed to decode stored pixel ({x}, {y})")] + ScalarSourceReadFailed { x: u32, y: u32 }, /// Logical extent contradicts the exact physical-to-logical scale. #[error( "logical extent {logical_extent:?} does not equal rotated crop {rotated_crop_extent:?} scaled by {scale_numerator}/{scale_denominator}" diff --git a/crates/hypercolor-core/src/input/screen/tone_map.rs b/crates/hypercolor-core/src/input/screen/tone_map.rs index 7d39d9601..e86e3d5f5 100644 --- a/crates/hypercolor-core/src/input/screen/tone_map.rs +++ b/crates/hypercolor-core/src/input/screen/tone_map.rs @@ -5,7 +5,7 @@ use std::time::Duration; use thiserror::Error; -use hypercolor_types::canvas::{linear_to_srgb_u8, srgb_u8_to_linear}; +use hypercolor_types::canvas::linear_to_srgb_u8; use super::frame::{ CaptureColorSpace, CaptureDynamicRange, CaptureLuminanceContext, CapturePositiveScalar, @@ -355,17 +355,34 @@ impl PreparedLedToneMap { /// Decode one source sample and apply the complete linear-light contract. #[must_use] pub fn decode_and_map(self, encoded: [u8; 4]) -> [f64; 4] { - let rgb = [ - self.decode_channel(encoded[0]), - self.decode_channel(encoded[1]), - self.decode_channel(encoded[2]), + self.decode_and_map_source(encoded.map(|channel| f32::from(channel) / 255.0)) + } + + /// Decode one full-precision source-domain sample and apply the linear contract. + /// + /// Values are not clamped before transfer decoding. Extended-linear sources + /// therefore retain diffuse and specular values above one until tone mapping. + #[must_use] + pub fn decode_and_map_source(self, source: [f32; 4]) -> [f64; 4] { + let mut rgb = [ + self.decode_source_channel(source[0]), + self.decode_source_channel(source[1]), + self.decode_source_channel(source[2]), ]; + if self.source_transfer == CaptureTransferFunction::Hlg { + rgb = hlg_scene_to_reference_linear( + rgb, + &self.constants.source_luminance_and_exposure[..3], + self.constants.curve[1], + self.constants.curve[2], + ); + } let mapped = self.map_linear(rgb); [ f64::from(mapped[0]), f64::from(mapped[1]), f64::from(mapped[2]), - f64::from(encoded[3]) / 255.0, + f64::from(source[3]), ] } @@ -404,6 +421,8 @@ impl PreparedLedToneMap { let encode = |value: f64| match self.output_transfer { CaptureTransferFunction::Srgb => linear_to_srgb_u8(value as f32), CaptureTransferFunction::Linear => encode_byte(value as f32), + CaptureTransferFunction::Rec709 => encode_byte(linear_to_rec709(value as f32)), + CaptureTransferFunction::Rec2020 => encode_byte(linear_to_rec2020(value as f32)), CaptureTransferFunction::Pq | CaptureTransferFunction::Hlg | CaptureTransferFunction::Unknown => { @@ -418,13 +437,15 @@ impl PreparedLedToneMap { ] } - fn decode_channel(self, encoded: u8) -> f32 { - let value = f32::from(encoded) / 255.0; + fn decode_source_channel(self, encoded: f32) -> f32 { match self.source_transfer { - CaptureTransferFunction::Srgb => srgb_u8_to_linear(encoded), - CaptureTransferFunction::Linear => value, - CaptureTransferFunction::Pq => pq_to_nits(value) / self.constants.curve[2], - CaptureTransferFunction::Hlg | CaptureTransferFunction::Unknown => { + CaptureTransferFunction::Srgb => srgb_to_linear(encoded), + CaptureTransferFunction::Linear => encoded, + CaptureTransferFunction::Rec709 => rec709_to_linear(encoded), + CaptureTransferFunction::Rec2020 => rec2020_to_linear(encoded), + CaptureTransferFunction::Pq => pq_to_nits(encoded) / self.constants.curve[2], + CaptureTransferFunction::Hlg => hlg_inverse_oetf(encoded), + CaptureTransferFunction::Unknown => { unreachable!("prepared source transfer remains executable") } } @@ -561,7 +582,12 @@ fn validate_transfer( CaptureTransferFunction::Srgb | CaptureTransferFunction::Linear, CaptureDynamicRange::Standard ) | ( - CaptureTransferFunction::Pq | CaptureTransferFunction::Linear, + CaptureTransferFunction::Rec709 | CaptureTransferFunction::Rec2020, + CaptureDynamicRange::Standard + ) | ( + CaptureTransferFunction::Pq + | CaptureTransferFunction::Hlg + | CaptureTransferFunction::Linear, CaptureDynamicRange::High ) ) @@ -571,6 +597,9 @@ fn validate_transfer( ( CaptureTransferFunction::Srgb | CaptureTransferFunction::Linear, CaptureDynamicRange::Standard + ) | ( + CaptureTransferFunction::Rec709 | CaptureTransferFunction::Rec2020, + CaptureDynamicRange::Standard ) ) }; @@ -670,6 +699,78 @@ fn pq_to_nits(encoded: f32) -> f32 { 10_000.0 * (numerator / denominator).powf(1.0 / M1) } +fn srgb_to_linear(encoded: f32) -> f32 { + if encoded <= 0.040_45 { + encoded / 12.92 + } else { + ((encoded + 0.055) / 1.055).powf(2.4) + } +} + +fn rec709_to_linear(encoded: f32) -> f32 { + if encoded < 0.081 { + encoded / 4.5 + } else { + ((encoded + 0.099) / 1.099).powf(1.0 / 0.45) + } +} + +fn linear_to_rec709(linear: f32) -> f32 { + if linear < 0.018 { + 4.5 * linear + } else { + 1.099 * linear.powf(0.45) - 0.099 + } +} + +fn rec2020_to_linear(encoded: f32) -> f32 { + const ALPHA: f32 = 1.099_296_8; + const BETA: f32 = 0.018_053_97; + if encoded < 4.5 * BETA { + encoded / 4.5 + } else { + ((encoded + ALPHA - 1.0) / ALPHA).powf(1.0 / 0.45) + } +} + +fn linear_to_rec2020(linear: f32) -> f32 { + const ALPHA: f32 = 1.099_296_8; + const BETA: f32 = 0.018_053_97; + if linear < BETA { + 4.5 * linear + } else { + ALPHA * linear.powf(0.45) - (ALPHA - 1.0) + } +} + +fn hlg_inverse_oetf(encoded: f32) -> f32 { + const A: f32 = 0.178_832_77; + const B: f32 = 0.284_668_92; + const C: f32 = 0.559_910_7; + let encoded = encoded.max(0.0); + if encoded <= 0.5 { + encoded * encoded / 3.0 + } else { + (((encoded - C) / A).exp() + B) / 12.0 + } +} + +fn hlg_scene_to_reference_linear( + scene_rgb: [f32; 3], + source_luminance: &[f32], + source_headroom: f32, + source_reference_nits: f32, +) -> [f32; 3] { + let source_peak_nits = source_reference_nits * source_headroom; + let system_gamma = 1.2 + 0.42 * (source_peak_nits / 1_000.0).log10(); + let scene_luminance = dot3(source_luminance, scene_rgb).max(0.0); + if scene_luminance <= f32::EPSILON { + return [0.0; 3]; + } + let ootf_scale = source_headroom * scene_luminance.powf(system_gamma - 1.0); + scene_rgb.map(|channel| channel * ootf_scale) +} + fn nits_to_pq(nits: f32) -> f32 { const M1: f32 = 2_610.0 / 16_384.0; const M2: f32 = 2_523.0 / 32.0; @@ -795,6 +896,16 @@ mod tests { .expect("extended-linear HDR source is valid") } + fn hlg_hdr_source() -> KnownCaptureColorimetry { + KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Hlg, + CaptureDynamicRange::High, + Some(luminance(203.0, 1_000.0)), + ) + .expect("HLG source is valid") + } + #[test] fn golden_reference_white_and_highlight_shoulder() { let sdr = PreparedLedToneMap::prepare( @@ -853,6 +964,51 @@ mod tests { assert_eq!(actual[1], actual[2]); } assert_eq!(prepared.decode_and_map([255; 4]), [0.5, 0.5, 0.5, 1.0]); + let specular = prepared.decode_and_map_source([1_000.0 / 203.0; 4]); + assert!((specular[0] - 1.0).abs() < 1.0e-6); + assert!(specular[3] > 4.9); + } + + #[test] + fn hlg_diffuse_white_and_specular_peak_share_the_hdr_curve() { + let prepared = PreparedLedToneMap::prepare( + hlg_hdr_source(), + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("HLG HDR curve prepares"); + let diffuse = prepared.decode_and_map_source([0.75, 0.75, 0.75, 1.0]); + assert!((diffuse[0] - 0.5).abs() < 5.0e-4); + assert_eq!(diffuse[0], diffuse[1]); + assert_eq!(diffuse[1], diffuse[2]); + let specular = prepared.decode_and_map_source([1.0; 4]); + assert!((specular[0] - 1.0).abs() < 2.0e-5); + assert_eq!(specular[0], specular[1]); + assert_eq!(specular[1], specular[2]); + let rec2020_luminance = &REC2020_TO_XYZ.padded_rows()[1][..3]; + let neutral_1k = hlg_scene_to_reference_linear( + [1.0 / 12.0; 3], + rec2020_luminance, + 1_000.0 / 203.0, + 203.0, + ); + assert!((neutral_1k[0] - 0.249_739_05).abs() < 1.0e-6); + let neutral_1600 = hlg_scene_to_reference_linear( + [1.0 / 12.0; 3], + rec2020_luminance, + 1_600.0 / 203.0, + 203.0, + ); + assert!((neutral_1600[0] - 0.322_914_7).abs() < 1.0e-6); + let chromatic = hlg_scene_to_reference_linear( + [1.0, 1.0 / 12.0, 0.0], + rec2020_luminance, + 1_000.0 / 203.0, + 203.0, + ); + assert!((chromatic[0] - 3.920_275_2).abs() < 1.0e-6); + assert!((chromatic[1] - 0.326_689_6).abs() < 1.0e-6); + assert_eq!(chromatic[2], 0.0); } #[test] diff --git a/crates/hypercolor-core/tests/capture_color_contract_tests.rs b/crates/hypercolor-core/tests/capture_color_contract_tests.rs index 2dc83cbfa..bbecff498 100644 --- a/crates/hypercolor-core/tests/capture_color_contract_tests.rs +++ b/crates/hypercolor-core/tests/capture_color_contract_tests.rs @@ -848,20 +848,41 @@ fn hdr_passthrough_and_sdr_to_hdr_conversion_remain_unavailable() { } #[test] -fn hlg_tone_mapping_requires_an_explicit_system_ootf_contract() { +fn hlg_tone_mapping_uses_the_declared_system_ootf_contract() { let hlg = known_hdr(CaptureTransferFunction::Hlg, luminance(203.0, 1_000.0)); + let hlg_source = source(CaptureColorimetry::from_known(hlg)); + let calibration = LedToneMapCalibration::DEFAULT; let tone_map = native_surface(ScreenProcessingProfileConfig { - hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::new( + hdr: ScreenHdrPolicy::ToneMap(ScreenToneMapPolicy::from_calibration( ScreenToneMapOperator::Bt2390Eetf, - luminance(100.0, 100.0), + calibration, )), ..ScreenProcessingProfileConfig::default() }); assert_eq!( - tone_map.resolve(&source(CaptureColorimetry::from_known(hlg))), - Err(ScreenPublicationError::UnsupportedHdrConversion) + tone_map.resolve(&hlg_source), + Err(ScreenPublicationError::UnsupportedColorTransform) ); + let descriptor = tone_map + .resolve_with_color_capabilities( + &hlg_source, + ScreenColorTransformCapabilities::new( + false, + false, + true, + LED_TONE_MAP_ALGORITHM_REVISION, + ), + ) + .expect("declared HLG OOTF and BT.2390 support resolve the exact tone-map contract"); + let ResolvedScreenColorTransform::ToneMap(resolved) = + descriptor.physical().color_pipeline().transform() + else { + panic!("HLG source should resolve the declared tone-map pipeline"); + }; + assert_eq!(resolved.operator(), ScreenToneMapOperator::Bt2390Eetf); + assert_eq!(resolved.source_luminance(), luminance(203.0, 1_000.0)); + assert_eq!(resolved.target_luminance(), calibration.target_luminance()); } #[test] diff --git a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs index 371c663c5..8f61817fa 100644 --- a/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs @@ -338,6 +338,13 @@ fn encoded_pixel(color: [u8; 4], pixel_format: CapturePixelFormat) -> [u8; 4] { match pixel_format { CapturePixelFormat::Rgba8 => color, CapturePixelFormat::Bgra8 => [color[2], color[1], color[0], color[3]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("branch processing fixtures accept only RGBA8 and BGRA8") + } } } @@ -345,6 +352,13 @@ fn decoded_pixel(color: [u8; 4], pixel_format: CapturePixelFormat) -> [u8; 3] { match pixel_format { CapturePixelFormat::Rgba8 => color[..3].try_into().expect("pixel has RGB channels"), CapturePixelFormat::Bgra8 => [color[2], color[1], color[0]], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("branch processing fixtures accept only RGBA8 and BGRA8") + } } } diff --git a/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs b/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs index e62bbba95..b730545a7 100644 --- a/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs +++ b/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs @@ -400,6 +400,13 @@ fn patterned_pixels(extent: PixelExtent, format: CapturePixelFormat) -> Vec CapturePixelFormat::Bgra8 => { pixels.extend_from_slice(&[rgba[2], rgba[1], rgba[0], rgba[3]]); } + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("packed reducer fixture accepts only RGBA8 and BGRA8") + } } } } @@ -480,6 +487,13 @@ fn one_pixel_roundtrips_every_filter_and_channel_order() { let expected = match target_format { CapturePixelFormat::Rgba8 => vec![19, 71, 3, 127], CapturePixelFormat::Bgra8 => vec![3, 71, 19, 127], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("packed reducer fixture accepts only RGBA8 and BGRA8") + } }; assert_eq!(output, expected); } @@ -913,6 +927,13 @@ fn scalar_reference( CapturePixelFormat::Bgra8 => { output.extend_from_slice(&[rgba[2], rgba[1], rgba[0], rgba[3]]); } + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("packed reducer fixture accepts only RGBA8 and BGRA8") + } } } } @@ -1034,6 +1055,13 @@ fn scalar_read( source[index], source[index + 3], ], + CapturePixelFormat::Argb2101010 + | CapturePixelFormat::Rgba16Float + | CapturePixelFormat::Yuv420VideoRange + | CapturePixelFormat::Yuv420FullRange + | CapturePixelFormat::Yuv44410BiPlanar => { + panic!("packed reducer fixture accepts only RGBA8 and BGRA8") + } } } diff --git a/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs index 222361b18..d7c75a94a 100644 --- a/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs +++ b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs @@ -13,11 +13,13 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; -#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] -use hypercolor_macos_capture::MacosCaptureFrame; use hypercolor_macos_capture::MacosCaptureSelector; #[cfg(target_os = "macos")] use hypercolor_macos_capture::MacosFrameDropReason; +#[cfg(any(target_os = "macos", all(test, feature = "capture-fixtures")))] +use hypercolor_macos_capture::{ + MacosCaptureFrame, MacosCapturePixelFormat, MacosColorPrimaries, MacosTransferFunction, +}; const DEFAULT_FRAME_COUNT: usize = 1; const MAX_FRAME_COUNT: usize = 600; @@ -396,10 +398,19 @@ fn export_frame_with_warning( let length = row_bytes .checked_mul(frame.storage_extent.height as usize) .ok_or_else(|| "pixel export length overflowed".to_owned())?; + if frame.pixel_format != MacosCapturePixelFormat::Bgra8 + || frame.color.primaries != MacosColorPrimaries::Srgb + || frame.color.transfer != MacosTransferFunction::Srgb + { + return Err("PAM export supports sRGB BGRA frames only".to_owned()); + } let mut rgba = vec![0_u8; length]; frame - .convert_bgra8_sdr_to_rgba8(&mut rgba, row_bytes) - .map_err(|_| "pixel export supports SDR BGRA frames only".to_owned())?; + .copy_bgra8_to(&mut rgba, row_bytes) + .map_err(|_| "PAM export could not map the retained BGRA plane".to_owned())?; + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } let file = std::fs::File::create(path) .map_err(|error| format!("could not create explicit output path: {error}"))?; diff --git a/crates/hypercolor-macos-capture/src/cpu.rs b/crates/hypercolor-macos-capture/src/cpu.rs index 99178db5b..754051dac 100644 --- a/crates/hypercolor-macos-capture/src/cpu.rs +++ b/crates/hypercolor-macos-capture/src/cpu.rs @@ -1,58 +1,299 @@ use crate::{ - MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, MacosColorPrimaries, - MacosTransferFunction, + MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCapturePlane, MacosChromaLocation, MacosColorRange, MacosPixelExtent, MacosYuvMatrix, }; +const RGBA_CHANNELS: usize = 4; + +/// Borrowed, validated CPU view over one retained native capture frame. +/// +/// RGB samples remain in the source transfer domain. `RGhA` therefore retains +/// extended-linear values above one, while YUV samples are matrix-converted to +/// transfer-encoded RGB before the shared color pipeline decodes them. +#[derive(Clone, Copy, Debug)] +pub struct MacosCpuSourceView<'frame> { + extent: MacosPixelExtent, + format: MacosCapturePixelFormat, + color: MacosCaptureColorimetry, + descriptors: &'frame [MacosCapturePlane], + planes: &'frame [&'frame [u8]], +} + +impl<'frame> MacosCpuSourceView<'frame> { + /// Exact storage extent represented by this view. + #[must_use] + pub const fn extent(self) -> MacosPixelExtent { + self.extent + } + + /// Exact native format decoded by this view. + #[must_use] + pub const fn pixel_format(self) -> MacosCapturePixelFormat { + self.format + } + + /// Source color metadata whose transfer domain the returned RGB retains. + #[must_use] + pub const fn colorimetry(self) -> MacosCaptureColorimetry { + self.color + } + + /// Decode one native pixel into source-domain RGBA32Float. + /// + /// # Errors + /// + /// Rejects coordinates outside the validated storage extent and any + /// addressing arithmetic that escapes a retained plane. + pub fn sample_rgba32f(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + if x >= self.extent.width || y >= self.extent.height { + return Err(MacosCaptureError::CpuPixelOutsideStorage { + x, + y, + extent: self.extent, + }); + } + match self.format { + MacosCapturePixelFormat::Bgra8 => self.sample_bgra8(x, y), + MacosCapturePixelFormat::Argb2101010 => self.sample_argb2101010(x, y), + MacosCapturePixelFormat::Rgba16Float => self.sample_rgba16_float(x, y), + MacosCapturePixelFormat::Yuv420VideoRange + | MacosCapturePixelFormat::Yuv420FullRange => self.sample_yuv420(x, y), + MacosCapturePixelFormat::Yuv44410BiPlanar => self.sample_yuv44410(x, y), + } + } + + fn sample_bgra8(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + let pixel = self.packed_pixel(0, x, y, 4)?; + Ok([ + normalize_u8(pixel[2]), + normalize_u8(pixel[1]), + normalize_u8(pixel[0]), + normalize_u8(pixel[3]), + ]) + } + + fn sample_argb2101010(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + let pixel = self.packed_pixel(0, x, y, 4)?; + let packed = u32::from_le_bytes( + pixel + .try_into() + .expect("validated packed pixel has exactly four bytes"), + ); + Ok([ + ((packed >> 20) & 0x03ff) as f32 / 1_023.0, + ((packed >> 10) & 0x03ff) as f32 / 1_023.0, + (packed & 0x03ff) as f32 / 1_023.0, + ((packed >> 30) & 0x0003) as f32 / 3.0, + ]) + } + + fn sample_rgba16_float(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + let pixel = self.packed_pixel(0, x, y, 8)?; + Ok([ + decode_f16(u16::from_le_bytes([pixel[0], pixel[1]])), + decode_f16(u16::from_le_bytes([pixel[2], pixel[3]])), + decode_f16(u16::from_le_bytes([pixel[4], pixel[5]])), + decode_f16(u16::from_le_bytes([pixel[6], pixel[7]])), + ]) + } + + fn sample_yuv420(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + let luma = f32::from(self.packed_pixel(0, x, y, 1)?[0]); + let location = self + .color + .chroma_location + .ok_or(MacosCaptureError::MissingYuvColorMetadata)?; + let [cb, cr] = self.sample_chroma_420(x, y, location)?; + let [luma, cb, cr] = match self.color.range { + MacosColorRange::Video => [ + (luma - 16.0) / 219.0, + (cb - 128.0) / 224.0, + (cr - 128.0) / 224.0, + ], + MacosColorRange::Full => [luma / 255.0, (cb - 128.0) / 255.0, (cr - 128.0) / 255.0], + }; + Ok(yuv_to_rgb( + luma, + cb, + cr, + self.color + .matrix + .ok_or(MacosCaptureError::MissingYuvColorMetadata)?, + )) + } + + fn sample_yuv44410(self, x: u32, y: u32) -> Result<[f32; 4], MacosCaptureError> { + let luma = f32::from(read_msb_10(self.packed_pixel(0, x, y, 2)?)); + let chroma = self.packed_pixel(1, x, y, 4)?; + let cb = f32::from(read_msb_10(&chroma[..2])); + let cr = f32::from(read_msb_10(&chroma[2..])); + let [luma, cb, cr] = match self.color.range { + MacosColorRange::Video => [ + (luma - 64.0) / 876.0, + (cb - 512.0) / 896.0, + (cr - 512.0) / 896.0, + ], + MacosColorRange::Full => [ + luma / 1_023.0, + (cb - 512.0) / 1_023.0, + (cr - 512.0) / 1_023.0, + ], + }; + Ok(yuv_to_rgb( + luma, + cb, + cr, + self.color + .matrix + .ok_or(MacosCaptureError::MissingYuvColorMetadata)?, + )) + } + + fn sample_chroma_420( + self, + x: u32, + y: u32, + location: MacosChromaLocation, + ) -> Result<[f32; 2], MacosCaptureError> { + let (horizontal_offset, vertical_offset) = match location { + MacosChromaLocation::Center => (1.0, 1.0), + MacosChromaLocation::Left => (0.5, 1.0), + MacosChromaLocation::TopLeft => (0.5, 0.5), + }; + let chroma_x = (x as f32 + 0.5 - horizontal_offset) * 0.5; + let chroma_y = (y as f32 + 0.5 - vertical_offset) * 0.5; + self.bilinear_chroma_8(chroma_x, chroma_y) + } + + fn bilinear_chroma_8(self, x: f32, y: f32) -> Result<[f32; 2], MacosCaptureError> { + let extent = self.descriptors[1].extent; + let maximum_x = extent.width.saturating_sub(1) as f32; + let maximum_y = extent.height.saturating_sub(1) as f32; + let x = x.clamp(0.0, maximum_x); + let y = y.clamp(0.0, maximum_y); + let x0 = x.floor() as u32; + let y0 = y.floor() as u32; + let x1 = (x0 + 1).min(extent.width - 1); + let y1 = (y0 + 1).min(extent.height - 1); + let x_weight = x - x0 as f32; + let y_weight = y - y0 as f32; + let top_left = self.chroma_8(x0, y0)?; + let top_right = self.chroma_8(x1, y0)?; + let bottom_left = self.chroma_8(x0, y1)?; + let bottom_right = self.chroma_8(x1, y1)?; + Ok(std::array::from_fn(|channel| { + let top = top_left[channel] + (top_right[channel] - top_left[channel]) * x_weight; + let bottom = + bottom_left[channel] + (bottom_right[channel] - bottom_left[channel]) * x_weight; + top + (bottom - top) * y_weight + })) + } + + fn chroma_8(self, x: u32, y: u32) -> Result<[f32; 2], MacosCaptureError> { + let pixel = self.packed_pixel(1, x, y, 2)?; + Ok([f32::from(pixel[0]), f32::from(pixel[1])]) + } + + fn packed_pixel( + self, + plane: usize, + x: u32, + y: u32, + bytes_per_pixel: usize, + ) -> Result<&'frame [u8], MacosCaptureError> { + let descriptor = &self.descriptors[plane]; + let row = usize::try_from(y) + .ok() + .and_then(|y| y.checked_mul(descriptor.bytes_per_row)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let offset = usize::try_from(x) + .ok() + .and_then(|x| x.checked_mul(bytes_per_pixel)) + .and_then(|x| row.checked_add(x)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let end = offset + .checked_add(bytes_per_pixel) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + self.planes[plane] + .get(offset..end) + .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch) + } +} + impl MacosCaptureFrame { - pub fn copy_bgra8_to( + /// Borrow a validated scalar decoding oracle while the retained pixel + /// buffer remains CPU-locked. + /// + /// # Errors + /// + /// Rejects a descriptor whose plane extents, strides, lengths, allocation, + /// or color metadata no longer match the delivered native frame. + pub fn with_cpu_source( + &self, + operation: impl for<'plane> FnOnce(MacosCpuSourceView<'plane>) -> R, + ) -> Result { + validate_cpu_source(self)?; + let lengths = self + .planes + .iter() + .map(|plane| plane.length_bytes) + .collect::>(); + self.surface.with_plane_bytes(&lengths, |planes| { + operation(MacosCpuSourceView { + extent: self.storage_extent, + format: self.pixel_format, + color: self.color, + descriptors: &self.planes, + planes, + }) + }) + } + + /// Decode the retained native frame into tightly typed RGBA32Float bytes. + /// + /// Each component is written in little-endian IEEE-754 form. RGB remains + /// in the source transfer domain for the shared color pipeline; alpha is + /// normalized. Destination row padding is left untouched. + pub fn copy_source_rgba32f_to( &self, destination: &mut [u8], destination_stride: usize, ) -> Result<(), MacosCaptureError> { - if self.pixel_format != MacosCapturePixelFormat::Bgra8 { - return Err(MacosCaptureError::UnsupportedCpuPixelFormat( - self.pixel_format, - )); - } let row_bytes = usize::try_from(self.storage_extent.width) .ok() - .and_then(|width| width.checked_mul(4)) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - if destination_stride < row_bytes { - return Err(MacosCaptureError::InvalidCpuDestinationStride { - minimum: row_bytes, - actual: destination_stride, - }); - } - let height = usize::try_from(self.storage_extent.height) - .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; - let required = destination_stride - .checked_mul(height) + .and_then(|width| width.checked_mul(RGBA_CHANNELS * size_of::())) .ok_or(MacosCaptureError::ArithmeticOverflow)?; - if destination.len() < required { - return Err(MacosCaptureError::CpuDestinationTooSmall { - required, - actual: destination.len(), - }); - } - let source = self - .planes - .first() - .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; - let lengths = [source.length_bytes]; - self.surface.with_plane_bytes(&lengths, |planes| { - copy_rows( - planes[0], - source.bytes_per_row, - destination, - destination_stride, - row_bytes, - height, - ) + let height = validate_destination(destination, destination_stride, row_bytes, self)?; + self.with_cpu_source(|source| { + for y in 0..height { + let destination_start = y + .checked_mul(destination_stride) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_end = destination_start + .checked_add(row_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let destination_length = destination.len(); + let row = destination + .get_mut(destination_start..destination_end) + .ok_or(MacosCaptureError::CpuDestinationTooSmall { + required: destination_end, + actual: destination_length, + })?; + for (x, pixel) in row.chunks_exact_mut(16).enumerate() { + let rgba = source.sample_rgba32f( + u32::try_from(x).map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + u32::try_from(y).map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + )?; + for (channel, bytes) in rgba.into_iter().zip(pixel.chunks_exact_mut(4)) { + bytes.copy_from_slice(&channel.to_le_bytes()); + } + } + } + Ok(()) })? } - pub fn convert_bgra8_sdr_to_rgba8( + pub fn copy_bgra8_to( &self, destination: &mut [u8], destination_stride: usize, @@ -62,39 +303,64 @@ impl MacosCaptureFrame { self.pixel_format, )); } - if matches!( - self.color.transfer, - MacosTransferFunction::Pq | MacosTransferFunction::Hlg - ) { - return Err(MacosCaptureError::UnsupportedCpuTransferFunction( - self.color.transfer, - )); - } + validate_cpu_source(self)?; let row_bytes = usize::try_from(self.storage_extent.width) .ok() .and_then(|width| width.checked_mul(4)) .ok_or(MacosCaptureError::ArithmeticOverflow)?; let height = validate_destination(destination, destination_stride, row_bytes, self)?; - let source = self - .planes - .first() - .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; + let source = &self.planes[0]; let lengths = [source.length_bytes]; self.surface.with_plane_bytes(&lengths, |planes| { - convert_bgra_rows( + copy_rows( planes[0], source.bytes_per_row, destination, destination_stride, row_bytes, height, - self.color.primaries, - self.color.transfer, ) })? } } +fn validate_cpu_source(frame: &MacosCaptureFrame) -> Result<(), MacosCaptureError> { + frame.color.validate_for(frame.pixel_format)?; + let expected = frame.pixel_format.plane_layout(frame.storage_extent); + if frame.planes.len() != expected.len() { + return Err(MacosCaptureError::CpuPlaneLayoutMismatch); + } + let mut allocation = 0_u64; + for (position, (plane, (extent, bytes_per_pixel))) in + frame.planes.iter().zip(expected).enumerate() + { + if usize::try_from(plane.index).ok() != Some(position) || plane.extent != extent { + return Err(MacosCaptureError::CpuPlaneLayoutMismatch); + } + let minimum_stride = u64::from(extent.width) + .checked_mul(bytes_per_pixel) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let stride = u64::try_from(plane.bytes_per_row) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let minimum_length = stride + .checked_mul(u64::from(extent.height)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if stride < minimum_stride || plane.length_bytes < minimum_length { + return Err(MacosCaptureError::CpuPlaneLayoutMismatch); + } + allocation = allocation + .checked_add(plane.length_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + } + if allocation > frame.surface.allocation_bytes { + return Err(MacosCaptureError::AllocationTooSmall { + required: allocation, + actual: frame.surface.allocation_bytes, + }); + } + Ok(()) +} + fn validate_destination( destination: &[u8], destination_stride: usize, @@ -157,119 +423,44 @@ fn copy_rows( Ok(()) } -#[allow(clippy::too_many_arguments)] -fn convert_bgra_rows( - source: &[u8], - source_stride: usize, - destination: &mut [u8], - destination_stride: usize, - row_bytes: usize, - height: usize, - primaries: MacosColorPrimaries, - transfer: MacosTransferFunction, -) -> Result<(), MacosCaptureError> { - for row in 0..height { - let source_start = row - .checked_mul(source_stride) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - let source_end = source_start - .checked_add(row_bytes) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - let destination_start = row - .checked_mul(destination_stride) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - let destination_end = destination_start - .checked_add(row_bytes) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - let source_row = source - .get(source_start..source_end) - .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; - let destination_row = destination - .get_mut(destination_start..destination_end) - .ok_or(MacosCaptureError::CpuPlaneLayoutMismatch)?; - for (source_pixel, destination_pixel) in source_row - .chunks_exact(4) - .zip(destination_row.chunks_exact_mut(4)) - { - let linear = [ - decode(source_pixel[2], transfer), - decode(source_pixel[1], transfer), - decode(source_pixel[0], transfer), - ]; - let linear = compress_gamut(convert_primaries(linear, primaries)); - destination_pixel[0] = encode_srgb(linear[0]); - destination_pixel[1] = encode_srgb(linear[1]); - destination_pixel[2] = encode_srgb(linear[2]); - destination_pixel[3] = source_pixel[3]; - } - } - Ok(()) -} - -fn decode(value: u8, transfer: MacosTransferFunction) -> f32 { - let value = f32::from(value) / 255.0; - match transfer { - MacosTransferFunction::Srgb => { - if value <= 0.040_45 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - } - MacosTransferFunction::Rec709 => decode_bt(value, 1.099, 0.018), - MacosTransferFunction::Rec2020 => decode_bt(value, 1.099_296_8, 0.018_053_97), - MacosTransferFunction::Linear => value, - MacosTransferFunction::Pq | MacosTransferFunction::Hlg => unreachable!(), - } +fn normalize_u8(value: u8) -> f32 { + f32::from(value) / 255.0 } -fn decode_bt(value: f32, alpha: f32, beta: f32) -> f32 { - let encoded_cut = 4.5 * beta; - if value < encoded_cut { - value / 4.5 - } else { - ((value + alpha - 1.0) / alpha).powf(1.0 / 0.45) - } +fn read_msb_10(bytes: &[u8]) -> u16 { + u16::from_le_bytes([bytes[0], bytes[1]]) >> 6 } -fn convert_primaries(rgb: [f32; 3], primaries: MacosColorPrimaries) -> [f32; 3] { - let matrix: [[f32; 3]; 3] = match primaries { - MacosColorPrimaries::Srgb => return rgb, - MacosColorPrimaries::DisplayP3 => [ - [1.224_745, -0.224_904, 0.0], - [-0.042_058, 1.042_081, 0.0], - [-0.019_642, -0.078_655, 1.098_537], - ], - MacosColorPrimaries::Rec2020 => [ - [1.660_491, -0.587_641, -0.072_85], - [-0.124_55, 1.132_9, -0.008_349], - [-0.018_151, -0.100_579, 1.118_73], - ], +fn yuv_to_rgb(luma: f32, cb: f32, cr: f32, matrix: MacosYuvMatrix) -> [f32; 4] { + let (red_luma, blue_luma) = match matrix { + MacosYuvMatrix::Bt601 => (0.299, 0.114), + MacosYuvMatrix::Bt709 => (0.2126, 0.0722), + MacosYuvMatrix::Bt2020 => (0.2627, 0.0593), }; - matrix.map(|row| row[0].mul_add(rgb[0], row[1].mul_add(rgb[1], row[2] * rgb[2]))) + let green_luma = 1.0 - red_luma - blue_luma; + [ + luma + 2.0 * (1.0 - red_luma) * cr, + luma - 2.0 * blue_luma * (1.0 - blue_luma) / green_luma * cb + - 2.0 * red_luma * (1.0 - red_luma) / green_luma * cr, + luma + 2.0 * (1.0 - blue_luma) * cb, + 1.0, + ] } -fn compress_gamut(mut rgb: [f32; 3]) -> [f32; 3] { - let minimum = rgb.into_iter().reduce(f32::min).unwrap_or(0.0); - if minimum < 0.0 { - for channel in &mut rgb { - *channel -= minimum; +fn decode_f16(bits: u16) -> f32 { + let sign = u32::from(bits & 0x8000) << 16; + let exponent = u32::from((bits >> 10) & 0x001f); + let fraction = u32::from(bits & 0x03ff); + if exponent == 0 { + if fraction == 0 { + return f32::from_bits(sign); } + let magnitude = fraction as f32 * 2.0_f32.powi(-24); + return if sign == 0 { magnitude } else { -magnitude }; } - let maximum = rgb.into_iter().reduce(f32::max).unwrap_or(1.0); - if maximum > 1.0 { - for channel in &mut rgb { - *channel /= maximum; - } - } - rgb -} - -fn encode_srgb(value: f32) -> u8 { - let encoded = if value <= 0.003_130_8 { - 12.92 * value - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 + let decoded = match exponent { + 0x1f => sign | 0x7f80_0000 | (fraction << 13), + _ => sign | ((exponent + 112) << 23) | (fraction << 13), }; - (encoded.clamp(0.0, 1.0) * 255.0).round() as u8 + f32::from_bits(decoded) } diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index c6a11ae87..5499a4d30 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -73,11 +73,12 @@ impl MacosFrameDropReason { | MacosCaptureError::CpuPlaneLayoutMismatch | MacosCaptureError::PixelBufferLockFailed(_) | MacosCaptureError::PixelBufferUnlockFailed(_) - | MacosCaptureError::FixturePixelLength { .. } + | MacosCaptureError::FixturePlaneCount { .. } + | MacosCaptureError::FixturePlaneLength { .. } | MacosCaptureError::PixelBufferFixtureCreateFailed(_) | MacosCaptureError::MissingCpuPlaneAddress(_) | MacosCaptureError::UnsupportedCpuPixelFormat(_) - | MacosCaptureError::UnsupportedCpuTransferFunction(_) + | MacosCaptureError::CpuPixelOutsideStorage { .. } | MacosCaptureError::InvalidCpuDestinationStride { .. } | MacosCaptureError::CpuDestinationTooSmall { .. } | MacosCaptureError::SequenceExhausted diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index 8bde21cd3..d22455b6e 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -13,7 +13,8 @@ use objc2_core_video::{ }; #[cfg(all(target_os = "macos", feature = "capture-fixtures"))] use objc2_core_video::{ - CVPixelBufferCreate, CVPixelBufferGetBytesPerRow, CVPixelBufferGetDataSize, + CVPixelBufferCreate, CVPixelBufferGetBytesPerRow, CVPixelBufferGetBytesPerRowOfPlane, + CVPixelBufferGetHeightOfPlane, CVPixelBufferGetWidthOfPlane, kCVPixelBufferIOSurfacePropertiesKey, }; use thiserror::Error; @@ -263,25 +264,43 @@ impl MacosNativeSurfaceLease<'_> { } impl MacosCaptureSurface { - /// Creates an IOSurface-backed packed BGRA fixture and its exact plane. + /// Creates an IOSurface-backed native-format fixture and exact plane descriptors. + /// + /// Source planes are tightly packed according to the format's canonical plane + /// geometry. Core Video may choose wider native row strides; padding remains + /// zeroed and is described by the returned plane metadata. #[cfg(all(target_os = "macos", feature = "capture-fixtures"))] - pub fn new_native_bgra_fixture( + pub fn new_native_fixture( extent: MacosPixelExtent, - pixels: &[u8], - ) -> Result<(Self, MacosCapturePlane), MacosCaptureError> { - let packed_stride = usize::try_from(extent.width) - .ok() - .and_then(|width| width.checked_mul(4)) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - let expected_len = packed_stride - .checked_mul(extent.height as usize) - .ok_or(MacosCaptureError::ArithmeticOverflow)?; - if pixels.len() != expected_len { - return Err(MacosCaptureError::FixturePixelLength { - expected: expected_len, - actual: pixels.len(), + format: MacosCapturePixelFormat, + color: MacosCaptureColorimetry, + planes: &[&[u8]], + ) -> Result<(Self, Vec), MacosCaptureError> { + color.validate_for(format)?; + let expected = format.plane_layout(extent); + if planes.len() != expected.len() { + return Err(MacosCaptureError::FixturePlaneCount { + expected: expected.len(), + actual: planes.len(), }); } + for (index, (source, (plane_extent, bytes_per_pixel))) in + planes.iter().zip(&expected).enumerate() + { + let expected_len = usize::try_from(plane_extent.width) + .ok() + .and_then(|width| width.checked_mul(usize::try_from(*bytes_per_pixel).ok()?)) + .and_then(|row| row.checked_mul(usize::try_from(plane_extent.height).ok()?)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if source.len() != expected_len { + return Err(MacosCaptureError::FixturePlaneLength { + plane: u32::try_from(index) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + expected: expected_len, + actual: source.len(), + }); + } + } let empty = CFDictionary::::from_slices(&[], &[]); // SAFETY: this is a framework-provided constant CFString reference. @@ -298,7 +317,7 @@ impl MacosCaptureSurface { None, extent.width as usize, extent.height as usize, - BGRA8, + format.fourcc(color.range)?, Some(attributes.as_opaque()), std::ptr::NonNull::from(&mut raw_pixel_buffer), ) @@ -310,37 +329,116 @@ impl MacosCaptureSurface { .ok_or(MacosCaptureError::PixelBufferFixtureCreateFailed(code))?; // SAFETY: a successful create call returned ownership at +1. let pixel_buffer = unsafe { CFRetained::from_raw(raw_pixel_buffer) }; + let native_plane_count = CVPixelBufferGetPlaneCount(&pixel_buffer); + let expected_native_plane_count = if expected.len() == 1 { + 0 + } else { + expected.len() + }; + if native_plane_count != expected_native_plane_count { + return Err(MacosCaptureError::FixturePlaneCount { + expected: expected_native_plane_count, + actual: native_plane_count, + }); + } let lock = PixelBufferWriteLock::acquire(&pixel_buffer)?; - let bytes_per_row = CVPixelBufferGetBytesPerRow(&pixel_buffer); - let base_address = CVPixelBufferGetBaseAddress(&pixel_buffer).cast::(); - if base_address.is_null() || bytes_per_row < packed_stride { - return Err(MacosCaptureError::MissingCpuPlaneAddress(0)); - } - for (row_index, source) in pixels.chunks_exact(packed_stride).enumerate() { - // SAFETY: the pixel buffer is locked, each destination row has at - // least packed_stride bytes, and source rows have that exact size. - unsafe { - std::ptr::copy_nonoverlapping( - source.as_ptr(), - base_address.add(row_index * bytes_per_row), - packed_stride, - ); + let mut descriptors = Vec::new(); + descriptors + .try_reserve_exact(expected.len()) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + for (index, (source, (plane_extent, bytes_per_pixel))) in + planes.iter().zip(expected).enumerate() + { + let (base_address, bytes_per_row, native_extent) = if native_plane_count == 0 { + ( + CVPixelBufferGetBaseAddress(&pixel_buffer).cast::(), + CVPixelBufferGetBytesPerRow(&pixel_buffer), + extent, + ) + } else { + ( + CVPixelBufferGetBaseAddressOfPlane(&pixel_buffer, index).cast::(), + CVPixelBufferGetBytesPerRowOfPlane(&pixel_buffer, index), + MacosPixelExtent { + width: u32::try_from(CVPixelBufferGetWidthOfPlane(&pixel_buffer, index)) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + height: u32::try_from(CVPixelBufferGetHeightOfPlane(&pixel_buffer, index)) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + }, + ) + }; + if base_address.is_null() || native_extent != plane_extent { + return Err(MacosCaptureError::InvalidPlaneExtent { + plane: u32::try_from(index) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + expected: plane_extent, + actual: native_extent, + }); + } + let packed_row_bytes = usize::try_from(plane_extent.width) + .ok() + .and_then(|width| width.checked_mul(usize::try_from(bytes_per_pixel).ok()?)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if bytes_per_row < packed_row_bytes { + return Err(MacosCaptureError::StrideTooSmall { + plane: u32::try_from(index) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + minimum: u64::try_from(packed_row_bytes) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + actual: u64::try_from(bytes_per_row) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + }); } + for (row_index, source_row) in source.chunks_exact(packed_row_bytes).enumerate() { + // SAFETY: the pixel buffer is write-locked, the validated native + // stride contains each packed row, and source rows are exact. + unsafe { + std::ptr::copy_nonoverlapping( + source_row.as_ptr(), + base_address.add(row_index * bytes_per_row), + packed_row_bytes, + ); + } + } + let length_bytes = u64::try_from(bytes_per_row) + .ok() + .and_then(|stride| stride.checked_mul(u64::from(plane_extent.height))) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + descriptors.push(MacosCapturePlane { + index: u32::try_from(index).map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + extent: plane_extent, + bytes_per_row, + length_bytes, + }); } lock.unlock()?; - let length_bytes = u64::try_from(CVPixelBufferGetDataSize(&pixel_buffer)) - .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; let surface = Self::from_pixel_buffer_with_delivery_metadata(pixel_buffer, None, None)?; - Ok(( - surface, - MacosCapturePlane { - index: 0, - extent, - bytes_per_row, - length_bytes, + Ok((surface, descriptors)) + } + + /// Creates an IOSurface-backed packed BGRA fixture and its exact plane. + #[cfg(all(target_os = "macos", feature = "capture-fixtures"))] + pub fn new_native_bgra_fixture( + extent: MacosPixelExtent, + pixels: &[u8], + ) -> Result<(Self, MacosCapturePlane), MacosCaptureError> { + let (surface, mut planes) = Self::new_native_fixture( + extent, + MacosCapturePixelFormat::Bgra8, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, }, - )) + &[pixels], + )?; + let plane = planes + .pop() + .expect("validated packed BGRA fixture contains one plane"); + Ok((surface, plane)) } #[cfg(feature = "capture-fixtures")] @@ -1060,8 +1158,14 @@ pub enum MacosCaptureError { MissingIoSurface, #[error("capture surface has no native pixel buffer")] NativeSurfaceUnavailable, - #[error("native BGRA fixture expects {expected} bytes, got {actual}")] - FixturePixelLength { expected: usize, actual: usize }, + #[error("native fixture expects {expected} planes, got {actual}")] + FixturePlaneCount { expected: usize, actual: usize }, + #[error("native fixture plane {plane} expects {expected} bytes, got {actual}")] + FixturePlaneLength { + plane: u32, + expected: usize, + actual: usize, + }, #[error("Core Video fixture pixel-buffer creation failed with code {0}")] PixelBufferFixtureCreateFailed(i32), #[error("ScreenCaptureKit filter retention failed")] @@ -1088,10 +1192,14 @@ pub enum MacosCaptureError { PixelBufferUnlockFailed(i32), #[error("Core Video returned no base address for plane {0}")] MissingCpuPlaneAddress(usize), - #[error("CPU publication requires BGRA8 input, got {0:?}")] + #[error("exact BGRA copy requires BGRA8 input, got {0:?}")] UnsupportedCpuPixelFormat(MacosCapturePixelFormat), - #[error("CPU SDR publication does not support {0:?} transfer")] - UnsupportedCpuTransferFunction(MacosTransferFunction), + #[error("CPU pixel ({x}, {y}) is outside storage extent {extent:?}")] + CpuPixelOutsideStorage { + x: u32, + y: u32, + extent: MacosPixelExtent, + }, #[error("CPU destination stride {actual} is smaller than {minimum}")] InvalidCpuDestinationStride { minimum: usize, actual: usize }, #[error("CPU destination has {actual} bytes, but {required} are required")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 6956cf6d2..54d6ab55f 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -19,6 +19,7 @@ mod worker; pub use native::MacosScreenCaptureSession; pub use clock::{MacosDisplayClock, MacosDisplayClockError}; +pub use cpu::MacosCpuSourceView; pub use diagnostics::{MacosCaptureCallbackDiagnostics, MacosFrameDropReason}; #[cfg(target_os = "macos")] pub use frame::MacosNativeSurfaceLease; diff --git a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs index 0a67875a9..7e76dada9 100644 --- a/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -775,43 +775,278 @@ fn cpu_copy_rejects_non_bgra_input_without_mapping_it() { } #[test] -fn bgra_sdr_conversion_swizzles_channels_and_preserves_alpha() { - let frame = bgra_cpu_frame([30, 20, 10, 127], rgb_color()); - let mut destination = vec![0; 192]; - frame - .convert_bgra8_sdr_to_rgba8(&mut destination, 32) - .expect("sRGB BGRA should convert"); - for pixel in destination.chunks_exact(4) { - assert_eq!(pixel, &[10, 20, 30, 127]); +fn scalar_oracle_decodes_bgra_l10r_and_rgha_without_early_quantization() { + let bgra = cpu_frame_from_planes( + pixel_extent(1, 1), + BGRA8, + rgb_color(), + vec![( + pixel_extent(1, 1), + 7, + vec![30, 20, 10, 127, 0xcc, 0xcc, 0xcc], + )], + ); + assert_rgba_close( + decoded_pixel(&bgra, 0, 0), + [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 127.0 / 255.0], + ); + + let packed = (2_u32 << 30) | (1_023 << 20) | (512 << 10); + let l10r = cpu_frame_from_planes( + pixel_extent(1, 1), + ARGB2101010, + hdr_rgb_color(), + vec![(pixel_extent(1, 1), 9, { + let mut row = packed.to_le_bytes().to_vec(); + row.extend_from_slice(&[0xcc; 5]); + row + })], + ); + assert_rgba_close( + decoded_pixel(&l10r, 0, 0), + [1.0, 512.0 / 1_023.0, 0.0, 2.0 / 3.0], + ); + + let rgha = cpu_frame_from_planes( + pixel_extent(1, 1), + RGBA16_FLOAT, + hdr_rgb_color(), + vec![(pixel_extent(1, 1), 13, { + let mut row = Vec::new(); + for bits in [0x0001_u16, 0x3c00, 0x4000, 0x3800] { + row.extend_from_slice(&bits.to_le_bytes()); + } + row.extend_from_slice(&[0xcc; 5]); + row + })], + ); + assert_rgba_close( + decoded_pixel(&rgha, 0, 0), + [2.0_f32.powi(-24), 1.0, 2.0, 0.5], + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_fixture_constructor_materializes_every_retained_format() { + let extent = pixel_extent(4, 4); + let rgb = rgb_color(); + let linear = MacosCaptureColorimetry { + transfer: MacosTransferFunction::Linear, + ..rgb + }; + let video = yuv_color_for( + MacosColorRange::Video, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Left, + ); + let full = yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt2020, + MacosChromaLocation::TopLeft, + ); + let fixtures = [ + ( + MacosCapturePixelFormat::Bgra8, + rgb, + vec![vec![0_u8; 4 * 4 * 4]], + ), + ( + MacosCapturePixelFormat::Argb2101010, + linear, + vec![vec![0_u8; 4 * 4 * 4]], + ), + ( + MacosCapturePixelFormat::Rgba16Float, + linear, + vec![vec![0_u8; 4 * 4 * 8]], + ), + ( + MacosCapturePixelFormat::Yuv420VideoRange, + video, + vec![vec![16_u8; 4 * 4], vec![128_u8; 2 * 2 * 2]], + ), + ( + MacosCapturePixelFormat::Yuv420FullRange, + full, + vec![vec![0_u8; 4 * 4], vec![128_u8; 2 * 2 * 2]], + ), + ( + MacosCapturePixelFormat::Yuv44410BiPlanar, + full, + vec![vec![0_u8; 4 * 4 * 2], vec![0_u8; 4 * 4 * 4]], + ), + ]; + + for (format, color, planes) in fixtures { + let borrowed = planes.iter().map(Vec::as_slice).collect::>(); + let (surface, descriptors) = + MacosCaptureSurface::new_native_fixture(extent, format, color, &borrowed) + .unwrap_or_else(|error| panic!("{format:?} native fixture failed: {error}")); + assert_eq!(descriptors.len(), planes.len()); + assert!(surface.allocation_bytes > 0); + surface + .with_native_surface(|_| ()) + .expect("native fixture exposes retained IOSurface handles"); } } #[test] -fn bgra_sdr_conversion_compresses_wide_gamut_primaries() { - let mut color = rgb_color(); - color.primaries = MacosColorPrimaries::DisplayP3; - let frame = bgra_cpu_frame([0, 0, 255, 255], color); - let mut destination = vec![0; 192]; - frame - .convert_bgra8_sdr_to_rgba8(&mut destination, 32) - .expect("Display P3 red should convert"); - assert_eq!(destination[0], 255); - assert_eq!(destination[1], 0); - assert!(destination[2] < 64); - assert_eq!(destination[3], 255); +fn scalar_oracle_distinguishes_yuv_video_and_full_range_extrema() { + let extent = pixel_extent(2, 2); + let chroma = pixel_extent(1, 1); + let video = cpu_frame_from_planes( + extent, + YUV420_VIDEO_RANGE, + yuv_color_for( + MacosColorRange::Video, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Left, + ), + vec![ + (extent, 4, vec![16, 235, 0xcc, 0xcc, 16, 235, 0xcc, 0xcc]), + (chroma, 4, vec![128, 128, 0xcc, 0xcc]), + ], + ); + assert_rgba_close(decoded_pixel(&video, 0, 0), [0.0, 0.0, 0.0, 1.0]); + assert_rgba_close(decoded_pixel(&video, 1, 0), [1.0, 1.0, 1.0, 1.0]); + + let full = cpu_frame_from_planes( + extent, + YUV420_FULL_RANGE, + yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Left, + ), + vec![ + ( + extent, + 5, + vec![0, 255, 0xcc, 0xcc, 0xcc, 0, 255, 0xcc, 0xcc, 0xcc], + ), + (chroma, 5, vec![128, 128, 0xcc, 0xcc, 0xcc]), + ], + ); + let full_black = decoded_pixel(&full, 0, 0); + assert_rgba_close(full_black, [0.0, 0.0, 0.0, 1.0]); + let full_white = decoded_pixel(&full, 1, 0); + assert_rgba_close(full_white, [1.0, 1.0, 1.0, 1.0]); +} + +#[test] +fn yuv420_oracle_honors_chroma_siting_for_odd_extents_and_hostile_strides() { + let extent = pixel_extent(3, 3); + let chroma = pixel_extent(2, 2); + let planes = || { + vec![ + ( + extent, + 5, + vec![ + 128, 128, 128, 0xcc, 0xcc, 128, 128, 128, 0xcc, 0xcc, 128, 128, 128, 0xcc, 0xcc, + ], + ), + ( + chroma, + 7, + vec![ + 16, 128, 240, 128, 0xcc, 0xcc, 0xcc, 240, 128, 240, 128, 0xcc, 0xcc, 0xcc, + ], + ), + ] + }; + let left = cpu_frame_from_planes( + extent, + YUV420_FULL_RANGE, + yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Left, + ), + planes(), + ); + let center = cpu_frame_from_planes( + extent, + YUV420_FULL_RANGE, + yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Center, + ), + planes(), + ); + let top_left = cpu_frame_from_planes( + extent, + YUV420_FULL_RANGE, + yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt709, + MacosChromaLocation::TopLeft, + ), + planes(), + ); + let left_middle = decoded_pixel(&left, 1, 0); + let center_middle = decoded_pixel(¢er, 1, 0); + assert!(left_middle[2] > center_middle[2]); + assert!((left_middle[2] - 128.0 / 255.0).abs() < 0.02); + assert!((center_middle[2] - 0.0945).abs() < 0.002); + assert!((decoded_pixel(&left, 0, 1)[2] - 0.0945).abs() < 0.002); + assert!((decoded_pixel(&top_left, 0, 1)[2] - 128.0 / 255.0).abs() < 0.02); + assert!(decoded_pixel(&left, 2, 2)[2] > 1.2); +} + +#[test] +fn xf44_oracle_reads_msb_aligned_10_bit_full_range() { + let extent = pixel_extent(2, 1); + let pack = |value: u16| (value << 6).to_le_bytes(); + let mut luma = Vec::new(); + luma.extend_from_slice(&pack(0)); + luma.extend_from_slice(&pack(1_023)); + luma.extend_from_slice(&[0xcc; 4]); + let mut chroma = Vec::new(); + for _ in 0..2 { + chroma.extend_from_slice(&pack(512)); + chroma.extend_from_slice(&pack(512)); + } + chroma.extend_from_slice(&[0xcc; 4]); + let frame = cpu_frame_from_planes( + extent, + YUV44410_FULL_RANGE, + yuv_color_for( + MacosColorRange::Full, + MacosYuvMatrix::Bt2020, + MacosChromaLocation::TopLeft, + ), + vec![(extent, 8, luma), (extent, 12, chroma)], + ); + let black = decoded_pixel(&frame, 0, 0); + let white = decoded_pixel(&frame, 1, 0); + assert!(black[0].abs() < 0.002 && black[2].abs() < 0.002); + assert!((white[1] - 1.0).abs() < 0.002); } #[test] -fn bgra_sdr_conversion_rejects_hdr_transfer_functions() { - let mut color = rgb_color(); - color.transfer = MacosTransferFunction::Pq; - let frame = bgra_cpu_frame([0, 0, 255, 255], color); +fn rgba32f_copy_preserves_padding_and_malformed_planes_fail_before_writes() { + let frame = bgra_cpu_frame([30, 20, 10, 127], rgb_color()); + let mut destination = vec![0xcc; 136 * 6]; + frame + .copy_source_rgba32f_to(&mut destination, 136) + .expect("validated BGRA should decode to RGBA32Float"); + assert_rgba_close( + read_rgba32f(&destination[..16]), + [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 127.0 / 255.0], + ); + assert_eq!(&destination[128..136], &[0xcc; 8]); + + let mut malformed = frame; + Arc::make_mut(&mut malformed.planes)[0].bytes_per_row = 1; + let mut untouched = [0x5a; 768]; assert_eq!( - frame.convert_bgra8_sdr_to_rgba8(&mut [0; 192], 32), - Err(MacosCaptureError::UnsupportedCpuTransferFunction( - MacosTransferFunction::Pq - )) + malformed.copy_source_rgba32f_to(&mut untouched, 128), + Err(MacosCaptureError::CpuPlaneLayoutMismatch) ); + assert_eq!(untouched, [0x5a; 768]); } #[test] @@ -943,6 +1178,88 @@ fn bgra_cpu_frame( decode_frame(&mut MacosFrameDecoder::new(1), sample) } +fn cpu_frame_from_planes( + extent: MacosPixelExtent, + fourcc: u32, + color: MacosCaptureColorimetry, + planes: Vec<(MacosPixelExtent, usize, Vec)>, +) -> hypercolor_macos_capture::MacosCaptureFrame { + let descriptors = planes + .iter() + .enumerate() + .map(|(index, (extent, stride, bytes))| MacosRawCapturePlane { + index: u32::try_from(index).expect("fixture plane index fits"), + extent: *extent, + bytes_per_row: *stride, + length_bytes: u64::try_from(bytes.len()).expect("fixture plane length fits"), + }) + .collect::>(); + let allocation_bytes = descriptors.iter().map(|plane| plane.length_bytes).sum(); + let surface = MacosCaptureSurface::new_cpu_fixture( + 7, + allocation_bytes, + 99, + planes + .into_iter() + .map(|(_, _, bytes)| Arc::<[u8]>::from(bytes)) + .collect(), + ) + .expect("CPU fixture surface should be valid"); + let sample = MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: extent, + planes: descriptors, + pixel_format_fourcc: fourcc, + color, + cursor_composed: false, + surface, + }), + attachments: MacosRawFrameAttachments { + status: MacosAttachment::Value(0), + display_time: MacosAttachment::Value(1), + display_scale_factor: MacosAttachment::Value(1.0), + content_scale: MacosAttachment::Value(1.0), + content_rect: MacosAttachment::Value(point_rect( + 0.0, + 0.0, + f64::from(extent.width), + f64::from(extent.height), + )), + dirty_rects: MacosAttachment::Missing, + screen_rect: MacosAttachment::Missing, + bounding_rect: MacosAttachment::Missing, + }, + }; + decode_frame(&mut MacosFrameDecoder::new(1), sample) +} + +fn decoded_pixel(frame: &hypercolor_macos_capture::MacosCaptureFrame, x: u32, y: u32) -> [f32; 4] { + frame + .with_cpu_source(|source| source.sample_rgba32f(x, y)) + .expect("CPU source should map") + .expect("fixture pixel should decode") +} + +fn read_rgba32f(bytes: &[u8]) -> [f32; 4] { + std::array::from_fn(|channel| { + let start = channel * 4; + f32::from_le_bytes( + bytes[start..start + 4] + .try_into() + .expect("RGBA32Float channel has four bytes"), + ) + }) +} + +fn assert_rgba_close(actual: [f32; 4], expected: [f32; 4]) { + for (channel, (actual, expected)) in actual.into_iter().zip(expected).enumerate() { + assert!( + (actual - expected).abs() <= 1.0e-6, + "channel {channel}: expected {expected}, got {actual}" + ); + } +} + fn complete_frame_mut(sample: &mut MacosRawCaptureSample) -> &mut MacosRawCompleteFrame { sample.frame.as_mut().expect("fixture frame should exist") } @@ -1035,6 +1352,20 @@ fn yuv_color(range: MacosColorRange) -> MacosCaptureColorimetry { } } +fn yuv_color_for( + range: MacosColorRange, + matrix: MacosYuvMatrix, + chroma_location: MacosChromaLocation, +) -> MacosCaptureColorimetry { + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(matrix), + range, + chroma_location: Some(chroma_location), + } +} + fn hdr_rgb_color() -> MacosCaptureColorimetry { MacosCaptureColorimetry { primaries: MacosColorPrimaries::DisplayP3, From 7d0932a170129dd296a088b656390ba73a18bfb1 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 03:36:08 -0700 Subject: [PATCH 073/144] feat(screen): account shared native targets exactly Split native target retention into branch-exclusive and plan-shared resources so equal physical reductions charge one backing allocation. Bind every publication to the exact plan generation, descriptor, target, and worker admission lifetimes before renderer preparation or delivery. Co-Authored-By: Nova (GPT-5 Codex) --- .../hypercolor-core/src/input/screen/frame.rs | 15 + .../src/input/screen/ledger.rs | 147 ++++++-- .../hypercolor-core/src/input/screen/macos.rs | 13 +- .../hypercolor-core/src/input/screen/mod.rs | 5 +- .../hypercolor-core/src/input/screen/plan.rs | 69 +++- .../src/input/screen/publication.rs | 209 ++++++++++-- ...creen_gpu_publication_reclamation_tests.rs | 318 +++++++++++++++++- ...creen_native_executor_negotiation_tests.rs | 308 ++++++++++++++++- 8 files changed, 1021 insertions(+), 63 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/frame.rs b/crates/hypercolor-core/src/input/screen/frame.rs index d1e1da6ce..246641314 100644 --- a/crates/hypercolor-core/src/input/screen/frame.rs +++ b/crates/hypercolor-core/src/input/screen/frame.rs @@ -1283,6 +1283,7 @@ pub struct PlatformGpuSurface { owner: Arc, retained_owner: Option>, target_resource_lifetime: Option, + shared_target_resource_lifetime: Option, capture_resource_lifetime: Option, } @@ -1291,6 +1292,7 @@ pub struct PlatformGpuSurface { pub struct PlatformGpuSurfaceOwner { owner: Arc, _target_resource_lifetime: Option, + _shared_target_resource_lifetime: Option, _capture_resource_lifetime: Option, } @@ -1298,11 +1300,13 @@ impl PlatformGpuSurfaceOwner { fn new( owner: Arc, target_resource_lifetime: Option, + shared_target_resource_lifetime: Option, capture_resource_lifetime: Option, ) -> Self { Self { owner, _target_resource_lifetime: target_resource_lifetime, + _shared_target_resource_lifetime: shared_target_resource_lifetime, _capture_resource_lifetime: capture_resource_lifetime, } } @@ -1350,6 +1354,7 @@ impl PlatformGpuSurface { owner, retained_owner: None, target_resource_lifetime: None, + shared_target_resource_lifetime: None, capture_resource_lifetime: None, }) } @@ -1358,10 +1363,12 @@ impl PlatformGpuSurface { mut self, retained_owner: Arc, target_resource_lifetime: ScreenResourceLifetime, + shared_target_resource_lifetime: Option, capture_resource_lifetime: Option, ) -> Self { self.retained_owner = Some(retained_owner); self.target_resource_lifetime = Some(target_resource_lifetime); + self.shared_target_resource_lifetime = shared_target_resource_lifetime; self.capture_resource_lifetime = capture_resource_lifetime; self } @@ -1407,6 +1414,7 @@ impl PlatformGpuSurface { PlatformGpuSurfaceOwner::new( owner, self.target_resource_lifetime.clone(), + self.shared_target_resource_lifetime.clone(), self.capture_resource_lifetime.clone(), ) }) @@ -1425,6 +1433,7 @@ impl PlatformGpuSurface { PlatformGpuSurfaceOwner::new( owner, self.target_resource_lifetime.clone(), + self.shared_target_resource_lifetime.clone(), self.capture_resource_lifetime.clone(), ) }) @@ -1436,6 +1445,12 @@ impl PlatformGpuSurface { self.target_resource_lifetime.as_ref() } + /// Exact plan-shared native physical allocation retained by this surface. + #[must_use] + pub const fn shared_resource_lifetime(&self) -> Option<&ScreenResourceLifetime> { + self.shared_target_resource_lifetime.as_ref() + } + /// Exact capture-plan allocation lifetime retained with this GPU surface. #[must_use] pub const fn capture_resource_lifetime(&self) -> Option<&ScreenResourceLifetime> { diff --git a/crates/hypercolor-core/src/input/screen/ledger.rs b/crates/hypercolor-core/src/input/screen/ledger.rs index 9b8d81179..7ba362aa0 100644 --- a/crates/hypercolor-core/src/input/screen/ledger.rs +++ b/crates/hypercolor-core/src/input/screen/ledger.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use thiserror::Error; -use super::plan::ScreenExternalResourceAdmission; +use super::plan::{ScreenExternalResourceAdmission, ScreenNativeSharedResourceBindingKey}; use super::{ AdmittedScreenNativeTargetPreparation, ResolvedScreenPublicationDescriptor, ScreenByteReservation, ScreenExactResource, ScreenExactResourceLedger, @@ -10,6 +10,14 @@ use super::{ ScreenPreparedWorkerToken, ScreenResourceLifetime, ScreenWorkerPreparationTicket, }; +#[derive(Clone, Debug)] +struct ScreenSharedNativeResource { + binding: ScreenNativeSharedResourceBindingKey, + bytes: u64, + resource_name: Option>, + admission_lease: Option, +} + /// Ticket-scoped construction of one exhaustive exact worker ledger. #[derive(Debug)] pub struct ScreenWorkerExactLedgerBuilder { @@ -18,6 +26,7 @@ pub struct ScreenWorkerExactLedgerBuilder { additional_resources: Vec, admission_top_ups: Vec, external_admissions: Vec, + shared_native_resources: Vec, } impl ScreenWorkerExactLedgerBuilder { @@ -40,6 +49,7 @@ impl ScreenWorkerExactLedgerBuilder { additional_resources: Vec::new(), admission_top_ups: Vec::new(), external_admissions: Vec::new(), + shared_native_resources: Vec::new(), }) } @@ -73,29 +83,117 @@ impl ScreenWorkerExactLedgerBuilder { resource_name: impl Into>, accounting_scope: impl Into>, ) -> anyhow::Result { + if platform.plan_generation() != self.ticket.plan_generation() { + return Err(ScreenWorkerLedgerBuildError::NativePlanGenerationMismatch { + expected: self.ticket.plan_generation(), + observed: platform.plan_generation(), + } + .into()); + } let quote = target.quote_preparation(descriptor, platform)?; + let retention = quote.retention(); + let shared_binding = quote.shared_binding(); + let existing_shared = self + .shared_native_resources + .iter() + .find(|shared| shared.binding == shared_binding) + .map(|shared| { + if shared.bytes != retention.shared_physical_bytes() { + return Err( + ScreenWorkerLedgerBuildError::ConflictingNativeSharedRetention { + expected: shared.bytes, + observed: retention.shared_physical_bytes(), + }, + ); + } + Ok((shared.resource_name.clone(), shared.admission_lease.clone())) + }) + .transpose()?; + let records_shared = existing_shared.is_none(); + let creates_shared = records_shared && retention.shared_physical_bytes() > 0; + let resource_name = resource_name.into(); + let accounting_scope = accounting_scope.into(); self.additional_resources - .try_reserve(1) + .try_reserve(usize::from(creates_shared) + 1) .map_err(|_| ScreenWorkerLedgerBuildError::AllocationFailed)?; self.external_admissions - .try_reserve(1) + .try_reserve(usize::from(creates_shared) + 1) .map_err(|_| ScreenWorkerLedgerBuildError::AllocationFailed)?; - let reservation = self + if records_shared { + self.shared_native_resources + .try_reserve(1) + .map_err(|_| ScreenWorkerLedgerBuildError::AllocationFailed)?; + } + let exclusive_reservation = self .ticket - .reserve_additional_exact_bytes(quote.retained_bytes())?; + .reserve_additional_exact_bytes(retention.exclusive_bytes())?; + let shared_reservation = creates_shared + .then(|| { + self.ticket + .reserve_additional_exact_bytes(retention.shared_physical_bytes()) + }) + .transpose()?; let preparation = target.prepare_quoted(descriptor, platform, quote)?; - let resource = preparation.exact_resource(resource_name, accounting_scope)?; - let resource_name = Arc::clone(resource.name()); - self.report_native_target(resource)?; - let lease = reservation.freeze(); + let resource = preparation + .exact_resource(Arc::clone(&resource_name), Arc::clone(&accounting_scope))?; + self.validate_native_target_resource(&resource)?; + let new_shared_name = + creates_shared.then(|| Arc::::from(format!("{resource_name}-shared-physical"))); + let new_shared_resource = match &new_shared_name { + Some(name) => { + let resource = ScreenExactResource::try_new_native_shared_target( + Arc::clone(name), + Arc::clone(&accounting_scope), + retention.shared_physical_bytes(), + shared_binding.clone(), + )?; + self.validate_native_target_resource(&resource)?; + Some(resource) + } + None => None, + }; + self.additional_resources.push(resource); + if let Some(resource) = new_shared_resource { + self.additional_resources.push(resource); + } + let lease = exclusive_reservation.freeze(); self.external_admissions .push(ScreenExternalResourceAdmission::new( - resource_name, + Arc::clone(&resource_name), lease.clone(), )); + let (shared_resource_name, shared_lease) = if let Some((name, lease)) = existing_shared { + (name, lease) + } else if let (Some(name), Some(reservation)) = (new_shared_name, shared_reservation) { + let shared_lease = reservation.freeze(); + self.external_admissions + .push(ScreenExternalResourceAdmission::new( + Arc::clone(&name), + shared_lease.clone(), + )); + self.shared_native_resources + .push(ScreenSharedNativeResource { + binding: shared_binding, + bytes: retention.shared_physical_bytes(), + resource_name: Some(Arc::clone(&name)), + admission_lease: Some(shared_lease.clone()), + }); + (Some(name), Some(shared_lease)) + } else { + self.shared_native_resources + .push(ScreenSharedNativeResource { + binding: shared_binding, + bytes: 0, + resource_name: None, + admission_lease: None, + }); + (None, None) + }; Ok(AdmittedScreenNativeTargetPreparation::new( preparation, lease, + shared_resource_name, + shared_lease, )) } @@ -202,17 +300,11 @@ impl ScreenWorkerExactLedgerBuilder { Ok(()) } - /// Report one renderer preparation through its target-bound ledger entry. - /// - /// # Errors - /// - /// Rejects generic worker resources, unknown or non-runtime scopes, - /// repeated names, and allocation failure while retaining prior reports. - pub(crate) fn report_native_target( - &mut self, - resource: ScreenExactResource, + fn validate_native_target_resource( + &self, + resource: &ScreenExactResource, ) -> Result<(), ScreenWorkerLedgerBuildError> { - if resource.native_binding().is_none() { + if resource.native_binding().is_none() && resource.native_shared_binding().is_none() { return Err(ScreenWorkerLedgerBuildError::UnboundNativeTargetResource { name: Arc::clone(resource.name()), }); @@ -251,10 +343,6 @@ impl ScreenWorkerExactLedgerBuilder { name: Arc::clone(resource.name()), }); } - self.additional_resources - .try_reserve(1) - .map_err(|_| ScreenWorkerLedgerBuildError::AllocationFailed)?; - self.additional_resources.push(resource); Ok(()) } @@ -371,6 +459,17 @@ pub enum ScreenWorkerLedgerBuildError { minimum: u64, actual: u64, }, + /// Equal native physical work produced inconsistent shared byte quotes. + #[error( + "equal native physical work quoted conflicting shared retention: expected {expected}, observed {observed}" + )] + ConflictingNativeSharedRetention { expected: u64, observed: u64 }, + /// A native payload belongs to another candidate plan generation. + #[error("native target payload belongs to plan generation {observed:?}, expected {expected:?}")] + NativePlanGenerationMismatch { + expected: super::ScreenPlanGeneration, + observed: super::ScreenPlanGeneration, + }, /// Ticket resource construction or acknowledgement failed. #[error(transparent)] Plan(#[from] ScreenPlanError), diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 18f829a8b..2c136a104 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -1537,6 +1537,7 @@ fn prepare_macos_exact_runtime( let mut native_routes = Vec::new(); native_routes.try_reserve_exact(pending_native.len())?; for pending in pending_native { + let shared_resource_name = pending.target.shared_resource_name().cloned(); let lifetime = lifetimes .iter() .find(|lifetime| lifetime.resource().name() == &pending.resource_name) @@ -1547,9 +1548,19 @@ fn prepare_macos_exact_runtime( .find(|lifetime| lifetime.resource().name() == &pending.capture_resource_name) .cloned() .ok_or_else(|| anyhow!("macOS native capture lifetime is missing"))?; + let shared_lifetime = shared_resource_name + .as_ref() + .map(|resource_name| { + lifetimes + .iter() + .find(|lifetime| lifetime.resource().name() == resource_name) + .cloned() + .ok_or_else(|| anyhow!("macOS native shared target lifetime is missing")) + }) + .transpose()?; native_routes.push(MacosNativeRoute { descriptor: pending.descriptor, - target: pending.target.bind(lifetime)?, + target: pending.target.bind_with_shared(lifetime, shared_lifetime)?, capture_lifetime, pacer: CaptureCadence::new(pending.requested_hz.get())?.pacer(), next_publish_at: Instant::now(), diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index 11a2a5251..c707ce0e2 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -122,8 +122,9 @@ pub use publication::{ ScreenCursorCapabilities, ScreenCursorPolicy, ScreenExecutorColorCapabilities, ScreenExtentRequest, ScreenGamutMapPolicy, ScreenGridPolicy, ScreenHdrPolicy, ScreenLetterboxFill, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, - ScreenNativePreparationPayload, ScreenNativeTargetAllocation, ScreenNativeTargetBindingError, - ScreenNativeTargetPreparation, ScreenNativeTargetPreparationError, ScreenNativeTargetPreparer, + ScreenNativePreparationPayload, ScreenNativeRetentionQuote, ScreenNativeTargetAllocation, + ScreenNativeTargetBindingError, ScreenNativeTargetPreparation, + ScreenNativeTargetPreparationError, ScreenNativeTargetPreparer, ScreenNativeTargetResourceError, ScreenPhysicalGpuDeviceIdentity, ScreenPhysicalReductionDescriptor, ScreenPhysicalReductionKey, ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenProfileScalar, ScreenPublicationError, diff --git a/crates/hypercolor-core/src/input/screen/plan.rs b/crates/hypercolor-core/src/input/screen/plan.rs index 9199b645a..343322631 100644 --- a/crates/hypercolor-core/src/input/screen/plan.rs +++ b/crates/hypercolor-core/src/input/screen/plan.rs @@ -529,6 +529,7 @@ pub struct ScreenExactResource { resource: ScreenResourceKind, bytes: u64, native_binding: Option, + native_shared_binding: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -552,6 +553,10 @@ impl ScreenNativeResourceBindingKey { self.target_id } + pub(crate) const fn descriptor(&self) -> &Arc { + &self.descriptor + } + pub(crate) fn matches( &self, target_id: NonZeroU64, @@ -561,6 +566,32 @@ impl ScreenNativeResourceBindingKey { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ScreenNativeSharedResourceBindingKey { + target_id: NonZeroU64, + descriptor: Arc, +} + +impl ScreenNativeSharedResourceBindingKey { + pub(crate) fn new( + target_id: NonZeroU64, + descriptor: Arc, + ) -> Self { + Self { + target_id, + descriptor, + } + } + + pub(crate) fn matches( + &self, + target_id: NonZeroU64, + descriptor: &ScreenPhysicalReductionDescriptor, + ) -> bool { + self.target_id == target_id && self.descriptor.as_ref() == descriptor + } +} + impl ScreenExactResource { /// Construct a named exact worker allocation. /// @@ -608,6 +639,7 @@ impl ScreenExactResource { resource, bytes, native_binding: None, + native_shared_binding: None, }) } @@ -627,6 +659,22 @@ impl ScreenExactResource { Ok(resource) } + pub(crate) fn try_new_native_shared_target( + name: impl Into>, + accounting_scope: impl Into>, + bytes: u64, + native_shared_binding: ScreenNativeSharedResourceBindingKey, + ) -> Result { + let mut resource = Self::try_new_scoped( + name, + accounting_scope, + ScreenResourceKind::WorkerAdditional, + bytes, + )?; + resource.native_shared_binding = Some(native_shared_binding); + Ok(resource) + } + /// Opaque worker resource name. #[must_use] pub const fn name(&self) -> &Arc { @@ -654,6 +702,12 @@ impl ScreenExactResource { pub(crate) const fn native_binding(&self) -> Option<&ScreenNativeResourceBindingKey> { self.native_binding.as_ref() } + + pub(crate) const fn native_shared_binding( + &self, + ) -> Option<&ScreenNativeSharedResourceBindingKey> { + self.native_shared_binding.as_ref() + } } #[derive(Debug)] @@ -740,6 +794,17 @@ impl ScreenResourceLifetime { .is_some_and(|binding| binding.matches(target_id, descriptor)) } + pub(crate) fn matches_native_shared_target( + &self, + target_id: NonZeroU64, + descriptor: &ScreenPhysicalReductionDescriptor, + ) -> bool { + self.inner + .resource + .native_shared_binding() + .is_some_and(|binding| binding.matches(target_id, descriptor)) + } + pub(crate) fn is_final_owner(&self) -> bool { Arc::strong_count(&self.inner) == 1 } @@ -1272,7 +1337,9 @@ impl ScreenWorkerPreparationTicket { .ok() .map(|index| &exact_ledger.resources()[index]); if resource.is_none_or(|resource| { - resource.native_binding().is_none() || resource.bytes() != claim.lease.bytes() + (resource.native_binding().is_none() + && resource.native_shared_binding().is_none()) + || resource.bytes() != claim.lease.bytes() }) { return Err(ScreenPlanError::ExternalAdmissionMismatch { name: Arc::clone(&claim.resource_name), diff --git a/crates/hypercolor-core/src/input/screen/publication.rs b/crates/hypercolor-core/src/input/screen/publication.rs index bb65e0de4..9bfa909f6 100644 --- a/crates/hypercolor-core/src/input/screen/publication.rs +++ b/crates/hypercolor-core/src/input/screen/publication.rs @@ -9,7 +9,7 @@ use std::time::Duration; use thiserror::Error; -use super::plan::ScreenNativeResourceBindingKey; +use super::plan::{ScreenNativeResourceBindingKey, ScreenNativeSharedResourceBindingKey}; use super::tone_map::{LED_TONE_MAP_ALGORITHM_REVISION, LedToneMapCalibration}; use super::{ CaptureColorSpace, CaptureColorimetry, CaptureColorimetryError, CaptureDynamicRange, @@ -550,6 +550,45 @@ pub struct ScreenNativeTargetAllocation { lifetime: ScreenResourceLifetime, } +/// Renderer retention split between branch-exclusive and shared physical storage. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ScreenNativeRetentionQuote { + exclusive_bytes: u64, + shared_physical_bytes: u64, +} + +impl ScreenNativeRetentionQuote { + /// Quote bytes owned only by one logical native branch. + #[must_use] + pub const fn exclusive(exclusive_bytes: u64) -> Self { + Self { + exclusive_bytes, + shared_physical_bytes: 0, + } + } + + /// Quote one branch allocation plus physical storage shared by equal work. + #[must_use] + pub const fn split(exclusive_bytes: u64, shared_physical_bytes: u64) -> Self { + Self { + exclusive_bytes, + shared_physical_bytes, + } + } + + /// Bytes retained only by this branch. + #[must_use] + pub const fn exclusive_bytes(self) -> u64 { + self.exclusive_bytes + } + + /// Physical bytes shared by equal descriptors in this candidate plan. + #[must_use] + pub const fn shared_physical_bytes(self) -> u64 { + self.shared_physical_bytes + } +} + impl ScreenNativeTargetAllocation { fn new(retained_bytes: u64, lifetime: ScreenResourceLifetime) -> Self { Self { @@ -576,24 +615,42 @@ impl ScreenNativeTargetAllocation { pub struct ScreenNativeTargetPreparation { binding: Option, platform: ScreenNativePreparationPayload, - retained_bytes: u64, + retention: ScreenNativeRetentionQuote, } impl ScreenNativeTargetPreparation { /// Pair renderer-specific prepared data with its exact retained byte count. #[must_use] pub fn new(platform: ScreenNativePreparationPayload, retained_bytes: u64) -> Self { + Self::with_retention( + platform, + ScreenNativeRetentionQuote::exclusive(retained_bytes), + ) + } + + /// Pair renderer-specific data with exclusive and shared retention. + #[must_use] + pub fn with_retention( + platform: ScreenNativePreparationPayload, + retention: ScreenNativeRetentionQuote, + ) -> Self { Self { binding: None, platform, - retained_bytes, + retention, } } /// Renderer bytes that must be reported before binding this preparation. #[must_use] pub const fn retained_bytes(&self) -> u64 { - self.retained_bytes + self.retention.exclusive_bytes + } + + /// Exact split between branch-exclusive and shared physical retention. + #[must_use] + pub const fn retention(&self) -> ScreenNativeRetentionQuote { + self.retention } /// Construct the exact ledger entry that may bind this preparation. @@ -614,7 +671,7 @@ impl ScreenNativeTargetPreparation { Ok(ScreenExactResource::try_new_native_target( name, accounting_scope, - self.retained_bytes, + self.retention.exclusive_bytes, binding, )?) } @@ -628,6 +685,14 @@ impl ScreenNativeTargetPreparation { pub fn bind( self, lifetime: ScreenResourceLifetime, + ) -> Result { + self.bind_with_shared(lifetime, None) + } + + fn bind_with_shared( + self, + lifetime: ScreenResourceLifetime, + shared_lifetime: Option, ) -> Result { let binding = self .binding @@ -635,8 +700,9 @@ impl ScreenNativeTargetPreparation { BoundScreenNativeTargetPreparation::try_new( binding, self.platform, - self.retained_bytes, + self.retention, lifetime, + shared_lifetime, ) } } @@ -646,16 +712,22 @@ impl ScreenNativeTargetPreparation { pub struct AdmittedScreenNativeTargetPreparation { preparation: ScreenNativeTargetPreparation, admission_lease: ScreenByteLease, + shared_resource_name: Option>, + shared_admission_lease: Option, } impl AdmittedScreenNativeTargetPreparation { pub(crate) fn new( preparation: ScreenNativeTargetPreparation, admission_lease: ScreenByteLease, + shared_resource_name: Option>, + shared_admission_lease: Option, ) -> Self { Self { preparation, admission_lease, + shared_resource_name, + shared_admission_lease, } } @@ -665,6 +737,12 @@ impl AdmittedScreenNativeTargetPreparation { self.preparation.retained_bytes() } + /// Exact shared physical resource name, when this branch uses one. + #[must_use] + pub const fn shared_resource_name(&self) -> Option<&Arc> { + self.shared_resource_name.as_ref() + } + /// Bind after the exact ledger installs this preparation's byte lease. /// /// # Errors @@ -673,11 +751,29 @@ impl AdmittedScreenNativeTargetPreparation { pub fn bind( self, lifetime: ScreenResourceLifetime, + ) -> Result { + self.bind_with_shared(lifetime, None) + } + + /// Bind branch-exclusive and optional plan-shared physical lifetimes. + /// + /// # Errors + /// + /// Rejects missing, substituted, or mismatched admission lifetimes. + pub fn bind_with_shared( + self, + lifetime: ScreenResourceLifetime, + shared_lifetime: Option, ) -> Result { if !lifetime.has_admission_lease(&self.admission_lease) { return Err(ScreenNativeTargetBindingError::AdmissionLeaseMismatch); } - self.preparation.bind(lifetime) + match (&self.shared_admission_lease, &shared_lifetime) { + (Some(lease), Some(lifetime)) if lifetime.has_admission_lease(lease) => {} + (None, None) => {} + _ => return Err(ScreenNativeTargetBindingError::SharedAdmissionLeaseMismatch), + } + self.preparation.bind_with_shared(lifetime, shared_lifetime) } } @@ -687,14 +783,16 @@ pub struct BoundScreenNativeTargetPreparation { target_id: ScreenNativeExecutionTargetId, platform: ScreenNativePreparationPayload, allocation: ScreenNativeTargetAllocation, + shared_physical_allocation: Option, } impl BoundScreenNativeTargetPreparation { fn try_new( binding: ScreenNativeResourceBindingKey, platform: ScreenNativePreparationPayload, - retained_bytes: u64, + retention: ScreenNativeRetentionQuote, lifetime: ScreenResourceLifetime, + shared_lifetime: Option, ) -> Result { let resource = lifetime.resource(); if resource.resource() != ScreenResourceKind::WorkerAdditional { @@ -702,9 +800,9 @@ impl BoundScreenNativeTargetPreparation { observed: resource.resource(), }); } - if resource.bytes() != retained_bytes { + if resource.bytes() != retention.exclusive_bytes { return Err(ScreenNativeTargetBindingError::RetainedBytesMismatch { - expected: retained_bytes, + expected: retention.exclusive_bytes, observed: resource.bytes(), }); } @@ -714,10 +812,31 @@ impl BoundScreenNativeTargetPreparation { if lifetime.plan_generation() != platform.plan_generation() { return Err(ScreenNativeTargetBindingError::PlanGenerationMismatch); } + let shared_physical_allocation = match (retention.shared_physical_bytes, shared_lifetime) { + (0, None) => None, + (0, Some(_)) | (_, None) => { + return Err(ScreenNativeTargetBindingError::SharedLifetimeMismatch); + } + (expected, Some(shared)) => { + let shared_resource = shared.resource(); + if shared_resource.resource() != ScreenResourceKind::WorkerAdditional + || shared_resource.bytes() != expected + || !shared.belongs_to_same_worker(&lifetime) + || !shared.matches_native_shared_target( + binding.target_id(), + binding.descriptor().physical(), + ) + { + return Err(ScreenNativeTargetBindingError::SharedLifetimeMismatch); + } + Some(ScreenNativeTargetAllocation::new(expected, shared)) + } + }; Ok(Self { target_id: ScreenNativeExecutionTargetId::new(binding.target_id()), platform, - allocation: ScreenNativeTargetAllocation::new(retained_bytes, lifetime), + allocation: ScreenNativeTargetAllocation::new(retention.exclusive_bytes, lifetime), + shared_physical_allocation, }) } @@ -733,12 +852,21 @@ impl BoundScreenNativeTargetPreparation { &self.allocation } + /// Plan-scoped physical allocation shared by equal native branches. + #[must_use] + pub const fn shared_physical_allocation(&self) -> Option<&ScreenNativeTargetAllocation> { + self.shared_physical_allocation.as_ref() + } + /// Attach platform access and exact accounting lifetime to one surface. #[must_use] pub fn retain_on_surface(&self, surface: PlatformGpuSurface) -> PlatformGpuSurface { surface.with_native_target_owners( Arc::clone(&self.platform.inner), self.allocation.lifetime.clone(), + self.shared_physical_allocation + .as_ref() + .map(|allocation| allocation.lifetime.clone()), None, ) } @@ -763,6 +891,9 @@ impl BoundScreenNativeTargetPreparation { Ok(surface.with_native_target_owners( Arc::clone(&self.platform.inner), self.allocation.lifetime.clone(), + self.shared_physical_allocation + .as_ref() + .map(|allocation| allocation.lifetime.clone()), Some(capture_lifetime), )) } @@ -785,6 +916,9 @@ pub enum ScreenNativeTargetBindingError { /// The exact ledger has not installed this preparation's dedicated lease. #[error("native target allocation is not bound to its admitted byte lease")] AdmissionLeaseMismatch, + /// The plan-shared allocation is not bound to its admitted byte lease. + #[error("native shared allocation is not bound to its admitted byte lease")] + SharedAdmissionLeaseMismatch, /// Only a live execution target can stamp preparation identity. #[error("native target preparation is missing execution-target identity")] TargetIdentityMissing, @@ -803,6 +937,9 @@ pub enum ScreenNativeTargetBindingError { /// The target payload belongs to another candidate plan generation. #[error("native target preparation belongs to another candidate plan generation")] PlanGenerationMismatch, + /// The shared physical lifetime is absent, substituted, or mismatched. + #[error("native shared physical allocation lifetime is missing or mismatched")] + SharedLifetimeMismatch, } /// Failure to dispatch a resolved descriptor to a native target. @@ -826,6 +963,9 @@ pub enum ScreenNativeTargetPreparationError { /// The renderer retained a different byte count than it quoted. #[error("native target retained {actual} bytes after quoting {quoted}")] PreparedRetainedBytesMismatch { quoted: u64, actual: u64 }, + /// The renderer retained a different shared byte count than it quoted. + #[error("native target retained {actual} shared bytes after quoting {quoted}")] + PreparedSharedRetainedBytesMismatch { quoted: u64, actual: u64 }, } /// Live renderer capability that prepares one exact source-native branch. @@ -842,6 +982,19 @@ pub trait ScreenNativeTargetPreparer: Send + Sync { platform: &ScreenNativePreparationPayload, ) -> anyhow::Result; + /// Quote branch-exclusive and plan-shared physical retention. + /// + /// The default preserves existing targets as fully exclusive. Targets + /// that reuse equal physical work override this method with a split quote. + fn quote_retention( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + self.quote_retained_bytes(descriptor, platform) + .map(ScreenNativeRetentionQuote::exclusive) + } + /// Prepare renderer-owned resources without changing active delivery. /// /// # Errors @@ -861,14 +1014,19 @@ pub(super) struct ScreenNativeTargetPreparationQuote { target_id: ScreenNativeExecutionTargetId, descriptor: ResolvedScreenPublicationDescriptor, plan_generation: ScreenPlanGeneration, - retained_bytes: u64, + retention: ScreenNativeRetentionQuote, } impl ScreenNativeTargetPreparationQuote { - /// Renderer bytes admitted before target preparation begins. - #[must_use] - pub const fn retained_bytes(&self) -> u64 { - self.retained_bytes + pub(super) const fn retention(&self) -> ScreenNativeRetentionQuote { + self.retention + } + + pub(super) fn shared_binding(&self) -> ScreenNativeSharedResourceBindingKey { + ScreenNativeSharedResourceBindingKey::new( + self.target_id.get(), + Arc::new(self.descriptor.physical().clone()), + ) } } @@ -975,12 +1133,12 @@ impl ScreenNativeExecutionTarget { platform: &ScreenNativePreparationPayload, ) -> anyhow::Result { self.validate_preparation_request(descriptor, platform)?; - let retained_bytes = self.preparer.quote_retained_bytes(descriptor, platform)?; + let retention = self.preparer.quote_retention(descriptor, platform)?; Ok(ScreenNativeTargetPreparationQuote { target_id: self.id, descriptor: descriptor.clone(), plan_generation: platform.plan_generation(), - retained_bytes, + retention, }) } @@ -1004,11 +1162,20 @@ impl ScreenNativeExecutionTarget { return Err(ScreenNativeTargetPreparationError::QuoteMismatch.into()); } let mut preparation = self.preparer.prepare(descriptor, platform)?; - if preparation.retained_bytes != quote.retained_bytes { + if preparation.retention.exclusive_bytes != quote.retention.exclusive_bytes { return Err( ScreenNativeTargetPreparationError::PreparedRetainedBytesMismatch { - quoted: quote.retained_bytes, - actual: preparation.retained_bytes, + quoted: quote.retention.exclusive_bytes, + actual: preparation.retention.exclusive_bytes, + } + .into(), + ); + } + if preparation.retention.shared_physical_bytes != quote.retention.shared_physical_bytes { + return Err( + ScreenNativeTargetPreparationError::PreparedSharedRetainedBytesMismatch { + quoted: quote.retention.shared_physical_bytes, + actual: preparation.retention.shared_physical_bytes, } .into(), ); diff --git a/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs b/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs index ca74584e5..9e1020a16 100644 --- a/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs +++ b/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs @@ -1,4 +1,5 @@ use std::num::{NonZeroU32, NonZeroU64}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex, Weak, mpsc}; use std::thread; use std::time::{Duration, Instant}; @@ -14,14 +15,16 @@ use hypercolor_core::input::screen::{ ScreenCursorCapabilities, ScreenExactResource, ScreenExecutorColorCapabilities, ScreenExtentRequest, ScreenGpuSurfacePayload, ScreenInputGraphGeneration, ScreenLiveBranchReceipt, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, - ScreenNativePreparationPayload, ScreenNativeTargetBindingError, ScreenNativeTargetPreparation, - ScreenNativeTargetResourceError, ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, - ScreenPlanBuilder, ScreenProcessingProfile, ScreenPublicationColorimetry, + ScreenNativePreparationPayload, ScreenNativeRetentionQuote, ScreenNativeTargetBindingError, + ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenNativeTargetResourceError, + ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, + ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenPublicationColorimetry, ScreenPublicationExecutor, ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationKind, ScreenPublicationMetadata, ScreenPublicationRequest, ScreenPublicationSlotPolicy, - ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, - ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerExactLedgerBuilder, SourceScale, + ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSceneCutPolicy, + ScreenSmoothingPolicy, ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, + ScreenWorkerExactLedgerBuilder, ScreenWorkerLedgerBuildError, SourceScale, }; #[path = "support/native_target.rs"] @@ -114,6 +117,20 @@ fn demand_for_target_extent( source: &ResolvedScreenSource, target: ScreenNativeExecutionTarget, requested_extent: ScreenExtentRequest, +) -> ResolvedScreenBranchDemand { + demand_for_target_profile_extent( + source, + target, + requested_extent, + Arc::new(ScreenProcessingProfile::default()), + ) +} + +fn demand_for_target_profile_extent( + source: &ResolvedScreenSource, + target: ScreenNativeExecutionTarget, + requested_extent: ScreenExtentRequest, + profile: Arc, ) -> ResolvedScreenBranchDemand { let registered = RegisteredScreenBranchDemand::new( ScreenPublicationRequest::new( @@ -122,7 +139,7 @@ fn demand_for_target_extent( ScreenPublicationExecutorRequest::SourceNative(target), requested_extent, ScreenAspectPolicy::Contain, - Arc::new(ScreenProcessingProfile::default()), + profile, ), non_zero(60), ); @@ -673,6 +690,295 @@ fn abandoned_and_rejected_gpu_staging_releases_native_owners() { #[derive(Debug)] struct RendererTargetPayload; +struct SharedTargetPreparer { + exclusive_bytes: u64, + shared_bytes: u64, +} + +impl ScreenNativeTargetPreparer for SharedTargetPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(self.exclusive_bytes) + } + + fn quote_retention( + &self, + _descriptor: &ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeRetentionQuote::split( + self.exclusive_bytes, + self.shared_bytes, + )) + } + + fn prepare( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(RendererTargetPayload), + ), + ScreenNativeRetentionQuote::split(self.exclusive_bytes, self.shared_bytes), + )) + } +} + +fn smoothing_profile() -> Arc { + Arc::new(ScreenProcessingProfile::new( + ScreenProcessingProfileConfig { + smoothing: ScreenSmoothingPolicy::Exponential { + time_constant: Duration::from_millis(80), + scene_cut: ScreenSceneCutPolicy::Disabled, + }, + ..ScreenProcessingProfileConfig::default() + }, + )) +} + +#[test] +fn equal_native_physical_work_retains_one_shared_allocation() { + const EXCLUSIVE_BYTES: u64 = 17; + const SHARED_BYTES: u64 = 101; + + let source = source(); + let target = native_target_with( + 81, + Arc::new(SharedTargetPreparer { + exclusive_bytes: EXCLUSIVE_BYTES, + shared_bytes: SHARED_BYTES, + }), + ); + let first = demand_for_target(&source, target.clone()); + let second = demand_for_target_profile_extent( + &source, + target.clone(), + ScreenExtentRequest::Native, + smoothing_profile(), + ); + assert_ne!(first.descriptor(), second.descriptor()); + assert_eq!( + first.descriptor().physical(), + second.descriptor().physical() + ); + + let ticket = worker_ticket_for([first.clone(), second.clone()]); + let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket) + .expect("shared native ledger metadata prepares"); + let first_admitted = ledger + .prepare_native_target( + &target, + first.descriptor(), + &ScreenNativePreparationPayload::new( + first.descriptor(), + ledger.ticket().plan_generation(), + Arc::new(RendererTargetPayload), + ), + "native-shared-first", + "worker-runtime-total", + ) + .expect("first shared native route prepares"); + let second_admitted = ledger + .prepare_native_target( + &target, + second.descriptor(), + &ScreenNativePreparationPayload::new( + second.descriptor(), + ledger.ticket().plan_generation(), + Arc::new(RendererTargetPayload), + ), + "native-shared-second", + "worker-runtime-total", + ) + .expect("second shared native route reuses physical admission"); + let shared_name = first_admitted + .shared_resource_name() + .cloned() + .expect("split quote names one shared physical allocation"); + assert_eq!(second_admitted.shared_resource_name(), Some(&shared_name)); + + let reports = ledger + .ticket() + .required_minimums() + .iter() + .map(|minimum| (Arc::clone(minimum.name()), minimum.minimum_bytes())) + .collect::>(); + for (name, bytes) in reports { + ledger + .report(&name, bytes) + .expect("required shared native scope reports"); + } + let (_, lifetimes) = ledger + .finish() + .expect("shared native ledger finishes") + .into_parts(); + let shared_lifetimes = lifetimes + .iter() + .filter(|lifetime| lifetime.resource().name() == &shared_name) + .cloned() + .collect::>(); + assert_eq!(shared_lifetimes.len(), 1); + assert_eq!(shared_lifetimes[0].resource().bytes(), SHARED_BYTES); + + for (admitted, name) in [ + (first_admitted, "native-shared-first"), + (second_admitted, "native-shared-second"), + ] { + let exclusive = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name().as_ref() == name) + .cloned() + .expect("branch-exclusive native lifetime exists"); + let bound = admitted + .bind_with_shared(exclusive, Some(shared_lifetimes[0].clone())) + .expect("branch binds the shared physical lifetime"); + assert_eq!(bound.allocation().retained_bytes(), EXCLUSIVE_BYTES); + assert_eq!( + bound + .shared_physical_allocation() + .expect("bound route retains shared physical admission") + .retained_bytes(), + SHARED_BYTES + ); + let (surface, _) = gpu_surface(91); + let surface = bound.retain_on_surface(surface); + assert_eq!( + surface + .shared_resource_lifetime() + .expect("published surface retains the shared physical lifetime") + .resource() + .name(), + &shared_name + ); + } +} + +struct ConflictingSharedTargetPreparer { + quotes: AtomicUsize, + first_shared_bytes: u64, + second_shared_bytes: u64, +} + +impl ScreenNativeTargetPreparer for ConflictingSharedTargetPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(17) + } + + fn quote_retention( + &self, + _descriptor: &ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + let shared_bytes = if self.quotes.fetch_add(1, Ordering::Relaxed) == 0 { + self.first_shared_bytes + } else { + self.second_shared_bytes + }; + Ok(ScreenNativeRetentionQuote::split(17, shared_bytes)) + } + + fn prepare( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(RendererTargetPayload), + ), + ScreenNativeRetentionQuote::split(17, self.first_shared_bytes), + )) + } +} + +fn conflicting_shared_quote_error( + first_shared_bytes: u64, + second_shared_bytes: u64, +) -> ScreenWorkerLedgerBuildError { + let source = source(); + let target = native_target_with( + 82, + Arc::new(ConflictingSharedTargetPreparer { + quotes: AtomicUsize::new(0), + first_shared_bytes, + second_shared_bytes, + }), + ); + let first = demand_for_target(&source, target.clone()); + let second = demand_for_target_profile_extent( + &source, + target.clone(), + ScreenExtentRequest::Native, + smoothing_profile(), + ); + let ticket = worker_ticket_for([first.clone(), second.clone()]); + let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket) + .expect("conflicting shared quote ledger prepares"); + ledger + .prepare_native_target( + &target, + first.descriptor(), + &ScreenNativePreparationPayload::new( + first.descriptor(), + ledger.ticket().plan_generation(), + Arc::new(RendererTargetPayload), + ), + "native-conflict-first", + "worker-runtime-total", + ) + .expect("first shared quote establishes the physical charge"); + ledger + .prepare_native_target( + &target, + second.descriptor(), + &ScreenNativePreparationPayload::new( + second.descriptor(), + ledger.ticket().plan_generation(), + Arc::new(RendererTargetPayload), + ), + "native-conflict-second", + "worker-runtime-total", + ) + .expect_err("equal physical work cannot change its shared byte quote") + .downcast::() + .expect("conflicting shared retention returns its typed ledger error") +} + +#[test] +fn equal_native_physical_work_rejects_conflicting_shared_quotes() { + assert!(matches!( + conflicting_shared_quote_error(101, 102), + ScreenWorkerLedgerBuildError::ConflictingNativeSharedRetention { + expected: 101, + observed: 102, + } + )); +} + +#[test] +fn equal_native_physical_work_rejects_zero_then_shared_quotes() { + assert!(matches!( + conflicting_shared_quote_error(0, 101), + ScreenWorkerLedgerBuildError::ConflictingNativeSharedRetention { + expected: 0, + observed: 101, + } + )); +} + #[test] fn native_target_bindings_require_installed_admission_and_exact_identity() { let source = source(); diff --git a/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs b/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs index 21df5aa36..48c1cd5b1 100644 --- a/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs +++ b/crates/hypercolor-core/tests/screen_native_executor_negotiation_tests.rs @@ -11,14 +11,15 @@ use hypercolor_core::input::screen::{ ScreenByteAdmissionCoordinator, ScreenCaptureBackend, ScreenColorTransformCapabilities, ScreenCursorCapabilities, ScreenExecutorColorCapabilities, ScreenExtentRequest, ScreenInputGraphGeneration, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, - ScreenNativePreparationPayload, ScreenNativeTargetPreparation, + ScreenNativePreparationPayload, ScreenNativeRetentionQuote, ScreenNativeTargetPreparation, ScreenNativeTargetPreparationError, ScreenNativeTargetPreparer, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, ScreenPlanGeneration, ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenPublicationError, ScreenPublicationExecutor, ScreenPublicationExecutorFallbackReason, ScreenPublicationExecutorRequest, ScreenPublicationKind, ScreenPublicationRequest, ScreenPublicationResidency, ScreenPublicationSlotPolicy, ScreenResourceApi, - ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerExactLedgerBuilder, SourceScale, + ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerExactLedgerBuilder, + ScreenWorkerLedgerBuildError, SourceScale, }; #[path = "support/native_target.rs"] @@ -74,6 +75,15 @@ struct CountingPreparer { calls: Arc, } +struct SplitCountingPreparer { + calls: Arc, +} + +struct DispatchCountingPreparer { + quote_calls: Arc, + prepare_calls: Arc, +} + impl ScreenNativeTargetPreparer for CountingPreparer { fn quote_retained_bytes( &self, @@ -100,6 +110,67 @@ impl ScreenNativeTargetPreparer for CountingPreparer { } } +impl ScreenNativeTargetPreparer for SplitCountingPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(1) + } + + fn quote_retention( + &self, + _descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeRetentionQuote::split(1, 1)) + } + + fn prepare( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(()), + ), + ScreenNativeRetentionQuote::split(1, 1), + )) + } +} + +impl ScreenNativeTargetPreparer for DispatchCountingPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + self.quote_calls.fetch_add(1, Ordering::Relaxed); + Ok(1) + } + + fn prepare( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + self.prepare_calls.fetch_add(1, Ordering::Relaxed); + Ok(ScreenNativeTargetPreparation::new( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(()), + ), + 1, + )) + } +} + struct WrongOutputPreparer { calls: Arc, output_descriptor: hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, @@ -108,6 +179,8 @@ struct WrongOutputPreparer { struct MisquotingPreparer; +struct MisquotingSharedPreparer; + impl ScreenNativeTargetPreparer for MisquotingPreparer { fn quote_retained_bytes( &self, @@ -133,6 +206,39 @@ impl ScreenNativeTargetPreparer for MisquotingPreparer { } } +impl ScreenNativeTargetPreparer for MisquotingSharedPreparer { + fn quote_retained_bytes( + &self, + _descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(7) + } + + fn quote_retention( + &self, + _descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeRetentionQuote::split(7, 11)) + } + + fn prepare( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(()), + ), + ScreenNativeRetentionQuote::split(7, 12), + )) + } +} + impl ScreenNativeTargetPreparer for WrongOutputPreparer { fn quote_retained_bytes( &self, @@ -332,6 +438,71 @@ fn native_target_rejects_substituted_preparer_output_before_binding() { assert_eq!(calls.load(Ordering::Relaxed), 1); } +#[test] +fn native_target_rejects_foreign_plan_generation_before_quote_or_prepare() { + let device = gpu_device(10); + let resolved_source = source( + extent(1920, 1080), + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Direct3d11), + Some(device.clone()), + ); + let quote_calls = Arc::new(AtomicUsize::new(0)); + let prepare_calls = Arc::new(AtomicUsize::new(0)); + let target = ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(91).expect("test target identity is non-zero"), + ), + PlatformGpuApi::Direct3d11, + device, + non_zero_u32(16_384), + Arc::new(DispatchCountingPreparer { + quote_calls: Arc::clone("e_calls), + prepare_calls: Arc::clone(&prepare_calls), + }), + ); + let demand = resolve_exact( + &resolved_source, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ); + let mut plan_builder = ScreenPlanBuilder::new(); + let mut preparing = plan_builder + .prepare( + [demand.clone()], + None, + InputPublicationDemandRevision::new(1), + ScreenInputGraphGeneration::new(1), + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("foreign-generation regression plan prepares"); + let ticket = preparing + .worker_ticket(&resolved_source.epoch().source_id) + .expect("foreign-generation regression owns one worker ticket"); + assert_ne!(ticket.plan_generation(), ScreenPlanGeneration::default()); + let foreign = ScreenNativePreparationPayload::new( + demand.descriptor(), + ScreenPlanGeneration::default(), + Arc::new(()), + ); + let mut ledger = + ScreenWorkerExactLedgerBuilder::new(ticket).expect("native ledger metadata prepares"); + + let error = ledger + .prepare_native_target( + &target, + demand.descriptor(), + &foreign, + "native-foreign-generation", + "worker-runtime-total", + ) + .expect_err("foreign plan generation is rejected before renderer dispatch"); + assert!(matches!( + error.downcast_ref::(), + Some(ScreenWorkerLedgerBuildError::NativePlanGenerationMismatch { .. }) + )); + assert_eq!(quote_calls.load(Ordering::Relaxed), 0); + assert_eq!(prepare_calls.load(Ordering::Relaxed), 0); +} + #[test] fn native_target_rejects_renderer_allocation_drift_from_preflight_quote() { let device = gpu_device(6); @@ -353,11 +524,6 @@ fn native_target_rejects_renderer_allocation_drift_from_preflight_quote() { &resolved_source, ScreenPublicationExecutorRequest::SourceNative(target.clone()), ); - let platform = ScreenNativePreparationPayload::new( - resolved.descriptor(), - ScreenPlanGeneration::default(), - Arc::new(()), - ); let mut plan_builder = ScreenPlanBuilder::new(); let mut preparing = plan_builder .prepare( @@ -371,6 +537,11 @@ fn native_target_rejects_renderer_allocation_drift_from_preflight_quote() { let ticket = preparing .worker_ticket(&resolved_source.epoch().source_id) .expect("native source owns one worker ticket"); + let platform = ScreenNativePreparationPayload::new( + resolved.descriptor(), + ticket.plan_generation(), + Arc::new(()), + ); let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket).expect("native ledger metadata prepares"); let error = ledger @@ -394,6 +565,52 @@ fn native_target_rejects_renderer_allocation_drift_from_preflight_quote() { ); } +#[test] +fn native_target_rejects_shared_allocation_drift_from_preflight_quote() { + let device = gpu_device(8); + let resolved_source = source( + extent(1920, 1080), + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Direct3d11), + Some(device.clone()), + ); + let target = ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(43).expect("test target identity is non-zero"), + ), + PlatformGpuApi::Direct3d11, + device, + non_zero_u32(16_384), + Arc::new(MisquotingSharedPreparer), + ); + let resolved = resolve_exact( + &resolved_source, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ); + let platform = ScreenNativePreparationPayload::new( + resolved.descriptor(), + ScreenPlanGeneration::default(), + Arc::new(()), + ); + let error = prepare_with_admission( + &resolved_source, + &resolved, + &target, + resolved.descriptor(), + &platform, + ) + .expect_err("shared allocation drift from the admitted quote is rejected"); + + assert_eq!( + error.downcast_ref::(), + Some( + &ScreenNativeTargetPreparationError::PreparedSharedRetainedBytesMismatch { + quoted: 11, + actual: 12, + } + ) + ); +} + #[test] fn admitted_native_target_keeps_its_quote_after_builder_and_plan_drop() { let coordinator = @@ -448,6 +665,76 @@ fn admitted_native_target_keeps_its_quote_after_builder_and_plan_drop() { assert_eq!(coordinator.snapshot().reserved_bytes(), 0); } +#[test] +fn shared_admission_failure_never_dispatches_renderer_preparation() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + let resolved_source = source( + extent(1920, 1080), + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Direct3d11), + Some(gpu_device(9)), + ); + let calls = Arc::new(AtomicUsize::new(0)); + let target = ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(90).expect("test target identity is non-zero"), + ), + PlatformGpuApi::Direct3d11, + gpu_device(9), + non_zero_u32(16_384), + Arc::new(SplitCountingPreparer { + calls: Arc::clone(&calls), + }), + ); + let demand = resolve_exact( + &resolved_source, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ); + let mut plan_builder = ScreenPlanBuilder::with_publication_slots_and_admission( + ScreenPublicationSlotPolicy::default(), + coordinator.clone(), + ); + let mut preparing = plan_builder + .prepare( + [demand.clone()], + None, + InputPublicationDemandRevision::new(1), + ScreenInputGraphGeneration::new(1), + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("split admission regression plan prepares"); + let ticket = preparing + .worker_ticket(&resolved_source.epoch().source_id) + .expect("split admission regression owns one worker ticket"); + let modeled_bytes = coordinator.snapshot().reserved_bytes(); + coordinator + .try_set_capacity(ScreenAdmissionCapacity::new( + modeled_bytes + 1, + modeled_bytes + 1, + )) + .expect("one exclusive byte remains available"); + let platform = ScreenNativePreparationPayload::new( + demand.descriptor(), + ticket.plan_generation(), + Arc::new(()), + ); + let mut ledger = + ScreenWorkerExactLedgerBuilder::new(ticket).expect("native ledger metadata prepares"); + + ledger + .prepare_native_target( + &target, + demand.descriptor(), + &platform, + "native-shared-admission-failure", + "worker-runtime-total", + ) + .expect_err("shared physical byte exceeds the remaining exact capacity"); + assert_eq!(calls.load(Ordering::Relaxed), 0); + assert_eq!(coordinator.snapshot().reserved_bytes(), modeled_bytes); + drop(preparing.abort()); +} + fn source( output_extent: PixelExtent, api: ScreenResourceApi, @@ -543,11 +830,16 @@ fn prepare_with_admission( let ticket = preparing .worker_ticket(&source.epoch().source_id) .expect("native test source owns one worker ticket"); + let platform = ScreenNativePreparationPayload::new( + platform.descriptor(), + ticket.plan_generation(), + Arc::new(()), + ); let mut ledger = ScreenWorkerExactLedgerBuilder::new(ticket)?; ledger.prepare_native_target( target, descriptor, - platform, + &platform, "native-negotiation-test", "worker-runtime-total", ) From d21d7625cfb0e5a7031556ea70d7e92286172317 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 03:58:57 -0700 Subject: [PATCH 074/144] feat(input): publish exact screen consumer counts Track committed screen consumers per source across detached publication preparation without conflating demand eligibility or session lifecycle. Preserve last-good counts on rejected candidates and clear them on empty plans, source topology invalidation, and retirement. Co-Authored-By: Nova (GPT-5 Codex) --- crates/hypercolor-core/src/input/mod.rs | 77 +++++- .../src/input/screen/coordinator.rs | 16 +- crates/hypercolor-core/src/input/status.rs | 31 +++ crates/hypercolor-core/src/input/traits.rs | 10 + crates/hypercolor-core/tests/input_tests.rs | 43 ++++ .../tests/screen_publication_demand_tests.rs | 228 ++++++++++++++---- 6 files changed, 354 insertions(+), 51 deletions(-) diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 8e6ceba62..e29e4db98 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -241,6 +241,7 @@ impl ScreenRuntimeRetirement { /// Stop detached workers and retire their status handles. pub fn retire(mut self) { for source in &mut self.sources { + source.set_active_consumer_count(0); source.stop(); if let Err(error) = source.retire_source_status(self.source_graph_generation) { error!(source = source.name(), %error, "Failed to retire screen input source status"); @@ -393,6 +394,17 @@ impl ManagedInputSource { } } + fn set_active_consumer_count(&mut self, active_consumer_count: usize) { + if let Err(error) = self.source.set_active_consumer_count(active_consumer_count) { + error!(source = self.source.name(), %error, "Failed to publish active consumer count"); + } + if let Some(status) = &mut self.compatibility_status + && let Err(error) = status.set_active_consumer_count(active_consumer_count) + { + error!(source = self.source.name(), %error, "Failed to publish compatibility consumer count"); + } + } + fn retire_source_status( &mut self, source_graph_generation: u64, @@ -617,6 +629,9 @@ impl InputManager { ); let replacement = self.create_managed_source(source, source_graph_generation); let mut previous = std::mem::replace(&mut self.sources[index], replacement); + if previous_domains.1 { + previous.set_active_consumer_count(0); + } previous.stop(); if let Err(error) = previous.retire_source_status(source_graph_generation) { error!(source = previous.name(), %error, "Failed to retire replaced input source status"); @@ -1415,7 +1430,7 @@ impl InputManager { resolved .try_reserve_exact(demand.branches().len()) .map_err(|_| screen::ScreenPlanError::AllocationFailed)?; - let mut owners: Vec<(screen::CaptureSourceId, usize)> = Vec::new(); + let mut owners: Vec<(screen::CaptureSourceId, usize, usize)> = Vec::new(); for (branch_index, branch) in demand.branches().iter().enumerate() { let mut resolution = None; for (source_index, source) in self.sources.iter().enumerate() { @@ -1448,7 +1463,10 @@ impl InputManager { }); }; let source_id = branch.descriptor().source_epoch().source_id.clone(); - if let Some((_, owner)) = owners.iter().find(|(candidate, _)| *candidate == source_id) { + if let Some((_, owner, active_consumer_count)) = owners + .iter_mut() + .find(|(candidate, _, _)| *candidate == source_id) + { if *owner != source_index { return Err( screen::ScreenPublicationTransitionError::SourceOwnershipConflict { @@ -1456,15 +1474,26 @@ impl InputManager { }, ); } + *active_consumer_count += 1; } else { owners .try_reserve(1) .map_err(|_| screen::ScreenPlanError::AllocationFailed)?; - owners.push((source_id, source_index)); + owners.push((source_id, source_index, 1)); } resolved.push(branch); } + let mut active_consumer_counts = Vec::new(); + active_consumer_counts + .try_reserve_exact(owners.len()) + .map_err(|_| screen::ScreenPlanError::AllocationFailed)?; + active_consumer_counts.extend( + owners + .iter() + .map(|(source_id, _, count)| (source_id.clone(), *count)), + ); + let compatibility_surface = resolved_compatibility_descriptor(&demand, &resolved, demand.compatibility_surface()); let compatibility_zones = @@ -1498,8 +1527,8 @@ impl InputManager { }; let owner = owners .iter() - .find(|(candidate, _)| candidate == &source_id) - .map(|(_, source_index)| *source_index) + .find(|(candidate, _, _)| candidate == &source_id) + .map(|(_, source_index, _)| *source_index) .or_else(|| { self.sources.iter().position(|source| { source.is_screen_source() @@ -1554,6 +1583,7 @@ impl InputManager { workers, demand, source_resolution_revision, + active_consumer_counts, ))) } @@ -1575,6 +1605,7 @@ impl InputManager { screen::ScreenPublicationTransitionFailure, > { let demand = prepared.demand().clone(); + let active_consumer_counts = prepared.active_consumer_counts().to_vec(); let expected_source_resolution_revision = prepared.source_resolution_revision(); let observed_source_resolution_revision = self.screen_publication_resolution_revision(); if expected_source_resolution_revision != observed_source_resolution_revision { @@ -1614,6 +1645,7 @@ impl InputManager { ) })?; prepared.disarm_worker_aborts(); + self.set_screen_publication_active_consumer_counts(&active_consumer_counts); self.screen_publication_demand = Some(demand); self.committed_screen_publication_resolution_revision = Some(observed_source_resolution_revision); @@ -1725,6 +1757,9 @@ impl InputManager { } (None, None) => {} } + if topology_changed { + self.invalidate_capture_domains((false, true, false)); + } self.screen_capture_demand = Some(plan.capture_demand); ScreenRuntimeRetirement { sources: retired, @@ -1837,6 +1872,7 @@ impl InputManager { let source_graph_generation = self.bump_source_graph_generation(); self.sources.retain_mut(|source| { if source.is_screen_source() { + source.set_active_consumer_count(0); source.stop(); if let Err(error) = source.retire_source_status(source_graph_generation) { error!(source = source.name(), %error, "Failed to retire screen input source status"); @@ -1847,7 +1883,7 @@ impl InputManager { true } }); - self.screen_capture_demand = None; + self.invalidate_capture_domains((false, true, false)); self.publish_source_status_registry(); } @@ -2189,12 +2225,41 @@ impl InputManager { self.screen_capture_demand = None; self.screen_publication_demand = None; self.committed_screen_publication_resolution_revision = None; + self.set_screen_publication_active_consumer_count(0); } if domains.2 { self.interaction_capture_active = None; } } + fn set_screen_publication_active_consumer_count(&mut self, active_consumer_count: usize) { + for source in self + .sources + .iter_mut() + .filter(|source| source.is_screen_source()) + { + source.set_active_consumer_count(active_consumer_count); + } + } + + fn set_screen_publication_active_consumer_counts( + &mut self, + active_consumer_counts: &[(screen::CaptureSourceId, usize)], + ) { + for source in self + .sources + .iter_mut() + .filter(|source| source.is_screen_source()) + { + let active_consumer_count = active_consumer_counts + .iter() + .filter(|(source_id, _)| source.owns_screen_publication_source(source_id)) + .map(|(_, count)| *count) + .sum(); + source.set_active_consumer_count(active_consumer_count); + } + } + fn publish_source_status_registry(&self) { let slots = self .sources diff --git a/crates/hypercolor-core/src/input/screen/coordinator.rs b/crates/hypercolor-core/src/input/screen/coordinator.rs index b397a7a71..92e89f2ff 100644 --- a/crates/hypercolor-core/src/input/screen/coordinator.rs +++ b/crates/hypercolor-core/src/input/screen/coordinator.rs @@ -220,12 +220,14 @@ impl ScreenPublicationAwaitGuard { mut self, demand: ScreenPublicationDemandSnapshot, source_resolution_revision: u64, + active_consumer_counts: Vec<(CaptureSourceId, usize)>, ) -> PreparedScreenPublicationPlan { debug_assert!(self.completions.is_empty()); PreparedScreenPublicationPlan { preparing: self.preparing.take(), demand, source_resolution_revision, + active_consumer_counts, worker_aborts: std::mem::take(&mut self.worker_aborts), } } @@ -246,6 +248,7 @@ pub struct ScreenPublicationPreparation { workers: Vec, demand: ScreenPublicationDemandSnapshot, source_resolution_revision: u64, + active_consumer_counts: Vec<(CaptureSourceId, usize)>, } impl ScreenPublicationPreparation { @@ -254,12 +257,14 @@ impl ScreenPublicationPreparation { workers: Vec, demand: ScreenPublicationDemandSnapshot, source_resolution_revision: u64, + active_consumer_counts: Vec<(CaptureSourceId, usize)>, ) -> Self { Self { preparing: Some(preparing), workers, demand, source_resolution_revision, + active_consumer_counts, } } @@ -303,7 +308,11 @@ impl ScreenPublicationPreparation { )); } } - Ok(awaiting.into_prepared(self.demand.clone(), self.source_resolution_revision)) + Ok(awaiting.into_prepared( + self.demand.clone(), + self.source_resolution_revision, + self.active_consumer_counts.clone(), + )) } /// Explicitly abandon all started worker preparations. @@ -340,6 +349,7 @@ pub struct PreparedScreenPublicationPlan { preparing: Option, demand: ScreenPublicationDemandSnapshot, source_resolution_revision: u64, + active_consumer_counts: Vec<(CaptureSourceId, usize)>, worker_aborts: Vec, } @@ -358,6 +368,10 @@ impl PreparedScreenPublicationPlan { self.source_resolution_revision } + pub(crate) fn active_consumer_counts(&self) -> &[(CaptureSourceId, usize)] { + &self.active_consumer_counts + } + pub(crate) fn disarm_worker_aborts(&mut self) { for abort in std::mem::take(&mut self.worker_aborts) { abort.disarm(); diff --git a/crates/hypercolor-core/src/input/status.rs b/crates/hypercolor-core/src/input/status.rs index 64dd968f5..07ca9dc38 100644 --- a/crates/hypercolor-core/src/input/status.rs +++ b/crates/hypercolor-core/src/input/status.rs @@ -459,6 +459,8 @@ pub struct SourceStatus { pub consented: bool, /// Whether the current render graph demands source data. pub demanded: bool, + /// Number of committed consumers currently reading this source domain. + pub active_consumer_count: usize, /// Lifecycle health, independent of sample freshness. pub state: SourceState, /// Freshness of the latest sampled data. @@ -501,6 +503,7 @@ impl SourceStatus { configured, consented, demanded, + active_consumer_count: 0, state: SourceState::Stopped, freshness: SourceFreshness::NotApplicable, source_graph_generation: 0, @@ -1086,6 +1089,25 @@ impl SourceStatusWriter { Ok(()) } + /// Publish the committed consumer count without disturbing lifecycle state. + pub fn set_active_consumer_count( + &self, + active_consumer_count: usize, + ) -> Result<(), SourceStatusError> { + let _control = lock_control(&self.shared); + let current = self.shared.latest.load_full(); + if current.retired { + return Err(SourceStatusError::Retired); + } + if current.active_consumer_count == active_consumer_count { + return Ok(()); + } + let mut status = (*current).clone(); + status.active_consumer_count = active_consumer_count; + publish_structural(&self.shared, status); + Ok(()) + } + /// Publish platform-specific state without disturbing generic lifecycle. /// /// # Errors @@ -1199,6 +1221,7 @@ impl SourceStatusWriter { control.active_session = None; let mut status = (*current).clone(); clear_stopped_state(&mut status); + status.active_consumer_count = 0; status.source_graph_generation = removal_graph_generation; status.retired = true; publish_structural(&self.shared, status); @@ -1393,6 +1416,14 @@ impl SourceStatusReporter { self.writer.set_backend(backend) } + /// Publish the committed consumer count without disturbing lifecycle state. + pub fn set_active_consumer_count( + &mut self, + active_consumer_count: usize, + ) -> Result<(), SourceStatusError> { + self.writer.set_active_consumer_count(active_consumer_count) + } + /// Publish platform-specific state without disturbing generic lifecycle. pub fn set_platform( &mut self, diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index e4f636687..4de8b8bbf 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -546,6 +546,16 @@ pub trait InputSource: Send { } } + /// Publish the number of consumers in the committed source domain. + fn set_active_consumer_count( + &mut self, + active_consumer_count: usize, + ) -> Result<(), SourceStatusError> { + self.source_status_reporter().map_or(Ok(()), |status| { + status.set_active_consumer_count(active_consumer_count) + }) + } + /// Permanently retire this source's status at its removal generation. /// /// # Errors diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index 4b5ae408c..bd230e5f7 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -3057,6 +3057,49 @@ fn source_session_slot_hands_a_long_lived_worker_the_successor_session() { assert!(successor.session_generation() > first.session_generation()); } +#[test] +fn active_consumer_count_survives_session_churn_until_domain_commit_changes_it() { + let (writer, handle) = test_status_writer(); + writer + .set_active_consumer_count(3) + .expect("consumer count should publish"); + let session = writer + .begin_session(1) + .expect("eligible source session should start"); + assert_eq!(handle.snapshot().active_consumer_count, 3); + + let sampled_at = Instant::now(); + assert_eq!( + session.record_sample(sampled_at, sampled_at + Duration::from_secs(1), 1), + Ok(true) + ); + writer.stop(); + let stopped = handle.snapshot(); + assert_eq!(stopped.state, SourceState::Stopped); + assert_eq!(stopped.active_consumer_count, 3); + + writer + .set_active_consumer_count(0) + .expect("domain invalidation should clear the count"); + let cleared = handle.snapshot(); + assert_eq!(cleared.state, SourceState::Stopped); + assert_eq!(cleared.session_generation, stopped.session_generation); + assert_eq!(cleared.active_consumer_count, 0); +} + +#[test] +fn source_retirement_clears_active_consumer_count() { + let (writer, handle) = test_status_writer(); + writer + .set_active_consumer_count(2) + .expect("consumer count should publish"); + writer.retire(1).expect("source retirement should publish"); + + let retired = handle.snapshot(); + assert!(retired.retired); + assert_eq!(retired.active_consumer_count, 0); +} + #[test] fn source_resource_scan_health_maps_access_failure_and_recovery() { assert_eq!( diff --git a/crates/hypercolor-core/tests/screen_publication_demand_tests.rs b/crates/hypercolor-core/tests/screen_publication_demand_tests.rs index fbcfb1d21..3a197cfa1 100644 --- a/crates/hypercolor-core/tests/screen_publication_demand_tests.rs +++ b/crates/hypercolor-core/tests/screen_publication_demand_tests.rs @@ -145,7 +145,7 @@ impl ExactWorkerState { } struct ExactDemandProbe { - source: ResolvedScreenSource, + sources: Vec, hub: Arc>>>, worker: Arc, preparation_barrier: Option>, @@ -168,38 +168,8 @@ impl ExactDemandProbe { selector: ScreenSourceSelector, source_id: CaptureSourceId, ) -> Self { - let extent = PixelExtent::new(7_680, 4_320).expect("test extent is non-empty"); - let geometry = CaptureGeometry::new( - PhysicalOrigin::default(), - extent, - extent, - CaptureRotation::Identity, - None, - SourceScale::ONE, - ) - .expect("test geometry is valid"); Self { - source: ResolvedScreenSource::new( - selector, - CaptureEpoch { - source_id, - topology_generation: 3, - session_generation: 5, - }, - ResolvedScreenSourceConfig::new( - geometry, - extent, - ScreenSourceReflection::None, - CapturePixelFormat::Rgba8, - CaptureColorimetry::SRGB, - ScreenBackendResourceIdentity::new( - ScreenCaptureBackend::Synthetic, - ScreenResourceApi::Cpu, - 7, - 11, - ), - ), - ), + sources: vec![resolved_source(selector, source_id)], hub, worker, preparation_barrier: None, @@ -216,6 +186,51 @@ impl ExactDemandProbe { self.completion_pause = Some(pause); self } + + fn with_alias_source(mut self, source_id: CaptureSourceId) -> Self { + self.sources.push(resolved_source( + ScreenSourceSelector::Exact(source_id.clone()), + source_id, + )); + self + } +} + +fn resolved_source( + selector: ScreenSourceSelector, + source_id: CaptureSourceId, +) -> ResolvedScreenSource { + let extent = PixelExtent::new(7_680, 4_320).expect("test extent is non-empty"); + let geometry = CaptureGeometry::new( + PhysicalOrigin::default(), + extent, + extent, + CaptureRotation::Identity, + None, + SourceScale::ONE, + ) + .expect("test geometry is valid"); + ResolvedScreenSource::new( + selector, + CaptureEpoch { + source_id, + topology_generation: 3, + session_generation: 5, + }, + ResolvedScreenSourceConfig::new( + geometry, + extent, + ScreenSourceReflection::None, + CapturePixelFormat::Rgba8, + CaptureColorimetry::SRGB, + ScreenBackendResourceIdentity::new( + ScreenCaptureBackend::Synthetic, + ScreenResourceApi::Cpu, + 7, + 11, + ), + ), + ) } impl InputSource for ExactDemandProbe { @@ -254,20 +269,25 @@ impl InputSource for ExactDemandProbe { demand: &RegisteredScreenBranchDemand, ) -> anyhow::Result> { self.worker.resolutions.fetch_add(1, Ordering::AcqRel); - if demand.request().selector() != self.source.selector() { + let Some(source) = self + .sources + .iter() + .find(|source| demand.request().selector() == source.selector()) + else { return Ok(None); - } + }; let capabilities = CpuReductionExecutor::new(NonZeroUsize::MIN, NonZeroU32::MIN) .expect("test CPU reducer builds") .capabilities(); - Ok(Some(demand.resolve_with_color_capabilities( - &self.source, - capabilities, - )?)) + Ok(Some( + demand.resolve_with_color_capabilities(source, capabilities)?, + )) } fn owns_screen_publication_source(&self, source_id: &CaptureSourceId) -> bool { - self.source.epoch().source_id == *source_id + self.sources + .iter() + .any(|source| source.epoch().source_id == *source_id) } fn begin_screen_publication_preparation( @@ -416,7 +436,17 @@ fn manager_fixture() -> ( #[tokio::test] async fn manager_commits_exact_plan_once_through_detached_worker_preparation() { let (mut manager, hub, worker) = manager_fixture(); - let demand = demand(&manager, 5, [branch(ScreenPublicationKind::Surface)]); + let demand = demand( + &manager, + 5, + [ + branch(ScreenPublicationKind::Surface), + branch(ScreenPublicationKind::Zones { + columns: NonZeroU32::MIN, + rows: NonZeroU32::MIN, + }), + ], + ); let preparation = manager .begin_screen_publication_transition(demand.clone()) .expect("exact plan resolves") @@ -432,8 +462,12 @@ async fn manager_commits_exact_plan_once_through_detached_worker_preparation() { .commit_screen_publication_transition(prepared, demand.revision()) .expect("fenced exact plan commits"); let committed = finish_retirements(committed).await; - assert_eq!(committed.plan().branches().len(), 1); - assert_eq!(hub.committed_state().branch_count(), 1); + assert_eq!(committed.plan().branches().len(), 2); + assert_eq!(hub.committed_state().branch_count(), 2); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].active_consumer_count, + 2 + ); assert_eq!(worker.aborts.load(Ordering::Acquire), 0); assert!( manager @@ -445,6 +479,11 @@ async fn manager_commits_exact_plan_once_through_detached_worker_preparation() { retirement .try_reclaim() .expect("first commit retires no visible resources"); + manager.stop_all(); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].active_consumer_count, + 0 + ); } #[tokio::test] @@ -558,12 +597,105 @@ async fn independent_source_workers_prepare_concurrently() { .expect("multi-source exact plan commits"); let committed = finish_retirements(committed).await; assert_eq!(committed.plan().branches().len(), 2); + let statuses = manager.source_status_registry().snapshot().statuses(); + assert_eq!(statuses.len(), 2); + assert!( + statuses + .iter() + .all(|status| status.active_consumer_count == 1) + ); +} + +#[tokio::test] +async fn one_adapter_sums_consumers_across_owned_capture_source_ids() { + let first_id = + CaptureSourceId::new("synthetic:alias:first").expect("test source id is non-empty"); + let second_id = + CaptureSourceId::new("synthetic:alias:second").expect("test source id is non-empty"); + let worker = Arc::new(ExactWorkerState::default()); + let mut manager = InputManager::new(); + manager.add_source(Box::new( + ExactDemandProbe::for_source( + Arc::new(Mutex::new(None)), + Arc::clone(&worker), + ScreenSourceSelector::Exact(first_id.clone()), + first_id.clone(), + ) + .with_alias_source(second_id.clone()), + )); + let exact = demand( + &manager, + 11, + [ + branch_for( + ScreenSourceSelector::Exact(first_id), + ScreenPublicationKind::Surface, + ), + branch_for( + ScreenSourceSelector::Exact(second_id), + ScreenPublicationKind::Surface, + ), + ], + ); + let prepared = manager + .begin_screen_publication_transition(exact.clone()) + .expect("both owned source identities resolve") + .expect("multi-identity plan requires preparation") + .await_workers() + .await + .expect("one worker acknowledges both identities"); + let committed = manager + .commit_screen_publication_transition(prepared, exact.revision()) + .expect("multi-identity exact plan commits"); + let committed = finish_retirements(committed).await; + + assert_eq!(committed.plan().branches().len(), 2); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].active_consumer_count, + 2 + ); + let retired_handle = manager.source_status_registry().snapshot().handles()[0].clone(); + let plan = manager.plan_screen_runtime_config(false); + let mut replacement = None; + let retirement = manager + .commit_screen_runtime_config(&plan, &mut replacement) + .expect("screen runtime removal commits"); + retirement.retire(); + let retired = retired_handle.snapshot(); + assert!(retired.retired); + assert_eq!(retired.active_consumer_count, 0); } #[tokio::test] async fn demand_race_aborts_candidate_and_preserves_committed_authority() { let (mut manager, hub, worker) = manager_fixture(); - let demand = demand(&manager, 5, [branch(ScreenPublicationKind::Surface)]); + let active = demand(&manager, 4, [branch(ScreenPublicationKind::Surface)]); + let prepared = manager + .begin_screen_publication_transition(active.clone()) + .expect("initial exact plan resolves") + .expect("initial exact plan prepares") + .await_workers() + .await + .expect("initial worker acknowledges exact resources"); + let committed = manager + .commit_screen_publication_transition(prepared, active.revision()) + .expect("initial exact plan commits"); + let committed = finish_retirements(committed).await; + let (_, retirement) = committed.into_parts(); + retirement + .try_reclaim() + .expect("initial plan retires no visible resources"); + let demand = demand( + &manager, + 5, + [ + branch(ScreenPublicationKind::Surface), + branch(ScreenPublicationKind::Zones { + columns: NonZeroU32::MIN, + rows: NonZeroU32::MIN, + }), + ], + ); let before = hub.committed_state(); let prepared = manager .begin_screen_publication_transition(demand.clone()) @@ -586,7 +718,11 @@ async fn demand_race_aborts_candidate_and_preserves_committed_authority() { ) )); assert!(Arc::ptr_eq(&before, &hub.committed_state())); - assert_eq!(failure.abort().active_plan().generation().get(), 0); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].active_consumer_count, + 1 + ); + assert_eq!(failure.abort().active_plan().generation().get(), 1); drop(failure); assert_eq!(worker.aborts.load(Ordering::Acquire), 1); } @@ -804,6 +940,10 @@ async fn empty_demand_retires_worker_and_reclaims_after_reader_release() { let (plan, retirement) = committed.into_parts(); assert!(plan.branches().is_empty()); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].active_consumer_count, + 0 + ); assert!(worker.retirements.load(Ordering::Acquire) >= 2); assert!( worker From 81926864f1e1b914768a6a81816ce19d4ee3a214 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:02:53 -0700 Subject: [PATCH 075/144] feat(macos): coordinate authoritative daemon ownership Make the canonical flock the sole daemon ownership authority across app, launchd, Homebrew, and standalone topologies. Persist journaled handovers, recover interrupted transitions, expose bounded status, and keep local owner selection and remedies topology-aware. Co-Authored-By: Nova (GPT-5 Codex) --- Cargo.lock | 20 + Cargo.toml | 1 + crates/hypercolor-app/Cargo.toml | 1 + crates/hypercolor-app/src/lib.rs | 1 + crates/hypercolor-app/src/main.rs | 18 +- crates/hypercolor-app/src/ownership.rs | 1220 +++++++++ crates/hypercolor-app/src/supervisor/mod.rs | 627 ++++- .../hypercolor-app/tests/packaging_tests.rs | 29 + .../hypercolor-app/tests/supervisor_tests.rs | 44 +- crates/hypercolor-cli/Cargo.toml | 1 + crates/hypercolor-cli/src/commands/service.rs | 550 +++- crates/hypercolor-daemon/Cargo.toml | 4 + crates/hypercolor-daemon/src/api/config.rs | 103 + crates/hypercolor-daemon/src/api/mod.rs | 7 +- crates/hypercolor-daemon/src/api/system.rs | 178 +- crates/hypercolor-daemon/src/daemon.rs | 11 +- crates/hypercolor-daemon/src/macos_owner.rs | 1166 +------- crates/hypercolor-daemon/src/main.rs | 604 ++++- .../src/startup/macos_owner_watch.rs | 736 +++++ crates/hypercolor-daemon/src/startup/mod.rs | 9 + .../hypercolor-daemon/src/startup/services.rs | 63 +- .../tests/macos_owner_tests.rs | 247 +- crates/hypercolor-macos-owner/Cargo.toml | 26 + crates/hypercolor-macos-owner/src/lib.rs | 2390 +++++++++++++++++ .../tests/coordinator_tests.rs | 882 ++++++ crates/hypercolor-types/src/event.rs | 38 + crates/hypercolor-types/tests/event_tests.rs | 13 +- packaging/homebrew/hypercolor.rb | 2 +- .../launchd/tech.hyperbliss.hypercolor.plist | 2 + protocol/websocket-v1.json | 3 +- .../hypercolor/_generated/models/__init__.py | 40 + .../models/api_response_system_status_data.py | 43 + .../input_source_platform_status_type_0.py | 157 ++ ...nput_source_platform_status_type_0_type.py | 8 + .../input_source_platform_status_type_1.py | 255 ++ ...nput_source_platform_status_type_1_type.py | 8 + .../_generated/models/input_source_status.py | 77 + .../models/macos_authorization_state_api.py | 11 + .../models/macos_capability_owner_api.py | 13 + .../models/macos_daemon_handover_phase_api.py | 27 + .../macos_daemon_owner_conflict_api_status.py | 79 + ...emon_owner_recovery_required_api_status.py | 80 + .../macos_daemon_ownership_api_status.py | 163 ++ .../macos_protected_source_state_api.py | 18 + .../macos_selection_state_api_type_0.py | 65 + .../macos_selection_state_api_type_0_type.py | 8 + .../macos_selection_state_api_type_1.py | 73 + .../macos_selection_state_api_type_1_type.py | 8 + .../macos_selection_state_api_type_2.py | 73 + .../macos_selection_state_api_type_2_type.py | 8 + ...tahoe_selection_capabilities_api_status.py | 85 + .../_generated/models/system_status.py | 43 + 52 files changed, 9116 insertions(+), 1222 deletions(-) create mode 100644 crates/hypercolor-app/src/ownership.rs create mode 100644 crates/hypercolor-daemon/src/startup/macos_owner_watch.rs create mode 100644 crates/hypercolor-macos-owner/Cargo.toml create mode 100644 crates/hypercolor-macos-owner/src/lib.rs create mode 100644 crates/hypercolor-macos-owner/tests/coordinator_tests.rs create mode 100644 python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py create mode 100644 python/src/hypercolor/_generated/models/input_source_platform_status_type_0_type.py create mode 100644 python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py create mode 100644 python/src/hypercolor/_generated/models/input_source_platform_status_type_1_type.py create mode 100644 python/src/hypercolor/_generated/models/macos_authorization_state_api.py create mode 100644 python/src/hypercolor/_generated/models/macos_capability_owner_api.py create mode 100644 python/src/hypercolor/_generated/models/macos_daemon_handover_phase_api.py create mode 100644 python/src/hypercolor/_generated/models/macos_daemon_owner_conflict_api_status.py create mode 100644 python/src/hypercolor/_generated/models/macos_daemon_owner_recovery_required_api_status.py create mode 100644 python/src/hypercolor/_generated/models/macos_daemon_ownership_api_status.py create mode 100644 python/src/hypercolor/_generated/models/macos_protected_source_state_api.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_0.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_0_type.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_1.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_1_type.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_2.py create mode 100644 python/src/hypercolor/_generated/models/macos_selection_state_api_type_2_type.py create mode 100644 python/src/hypercolor/_generated/models/macos_tahoe_selection_capabilities_api_status.py diff --git a/Cargo.lock b/Cargo.lock index 4d60bfda4..f62ae002a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4928,6 +4928,7 @@ dependencies = [ "dirs 6.0.0", "futures-util", "hypercolor-core", + "hypercolor-macos-owner", "hypercolor-types", "image", "open", @@ -4961,6 +4962,7 @@ dependencies = [ "dirs 6.0.0", "hypercolor-core", "hypercolor-daemon", + "hypercolor-macos-owner", "hypercolor-tui", "opaline", "open", @@ -5073,6 +5075,8 @@ dependencies = [ "hypercolor-leptos-ext", "hypercolor-macos-capture", "hypercolor-macos-gpu-interop", + "hypercolor-macos-input", + "hypercolor-macos-owner", "hypercolor-network", "hypercolor-platform-fs", "hypercolor-types", @@ -5082,6 +5086,7 @@ dependencies = [ "if-addrs", "image", "mdns-sd", + "notify", "objc2-core-foundation", "owo-colors", "pollster", @@ -5090,6 +5095,7 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "sha2 0.10.9", "single-instance", "socket2 0.6.3", "spin_sleep", @@ -5377,6 +5383,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hypercolor-macos-owner" +version = "0.3.1" +dependencies = [ + "hypercolor-platform-fs", + "nix 0.29.0", + "notify", + "serde", + "serde_json", + "single-instance", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "hypercolor-network" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 8cb4d3134..8e16cdeb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,6 +145,7 @@ hypercolor-linux-gpu-interop = { path = "crates/hypercolor-linux-gpu-interop" } hypercolor-macos-gpu-interop = { path = "crates/hypercolor-macos-gpu-interop" } hypercolor-macos-capture = { path = "crates/hypercolor-macos-capture" } hypercolor-macos-input = { path = "crates/hypercolor-macos-input" } +hypercolor-macos-owner = { path = "crates/hypercolor-macos-owner" } hypercolor-windows-capture = { path = "crates/hypercolor-windows-capture" } hypercolor-windows-gpu-interop = { path = "crates/hypercolor-windows-gpu-interop" } hypercolor-driver-api = { path = "crates/hypercolor-driver-api" } diff --git a/crates/hypercolor-app/Cargo.toml b/crates/hypercolor-app/Cargo.toml index e628b2c43..3c119045c 100644 --- a/crates/hypercolor-app/Cargo.toml +++ b/crates/hypercolor-app/Cargo.toml @@ -26,6 +26,7 @@ unwrap_used = "deny" [dependencies] hypercolor-core = { workspace = true } hypercolor-types = { workspace = true } +hypercolor-macos-owner = { workspace = true } tauri = { version = "2", features = ["devtools", "tray-icon"] } tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" diff --git a/crates/hypercolor-app/src/lib.rs b/crates/hypercolor-app/src/lib.rs index ced1dff21..1780b3634 100644 --- a/crates/hypercolor-app/src/lib.rs +++ b/crates/hypercolor-app/src/lib.rs @@ -9,6 +9,7 @@ pub mod first_run; pub mod helper_client; pub mod linux_webkit; pub mod logging; +pub mod ownership; pub mod power_events; pub mod process_ext; pub mod state; diff --git a/crates/hypercolor-app/src/main.rs b/crates/hypercolor-app/src/main.rs index b70ef3a1a..3e5ee7832 100644 --- a/crates/hypercolor-app/src/main.rs +++ b/crates/hypercolor-app/src/main.rs @@ -15,6 +15,15 @@ fn maybe_open_devtools(window: &tauri::WebviewWindow) { let _ = window; } +fn autostart_plugin() -> tauri::plugin::TauriPlugin { + let builder = tauri_plugin_autostart::Builder::new() + .app_name(hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME) + .arg("--minimized"); + #[cfg(target_os = "macos")] + let builder = builder.macos_launcher(tauri_plugin_autostart::MacosLauncher::LaunchAgent); + builder.build() +} + fn main() -> anyhow::Result<()> { #[cfg(target_os = "linux")] hypercolor_app::linux_webkit::reexec_with_webkit_env_if_needed()?; @@ -39,6 +48,10 @@ fn main() -> anyhow::Result<()> { hypercolor_app::first_run::is_first_run_pending, hypercolor_app::first_run::mark_first_run_complete, hypercolor_app::first_run::reset_first_run, + hypercolor_app::ownership::choose_daemon_owner, + hypercolor_app::ownership::execute_macos_daemon_owner_offline_remedy, + hypercolor_app::ownership::macos_daemon_owner_offline_status, + hypercolor_app::ownership::restart_macos_capture_owner, hypercolor_app::support::detect_pawnio_support, hypercolor_app::support::detect_windows_daemon_service, hypercolor_app::support::launch_pawnio_helper, @@ -54,10 +67,7 @@ fn main() -> anyhow::Result<()> { tracing::warn!(%error, "failed to show main window from forwarded invocation"); } })) - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - Some(vec!["--minimized"]), - )) + .plugin(autostart_plugin()) .setup(move |app| { let url: url::Url = daemon_url .parse() diff --git a/crates/hypercolor-app/src/ownership.rs b/crates/hypercolor-app/src/ownership.rs new file mode 100644 index 000000000..451ac0ae1 --- /dev/null +++ b/crates/hypercolor-app/src/ownership.rs @@ -0,0 +1,1220 @@ +//! Local-only macOS daemon ownership coordination. + +use hypercolor_macos_owner::{ + MACOS_APP_PRODUCT_NAME, MacosDaemonOwner, MacosOwnerCoordinatorOutcome, + MacosOwnerExecutionError, MacosOwnerRemedy, +}; +use tauri::{AppHandle, Runtime, State}; + +use crate::supervisor::{MacosDaemonOwnerOfflineStatus, SupervisorState}; + +/// Select one local macOS daemon topology through the durable coordinator. +#[tauri::command] +pub async fn choose_daemon_owner( + app: AppHandle, + state: State<'_, SupervisorState>, + requested_owner: MacosDaemonOwner, +) -> Result { + #[cfg(target_os = "macos")] + { + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + choose_daemon_owner_inner(&app, state, requested_owner) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app, state, requested_owner); + Err("macOS daemon owner selection is unavailable on this platform".to_owned()) + } +} + +/// Return app-local external-owner state even when the daemon is offline. +#[tauri::command] +pub fn macos_daemon_owner_offline_status( + state: State<'_, SupervisorState>, +) -> Option { + state.macos_owner_offline() +} + +/// Result of executing an app-local offline-owner remedy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MacosDaemonOwnerRemedyOutcome { + /// The selected external owner published a newer healthy epoch. + Started { owner: MacosDaemonOwner }, +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingOfflineRemedy { + status: MacosDaemonOwnerOfflineStatus, + owner: MacosDaemonOwner, + after_epoch: u64, +} + +/// Owner vocabulary matching `SystemStatus.macos_daemon_ownership.active_owner`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosCaptureOwner { + AppSidecar, + LaunchdService, + HomebrewService, + Standalone, +} + +impl From for MacosDaemonOwner { + fn from(owner: MacosCaptureOwner) -> Self { + match owner { + MacosCaptureOwner::AppSidecar => Self::AppSidecar, + MacosCaptureOwner::LaunchdService => Self::DirectLaunchd, + MacosCaptureOwner::HomebrewService => Self::Homebrew, + MacosCaptureOwner::Standalone => Self::Standalone, + } + } +} + +impl From for MacosCaptureOwner { + fn from(owner: MacosDaemonOwner) -> Self { + match owner { + MacosDaemonOwner::AppSidecar => Self::AppSidecar, + MacosDaemonOwner::DirectLaunchd => Self::LaunchdService, + MacosDaemonOwner::Homebrew => Self::HomebrewService, + MacosDaemonOwner::Standalone => Self::Standalone, + } + } +} + +/// Result of an explicit local restart of the authoritative capture owner. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MacosCaptureOwnerRestartOutcome { + /// A managed owner published a new authoritative epoch after restart. + Restarted { + owner: MacosCaptureOwner, + previous_owner_epoch: u64, + owner_epoch: u64, + }, + /// A standalone owner must be stopped by the terminal user. + UserActionRequired { + owner: MacosCaptureOwner, + owner_epoch: u64, + remedy: MacosOwnerRemedy, + }, +} + +/// Execute the exact start remedy published by the current offline-owner status. +#[tauri::command] +pub async fn execute_macos_daemon_owner_offline_remedy( + app: AppHandle, + state: State<'_, SupervisorState>, + remedy: MacosOwnerRemedy, +) -> Result { + #[cfg(target_os = "macos")] + { + let state = state.inner().clone(); + let start_state = state.clone(); + let (pending, daemon_url) = tauri::async_runtime::spawn_blocking(move || { + execute_offline_remedy_inner(&app, start_state, remedy) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())??; + let converged = crate::supervisor::wait_for_authoritative_macos_owner( + &reqwest::Client::new(), + &daemon_url, + pending.owner, + Some(pending.after_epoch), + hypercolor_macos_owner::MACOS_MANAGED_HANDOVER_TIMEOUT, + ) + .await; + complete_offline_remedy_with(&state, pending, converged).map_err(|error| error.to_string()) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app, state, remedy); + Err("macOS daemon owner remedies are unavailable on this platform".to_owned()) + } +} + +/// Restart the exact authoritative owner named by a protected-source status. +#[tauri::command] +pub async fn restart_macos_capture_owner( + app: AppHandle, + state: State<'_, SupervisorState>, + active_owner: MacosCaptureOwner, + owner_epoch: u64, +) -> Result { + #[cfg(target_os = "macos")] + { + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + restart_capture_owner_inner(&app, state, active_owner, owner_epoch) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app, state, active_owner, owner_epoch); + Err("macOS capture-owner restart is unavailable on this platform".to_owned()) + } +} + +#[cfg(target_os = "macos")] +fn execute_offline_remedy_inner( + app: &AppHandle, + state: SupervisorState, + remedy: MacosOwnerRemedy, +) -> Result<(PendingOfflineRemedy, url::Url), anyhow::Error> { + use hypercolor_core::config::paths::data_dir; + use hypercolor_macos_owner::{MacosOwnerExecutor as _, MacosOwnerStore}; + + let store = MacosOwnerStore::new(data_dir()); + let daemon_url: url::Url = std::env::var("HYPERCOLOR_URL") + .unwrap_or_else(|_| crate::DEFAULT_DAEMON_URL.to_owned()) + .parse() + .map_err(|error| anyhow::anyhow!("invalid HYPERCOLOR_URL: {error}"))?; + let after_epoch = store + .load_owner_record()? + .ok_or_else(|| anyhow::anyhow!("macOS daemon owner record is unavailable"))? + .owner_epoch; + let mut executor = + AppOwnerExecutor::new(app.clone(), state.clone(), daemon_url.clone(), store)?; + let pending = + execute_offline_remedy_with(&state, remedy, after_epoch, |owner| executor.start(owner))?; + Ok((pending, daemon_url)) +} + +#[cfg(target_os = "macos")] +fn restart_capture_owner_inner( + app: &AppHandle, + state: SupervisorState, + active_owner: MacosCaptureOwner, + owner_epoch: u64, +) -> Result { + use hypercolor_core::config::paths::data_dir; + use hypercolor_macos_owner::MacosOwnerStore; + + let store = MacosOwnerStore::new(data_dir()); + let daemon_url = std::env::var("HYPERCOLOR_URL") + .unwrap_or_else(|_| crate::DEFAULT_DAEMON_URL.to_owned()) + .parse() + .map_err(|error| anyhow::anyhow!("invalid HYPERCOLOR_URL: {error}"))?; + let active_owner = MacosDaemonOwner::from(active_owner); + let mut executor = + AppOwnerExecutor::new(app.clone(), state.clone(), daemon_url, store.clone())?; + restart_capture_owner_with(&store, &mut executor, active_owner, owner_epoch) +} + +#[cfg(target_os = "macos")] +fn restart_capture_owner_with( + store: &hypercolor_macos_owner::MacosOwnerStore, + executor: &mut impl hypercolor_macos_owner::MacosOwnerExecutor, + active_owner: MacosDaemonOwner, + owner_epoch: u64, +) -> Result { + let record = store + .load_owner_record()? + .ok_or_else(|| anyhow::anyhow!("macOS daemon owner record is unavailable"))?; + if store + .load_handover_journal()? + .is_some_and(|journal| !journal.phase.is_terminal()) + { + anyhow::bail!("macOS daemon owner handover requires recovery before capture-owner restart"); + } + if record.active_owner != active_owner || record.owner_epoch != owner_epoch { + anyhow::bail!( + "macOS capture-owner status is stale: requested {active_owner:?} epoch {owner_epoch}, authoritative {:?} epoch {}", + record.active_owner, + record.owner_epoch + ); + } + if active_owner == MacosDaemonOwner::Standalone { + return Ok(MacosCaptureOwnerRestartOutcome::UserActionRequired { + owner: active_owner.into(), + owner_epoch, + remedy: MacosOwnerRemedy::RestartStandalone { + pid: record.active_identity.pid, + }, + }); + } + executor + .flush_and_stop(active_owner, Some(record.active_identity.pid)) + .map_err(anyhow::Error::from)?; + let restart = (|| { + if !executor + .wait_for_guard_release( + record.active_identity.pid, + hypercolor_macos_owner::MACOS_MANAGED_HANDOVER_TIMEOUT, + ) + .map_err(anyhow::Error::from)? + { + anyhow::bail!( + "macOS capture owner did not release the daemon guard within ten seconds" + ); + } + executor.start(active_owner).map_err(anyhow::Error::from)?; + if !executor + .wait_for_owner( + active_owner, + owner_epoch, + hypercolor_macos_owner::MACOS_MANAGED_HANDOVER_TIMEOUT, + ) + .map_err(anyhow::Error::from)? + { + anyhow::bail!("restarted macOS capture owner did not publish within ten seconds"); + } + let restarted = store + .load_owner_record()? + .filter(|record| { + record.active_owner == active_owner && record.owner_epoch > owner_epoch + }) + .ok_or_else(|| { + anyhow::anyhow!("restarted macOS capture owner did not publish a new epoch") + })?; + Ok(MacosCaptureOwnerRestartOutcome::Restarted { + owner: active_owner.into(), + previous_owner_epoch: owner_epoch, + owner_epoch: restarted.owner_epoch, + }) + })(); + if restart.is_err() && active_owner == MacosDaemonOwner::AppSidecar { + executor.start(active_owner).map_err(|rearm_error| { + anyhow::anyhow!("failed to rearm the app-sidecar supervisor: {rearm_error}") + })?; + } + restart +} + +#[cfg(target_os = "macos")] +fn execute_offline_remedy_with( + state: &SupervisorState, + remedy: MacosOwnerRemedy, + after_epoch: u64, + start_owner: impl FnOnce(MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError>, +) -> Result { + let status = state.macos_owner_offline().ok_or_else(|| { + MacosOwnerExecutionError::new("no selected macOS daemon owner is currently offline") + })?; + if status.remedy != remedy { + return Err(MacosOwnerExecutionError::new( + "offline-owner remedy is stale or does not match the selected topology", + )); + } + let owner = match remedy { + MacosOwnerRemedy::StartLaunchdService => MacosDaemonOwner::DirectLaunchd, + MacosOwnerRemedy::StartHomebrewService => MacosDaemonOwner::Homebrew, + MacosOwnerRemedy::StartAppSidecar + | MacosOwnerRemedy::RestartStandalone { .. } + | MacosOwnerRemedy::StopStandaloneOwner { .. } => { + return Err(MacosOwnerExecutionError::new( + "offline-owner status only permits an external service start", + )); + } + }; + if owner != status.selected_owner { + return Err(MacosOwnerExecutionError::new( + "offline-owner remedy does not match the selected owner", + )); + } + start_owner(owner)?; + Ok(PendingOfflineRemedy { + status, + owner, + after_epoch, + }) +} + +#[cfg(target_os = "macos")] +fn complete_offline_remedy_with( + state: &SupervisorState, + pending: PendingOfflineRemedy, + authoritative_owner_converged: bool, +) -> Result { + if !authoritative_owner_converged { + return Err(MacosOwnerExecutionError::new( + "selected macOS daemon owner did not publish a newer healthy epoch within ten seconds", + )); + } + if !state.clear_macos_owner_offline_if(pending.status) { + return Err(MacosOwnerExecutionError::new( + "offline-owner status changed while the selected owner was starting", + )); + } + Ok(MacosDaemonOwnerRemedyOutcome::Started { + owner: pending.owner, + }) +} + +#[cfg(target_os = "macos")] +fn choose_daemon_owner_inner( + app: &AppHandle, + state: SupervisorState, + requested_owner: MacosDaemonOwner, +) -> Result { + use hypercolor_core::config::paths::data_dir; + use hypercolor_macos_owner::{ + MacosHandoverTransactionId, MacosOwnerStore, choose_daemon_owner, + }; + + let store = MacosOwnerStore::new(data_dir()); + let daemon_url = std::env::var("HYPERCOLOR_URL") + .unwrap_or_else(|_| crate::DEFAULT_DAEMON_URL.to_owned()) + .parse() + .map_err(|error| anyhow::anyhow!("invalid HYPERCOLOR_URL: {error}"))?; + let mut executor = + AppOwnerExecutor::new(app.clone(), state.clone(), daemon_url, store.clone())?; + let transaction_id = MacosHandoverTransactionId::new(format!( + "owner-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ))?; + let outcome = choose_daemon_owner(&store, &mut executor, requested_owner, transaction_id) + .map_err(anyhow::Error::from)?; + if let MacosOwnerCoordinatorOutcome::Active { owner, .. } = outcome { + state.set_macos_external_owner(match owner { + MacosDaemonOwner::DirectLaunchd => { + Some(hypercolor_macos_owner::MacosExternalOwnerMode::DirectLaunchd) + } + MacosDaemonOwner::Homebrew => { + Some(hypercolor_macos_owner::MacosExternalOwnerMode::Homebrew) + } + MacosDaemonOwner::AppSidecar | MacosDaemonOwner::Standalone => None, + }); + state.set_macos_owner_offline(None); + } + Ok(outcome) +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MacosStartupRecoveryDisposition { + Continue, + SupervisorStarted, + SuppressSupervisor, +} + +#[cfg(target_os = "macos")] +pub(crate) fn recover_daemon_owner_before_supervisor( + app: &AppHandle, + state: SupervisorState, + daemon_url: url::Url, + store: hypercolor_macos_owner::MacosOwnerStore, +) -> Result { + let mut executor = AppOwnerExecutor::new(app.clone(), state, daemon_url, store.clone())?; + let outcome = hypercolor_macos_owner::recover_daemon_owner(&store, &mut executor)?; + Ok(startup_recovery_disposition( + outcome.as_ref(), + executor.app_sidecar_supervisor_started, + )) +} + +#[cfg(target_os = "macos")] +fn startup_recovery_disposition( + outcome: Option<&MacosOwnerCoordinatorOutcome>, + app_sidecar_supervisor_started: bool, +) -> MacosStartupRecoveryDisposition { + if app_sidecar_supervisor_started { + MacosStartupRecoveryDisposition::SupervisorStarted + } else if matches!( + outcome, + Some(MacosOwnerCoordinatorOutcome::PendingStandalone { .. }) + ) { + MacosStartupRecoveryDisposition::SuppressSupervisor + } else { + MacosStartupRecoveryDisposition::Continue + } +} + +#[cfg(target_os = "macos")] +struct AppOwnerExecutor { + app: AppHandle, + state: SupervisorState, + daemon_url: url::Url, + store: hypercolor_macos_owner::MacosOwnerStore, + uid: String, + launch_agents: std::path::PathBuf, + app_sidecar_supervisor_started: bool, +} + +#[cfg(target_os = "macos")] +impl AppOwnerExecutor { + fn new( + app: AppHandle, + state: SupervisorState, + daemon_url: url::Url, + store: hypercolor_macos_owner::MacosOwnerStore, + ) -> Result { + let uid = command_stdout("/usr/bin/id", &["-u"])?; + let launch_agents = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("failed to resolve the user home directory"))? + .join("Library/LaunchAgents"); + Ok(Self { + app, + state, + daemon_url, + store, + uid, + launch_agents, + app_sidecar_supervisor_started: false, + }) + } + + fn service_target(&self, owner: MacosDaemonOwner) -> Result { + Ok(format!("gui/{}/{}", self.uid, service_label(owner)?)) + } + + fn service_plist( + &self, + owner: MacosDaemonOwner, + ) -> Result { + Ok(self + .launch_agents + .join(format!("{}.plist", service_label(owner)?))) + } + + fn service_autostart_enabled( + &self, + owner: MacosDaemonOwner, + ) -> Result { + let plist = self.service_plist(owner)?; + if !plist.is_file() { + return Ok(false); + } + let output = command_output( + "/bin/launchctl", + &["print-disabled", &format!("gui/{}", self.uid)], + )?; + if !output.status.success() { + return Err(MacosOwnerExecutionError::new( + "launchctl failed to inspect service autostart state", + )); + } + Ok(!launchctl_service_disabled( + &String::from_utf8_lossy(&output.stdout), + service_label(owner)?, + )) + } +} + +#[cfg(target_os = "macos")] +impl hypercolor_macos_owner::MacosOwnerExecutor for AppOwnerExecutor { + fn autostart_enabled( + &mut self, + owner: MacosDaemonOwner, + ) -> Result { + use tauri_plugin_autostart::ManagerExt; + + match owner { + MacosDaemonOwner::AppSidecar => self + .app + .autolaunch() + .is_enabled() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) + .and_then(|enabled| { + if !enabled { + Ok(false) + } else { + let output = command_output( + "/bin/launchctl", + &["print-disabled", &format!("gui/{}", self.uid)], + )?; + if !output.status.success() { + return Err(MacosOwnerExecutionError::new( + "launchctl failed to inspect app autostart state", + )); + } + Ok(!launchctl_service_disabled( + &String::from_utf8_lossy(&output.stdout), + MACOS_APP_PRODUCT_NAME, + )) + } + }), + MacosDaemonOwner::DirectLaunchd | MacosDaemonOwner::Homebrew => { + self.service_autostart_enabled(owner) + } + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone has no autostart state", + )), + } + } + + fn set_autostart( + &mut self, + owner: MacosDaemonOwner, + enabled: bool, + ) -> Result<(), MacosOwnerExecutionError> { + use tauri_plugin_autostart::ManagerExt; + + match owner { + MacosDaemonOwner::AppSidecar => { + update_app_sidecar_gate_for_autostart(&self.state, enabled); + if enabled { + self.app + .autolaunch() + .enable() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + run_command( + "/bin/launchctl", + &[ + "enable", + &format!("gui/{}/{}", self.uid, MACOS_APP_PRODUCT_NAME), + ], + ) + } else { + run_command( + "/bin/launchctl", + &[ + "disable", + &format!("gui/{}/{}", self.uid, MACOS_APP_PRODUCT_NAME), + ], + )?; + self.app + .autolaunch() + .disable() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) + } + } + MacosDaemonOwner::DirectLaunchd | MacosDaemonOwner::Homebrew => { + let action = if enabled { "enable" } else { "disable" }; + run_command("/bin/launchctl", &[action, &self.service_target(owner)?]) + } + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone has no autostart state", + )), + } + } + + fn flush_and_stop( + &mut self, + owner: MacosDaemonOwner, + pid: Option, + ) -> Result<(), MacosOwnerExecutionError> { + if owner == MacosDaemonOwner::AppSidecar { + hold_app_sidecar_supervisor(&self.state); + } + if owner == MacosDaemonOwner::Standalone { + return Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )); + } + let pid = pid + .or_else(|| { + self.store + .load_owner_record() + .ok() + .flatten() + .filter(|record| record.active_owner == owner) + .map(|record| record.active_identity.pid) + }) + .or_else(|| { + (owner == MacosDaemonOwner::AppSidecar) + .then(|| self.state.child_pid()) + .flatten() + }); + if let Some(pid) = pid { + return hypercolor_macos_owner::terminate_macos_owner_process(pid); + } + let Some(target) = fallback_service_stop_target(owner, &self.uid)? else { + return Ok(()); + }; + let output = command_output("/bin/launchctl", &["print", &target])?; + if !output.status.success() { + return Ok(()); + } + run_command("/bin/launchctl", &["kill", "SIGTERM", &target]) + } + + fn start(&mut self, owner: MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError> { + match owner { + MacosDaemonOwner::AppSidecar => { + crate::supervisor::start_app_sidecar_for_handover( + &self.app, + self.daemon_url.clone(), + ) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + release_app_sidecar_supervisor(&self.state); + self.app_sidecar_supervisor_started = true; + Ok(()) + } + MacosDaemonOwner::DirectLaunchd => { + let target = self.service_target(owner)?; + if command_output("/bin/launchctl", &["print", &target])? + .status + .success() + { + run_command("/bin/launchctl", &["kickstart", &target]) + } else { + let plist = self.service_plist(owner)?; + run_command( + "/bin/launchctl", + &[ + "bootstrap", + &format!("gui/{}", self.uid), + &plist.to_string_lossy(), + ], + ) + } + } + MacosDaemonOwner::Homebrew => { + let brew = homebrew_binary()?; + run_command( + &brew.to_string_lossy(), + &["services", "start", "hypercolor"], + ) + } + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone cannot be started by the app coordinator", + )), + } + } + + fn wait_for_guard_release( + &mut self, + pid: u32, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_macos_guard_release( + pid, + timeout, + &std::env::temp_dir() + .join("hypercolor-daemon.lock") + .to_string_lossy(), + ) + } + + fn wait_for_owner( + &mut self, + owner: MacosDaemonOwner, + after_epoch: u64, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_owner_publication(&self.store, owner, after_epoch, timeout) + } +} + +#[cfg(target_os = "macos")] +fn update_app_sidecar_gate_for_autostart(state: &SupervisorState, enabled: bool) { + if !enabled { + hold_app_sidecar_supervisor(state); + } +} + +#[cfg(target_os = "macos")] +fn hold_app_sidecar_supervisor(state: &SupervisorState) { + state.set_owner_handover_stop(true); +} + +#[cfg(target_os = "macos")] +fn release_app_sidecar_supervisor(state: &SupervisorState) { + state.set_owner_handover_stop(false); +} + +#[cfg(target_os = "macos")] +fn fallback_service_stop_target( + owner: MacosDaemonOwner, + uid: &str, +) -> Result, MacosOwnerExecutionError> { + if owner == MacosDaemonOwner::AppSidecar { + return Ok(None); + } + Ok(Some(format!("gui/{uid}/{}", service_label(owner)?))) +} + +#[cfg(target_os = "macos")] +fn service_label(owner: MacosDaemonOwner) -> Result<&'static str, MacosOwnerExecutionError> { + match owner { + MacosDaemonOwner::AppSidecar => Ok(MACOS_APP_PRODUCT_NAME), + MacosDaemonOwner::DirectLaunchd => Ok("tech.hyperbliss.hypercolor"), + MacosDaemonOwner::Homebrew => Ok("homebrew.mxcl.hypercolor"), + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "owner does not use a service label", + )), + } +} + +#[cfg(target_os = "macos")] +fn launchctl_service_disabled(output: &str, label: &str) -> bool { + output.lines().any(|line| { + let line = line.trim(); + line.contains(&format!("\"{label}\"")) && line.ends_with("=> true") + }) +} + +#[cfg(target_os = "macos")] +fn homebrew_binary() -> Result { + ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] + .into_iter() + .map(std::path::PathBuf::from) + .find(|path| path.is_file()) + .ok_or_else(|| MacosOwnerExecutionError::new("Homebrew executable is unavailable")) +} + +#[cfg(target_os = "macos")] +fn command_stdout(program: &str, args: &[&str]) -> Result { + let output = std::process::Command::new(program).args(args).output()?; + if !output.status.success() { + anyhow::bail!("{program} failed with {}", output.status); + } + if output.stdout.len() > 64 * 1024 { + anyhow::bail!("{program} output exceeds 64 KiB"); + } + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +#[cfg(target_os = "macos")] +fn command_output( + program: &str, + args: &[&str], +) -> Result { + std::process::Command::new(program) + .args(args) + .output() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) +} + +#[cfg(target_os = "macos")] +fn run_command(program: &str, args: &[&str]) -> Result<(), MacosOwnerExecutionError> { + let output = command_output(program, args)?; + if output.status.success() { + Ok(()) + } else { + let mut stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + stderr.truncate(4_096); + Err(MacosOwnerExecutionError::new(format!( + "{program} failed with {}: {}", + output.status, + stderr.trim() + ))) + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use std::time::Duration; + + use super::{ + MacosCaptureOwner, MacosCaptureOwnerRestartOutcome, MacosDaemonOwnerRemedyOutcome, + MacosStartupRecoveryDisposition, complete_offline_remedy_with, execute_offline_remedy_with, + fallback_service_stop_target, hold_app_sidecar_supervisor, launchctl_service_disabled, + release_app_sidecar_supervisor, restart_capture_owner_with, service_label, + startup_recovery_disposition, update_app_sidecar_gate_for_autostart, + }; + use hypercolor_macos_owner::{ + MacosDaemonOwner, MacosHandoverOperation, MacosOwnerCoordinatorOutcome, + MacosOwnerExecutionError, MacosOwnerExecutor, MacosOwnerIdentity, MacosOwnerRemedy, + MacosOwnerStore, + }; + + use crate::supervisor::{MacosDaemonOwnerOfflineStatus, SupervisorState}; + + struct RestartFixtureExecutor { + store: MacosOwnerStore, + operations: Vec, + next_pid: u32, + guard_released: bool, + } + + impl RestartFixtureExecutor { + fn new(store: MacosOwnerStore) -> Self { + Self { + store, + operations: Vec::new(), + next_pid: 1_000, + guard_released: true, + } + } + } + + impl MacosOwnerExecutor for RestartFixtureExecutor { + fn autostart_enabled( + &mut self, + _owner: MacosDaemonOwner, + ) -> Result { + Ok(false) + } + + fn set_autostart( + &mut self, + _owner: MacosDaemonOwner, + _enabled: bool, + ) -> Result<(), MacosOwnerExecutionError> { + Err(MacosOwnerExecutionError::new( + "restart must not mutate autostart", + )) + } + + fn flush_and_stop( + &mut self, + owner: MacosDaemonOwner, + _pid: Option, + ) -> Result<(), MacosOwnerExecutionError> { + self.operations.push(match owner { + MacosDaemonOwner::AppSidecar => MacosHandoverOperation::FlushAndStopAppSidecar {}, + MacosDaemonOwner::DirectLaunchd => { + MacosHandoverOperation::FlushAndStopDirectLaunchd {} + } + MacosDaemonOwner::Homebrew => MacosHandoverOperation::FlushAndStopHomebrew {}, + MacosDaemonOwner::Standalone => { + return Err(MacosOwnerExecutionError::new( + "standalone restart must remain user-directed", + )); + } + }); + Ok(()) + } + + fn start(&mut self, owner: MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError> { + self.operations.push(match owner { + MacosDaemonOwner::AppSidecar => MacosHandoverOperation::StartAppSidecar {}, + MacosDaemonOwner::DirectLaunchd => MacosHandoverOperation::StartDirectLaunchd {}, + MacosDaemonOwner::Homebrew => MacosHandoverOperation::StartHomebrew {}, + MacosDaemonOwner::Standalone => { + return Err(MacosOwnerExecutionError::new( + "standalone restart must remain user-directed", + )); + } + }); + self.next_pid += 1; + self.store + .publish_owner( + owner, + MacosOwnerIdentity::new( + "restart-audit", + "/fixture/hypercolor-daemon", + "restart-requirement", + self.next_pid, + ) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?, + ) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + Ok(()) + } + + fn wait_for_guard_release( + &mut self, + _pid: u32, + _timeout: Duration, + ) -> Result { + Ok(self.guard_released) + } + + fn wait_for_owner( + &mut self, + owner: MacosDaemonOwner, + after_epoch: u64, + _timeout: Duration, + ) -> Result { + Ok(self.store.load_owner_record().is_ok_and(|record| { + record.is_some_and(|record| { + record.active_owner == owner && record.owner_epoch > after_epoch + }) + })) + } + } + + fn restart_identity(pid: u32) -> MacosOwnerIdentity { + MacosOwnerIdentity::new( + "active-audit", + "/fixture/active-hypercolor-daemon", + "active-requirement", + pid, + ) + .expect("fixture identity should build") + } + + #[test] + fn disabled_service_parser_is_exact_to_the_requested_label() { + let output = r#"disabled services = { + "tech.hyperbliss.hypercolor" => true + "homebrew.mxcl.hypercolor" => false + }"#; + assert!(launchctl_service_disabled( + output, + "tech.hyperbliss.hypercolor" + )); + assert!(!launchctl_service_disabled( + output, + "homebrew.mxcl.hypercolor" + )); + } + + #[test] + fn app_sidecar_service_label_matches_tauri_product_name() { + assert_eq!( + service_label(MacosDaemonOwner::AppSidecar).expect("app label should resolve"), + hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME + ); + } + + #[test] + fn app_sidecar_gate_stays_held_until_explicit_start() { + let state = SupervisorState::default(); + update_app_sidecar_gate_for_autostart(&state, true); + assert!(!state.owner_handover_stop()); + + hold_app_sidecar_supervisor(&state); + update_app_sidecar_gate_for_autostart(&state, true); + assert!(state.owner_handover_stop()); + + release_app_sidecar_supervisor(&state); + assert!(!state.owner_handover_stop()); + update_app_sidecar_gate_for_autostart(&state, false); + assert!(state.owner_handover_stop()); + } + + #[test] + fn app_sidecar_without_authoritative_pid_has_no_launchctl_stop_target() { + assert_eq!( + fallback_service_stop_target(MacosDaemonOwner::AppSidecar, "501") + .expect("app sidecar fallback should resolve"), + None + ); + assert_eq!( + fallback_service_stop_target(MacosDaemonOwner::DirectLaunchd, "501") + .expect("launchd fallback should resolve"), + Some("gui/501/tech.hyperbliss.hypercolor".to_owned()) + ); + } + + #[test] + fn exact_offline_remedy_clears_only_after_new_healthy_owner_epoch() { + let state = SupervisorState::default(); + let status = MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::DirectLaunchd, + remedy: MacosOwnerRemedy::StartLaunchdService, + }; + state.set_macos_owner_offline(Some(status)); + + let pending = execute_offline_remedy_with( + &state, + MacosOwnerRemedy::StartLaunchdService, + 7, + |owner| { + assert_eq!(owner, MacosDaemonOwner::DirectLaunchd); + Ok(()) + }, + ) + .expect("matching remedy should start"); + + assert_eq!(pending.status, status); + assert_eq!(pending.owner, MacosDaemonOwner::DirectLaunchd); + assert_eq!(pending.after_epoch, 7); + assert_eq!(state.macos_owner_offline(), Some(status)); + assert!(complete_offline_remedy_with(&state, pending, false).is_err()); + assert_eq!(state.macos_owner_offline(), Some(status)); + let outcome = complete_offline_remedy_with(&state, pending, true) + .expect("new authoritative owner epoch should complete the remedy"); + assert_eq!( + outcome, + MacosDaemonOwnerRemedyOutcome::Started { + owner: MacosDaemonOwner::DirectLaunchd + } + ); + assert_eq!(state.macos_owner_offline(), None); + } + + #[test] + fn failed_or_stale_offline_remedy_preserves_status() { + let state = SupervisorState::default(); + let status = MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::Homebrew, + remedy: MacosOwnerRemedy::StartHomebrewService, + }; + state.set_macos_owner_offline(Some(status)); + + assert!( + execute_offline_remedy_with( + &state, + MacosOwnerRemedy::StartLaunchdService, + 7, + |_| panic!("stale remedy must not execute"), + ) + .is_err() + ); + assert_eq!(state.macos_owner_offline(), Some(status)); + assert!( + execute_offline_remedy_with( + &state, + MacosOwnerRemedy::StartHomebrewService, + 7, + |_| Err(MacosOwnerExecutionError::new("injected start failure")), + ) + .is_err() + ); + assert_eq!(state.macos_owner_offline(), Some(status)); + } + + #[test] + fn capture_owner_restart_revalidates_and_publishes_a_new_epoch() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner(MacosDaemonOwner::DirectLaunchd, restart_identity(42)) + .expect("fixture owner should publish"); + let mut executor = RestartFixtureExecutor::new(store.clone()); + + let outcome = restart_capture_owner_with( + &store, + &mut executor, + record.active_owner, + record.owner_epoch, + ) + .expect("managed owner should restart"); + + assert_eq!( + outcome, + MacosCaptureOwnerRestartOutcome::Restarted { + owner: MacosCaptureOwner::LaunchdService, + previous_owner_epoch: 1, + owner_epoch: 2, + } + ); + assert_eq!( + executor.operations, + [ + MacosHandoverOperation::FlushAndStopDirectLaunchd {}, + MacosHandoverOperation::StartDirectLaunchd {}, + ] + ); + } + + #[test] + fn capture_owner_restart_rejects_stale_epoch_and_wrong_owner_without_mutation() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner(MacosDaemonOwner::Homebrew, restart_identity(42)) + .expect("fixture owner should publish"); + let mut executor = RestartFixtureExecutor::new(store.clone()); + + assert!( + restart_capture_owner_with( + &store, + &mut executor, + MacosDaemonOwner::Homebrew, + record.owner_epoch + 1, + ) + .is_err() + ); + assert!( + restart_capture_owner_with( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + record.owner_epoch, + ) + .is_err() + ); + assert!(executor.operations.is_empty()); + } + + #[test] + fn failed_app_sidecar_restart_rearms_the_supervisor_after_stop() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner(MacosDaemonOwner::AppSidecar, restart_identity(42)) + .expect("fixture owner should publish"); + let mut executor = RestartFixtureExecutor::new(store.clone()); + executor.guard_released = false; + + assert!( + restart_capture_owner_with( + &store, + &mut executor, + record.active_owner, + record.owner_epoch, + ) + .is_err() + ); + assert_eq!( + executor.operations, + [ + MacosHandoverOperation::FlushAndStopAppSidecar {}, + MacosHandoverOperation::StartAppSidecar {}, + ] + ); + assert!( + store + .load_owner_record() + .expect("owner record should load") + .is_some_and(|current| { + current.active_owner == MacosDaemonOwner::AppSidecar + && current.owner_epoch > record.owner_epoch + }) + ); + } + + #[test] + fn standalone_capture_owner_restart_returns_typed_user_remedy() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner(MacosDaemonOwner::Standalone, restart_identity(77)) + .expect("fixture owner should publish"); + let mut executor = RestartFixtureExecutor::new(store.clone()); + + let outcome = restart_capture_owner_with( + &store, + &mut executor, + record.active_owner, + record.owner_epoch, + ) + .expect("standalone owner should return a local remedy"); + + assert_eq!( + outcome, + MacosCaptureOwnerRestartOutcome::UserActionRequired { + owner: MacosCaptureOwner::Standalone, + owner_epoch: 1, + remedy: MacosOwnerRemedy::RestartStandalone { pid: 77 }, + } + ); + assert_eq!( + serde_json::to_value(outcome).expect("restart outcome should serialize"), + serde_json::json!({ + "status": "user_action_required", + "owner": "standalone", + "owner_epoch": 1, + "remedy": { + "kind": "restart_standalone", + "pid": 77 + } + }) + ); + assert!(executor.operations.is_empty()); + } + + #[test] + fn startup_recovery_suppresses_normal_watchdog_until_pending_standalone_exits() { + let pending = MacosOwnerCoordinatorOutcome::PendingStandalone { + requested_owner: MacosDaemonOwner::Homebrew, + remedy: MacosOwnerRemedy::StopStandaloneOwner { pid: 42 }, + }; + assert_eq!( + startup_recovery_disposition(Some(&pending), false), + MacosStartupRecoveryDisposition::SuppressSupervisor + ); + assert_eq!( + startup_recovery_disposition(None, false), + MacosStartupRecoveryDisposition::Continue + ); + assert_eq!( + startup_recovery_disposition(Some(&pending), true), + MacosStartupRecoveryDisposition::SupervisorStarted + ); + } +} diff --git a/crates/hypercolor-app/src/supervisor/mod.rs b/crates/hypercolor-app/src/supervisor/mod.rs index f1d4ff4fd..64ef2daa2 100644 --- a/crates/hypercolor-app/src/supervisor/mod.rs +++ b/crates/hypercolor-app/src/supervisor/mod.rs @@ -10,7 +10,11 @@ use std::{ use anyhow::{Context, Result}; use hypercolor_core::config::paths::data_dir; -use tauri::{AppHandle, Manager, Runtime}; +use hypercolor_macos_owner::{ + MacosDaemonOwner, MacosExternalOwnerMode, MacosOwnerRemedy, MacosOwnerStore, +}; +use hypercolor_types::event::MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE; +use tauri::{AppHandle, Emitter, Manager, Runtime}; use url::Url; /// Default daemon bind address used by the app-spawned daemon. @@ -87,6 +91,17 @@ pub struct SupervisorState { /// The tray reads this to surface the red `IconState::Error` so users /// know the supervisor has given up trying to restart the daemon. permanent_failure: Arc, + owner_handover_stop: Arc, + macos_external_owner: Arc>>, + macos_owner_offline: Arc>>, +} + +/// App-local status when a persisted external owner is not reachable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub struct MacosDaemonOwnerOfflineStatus { + pub code: &'static str, + pub selected_owner: MacosDaemonOwner, + pub remedy: MacosOwnerRemedy, } impl SupervisorState { @@ -104,6 +119,63 @@ impl SupervisorState { .load(std::sync::atomic::Ordering::Acquire) } + /// Return the persisted external owner selected before watchdog startup. + #[must_use] + pub fn macos_external_owner(&self) -> Option { + *self + .macos_external_owner + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Return the topology-specific offline status for the app bridge. + #[must_use] + pub fn macos_owner_offline(&self) -> Option { + *self + .macos_owner_offline + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn set_owner_handover_stop(&self, stopping: bool) { + self.owner_handover_stop + .store(stopping, std::sync::atomic::Ordering::Release); + } + + pub(crate) fn owner_handover_stop(&self) -> bool { + self.owner_handover_stop + .load(std::sync::atomic::Ordering::Acquire) + } + + pub(crate) fn set_macos_external_owner(&self, owner: Option) { + *self + .macos_external_owner + .lock() + .unwrap_or_else(PoisonError::into_inner) = owner; + } + + pub(crate) fn set_macos_owner_offline(&self, status: Option) { + *self + .macos_owner_offline + .lock() + .unwrap_or_else(PoisonError::into_inner) = status; + } + + pub(crate) fn clear_macos_owner_offline_if( + &self, + status: MacosDaemonOwnerOfflineStatus, + ) -> bool { + let mut current = self + .macos_owner_offline + .lock() + .unwrap_or_else(PoisonError::into_inner); + if *current != Some(status) { + return false; + } + *current = None; + true + } + fn replace_child_pid(&self, pid: u32) { *self.child_guard() = Some(pid); } @@ -355,6 +427,9 @@ pub fn build_daemon_command( ) -> DaemonCommand { let mut args = vec!["--bind".to_owned(), bind.to_owned()]; + #[cfg(target_os = "macos")] + args.extend(["--macos-owner".to_owned(), "app-sidecar".to_owned()]); + if let Some(ui_dir) = ui_dir { args.push("--ui-dir".to_owned()); args.push(ui_dir.display().to_string()); @@ -391,6 +466,133 @@ pub fn health_url(base: &Url) -> Url { .expect("static health endpoint path should be valid") } +#[cfg(target_os = "macos")] +fn system_status_url(base: &Url) -> Url { + base.join("/api/v1/status") + .expect("static system-status endpoint path should be valid") +} + +#[cfg(target_os = "macos")] +#[derive(Debug, serde::Deserialize)] +struct SystemStatusEnvelope { + data: SystemStatusData, +} + +#[cfg(target_os = "macos")] +#[derive(Debug, serde::Deserialize)] +struct SystemStatusData { + macos_daemon_ownership: Option, +} + +#[cfg(target_os = "macos")] +#[derive(Debug, serde::Deserialize)] +struct SystemStatusMacosOwnership { + active_owner: SystemStatusMacosOwner, + owner_epoch: u64, +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum SystemStatusMacosOwner { + AppSidecar, + App, + LaunchdService, + HomebrewService, + Broker, + Standalone, +} + +#[cfg(target_os = "macos")] +const fn system_status_owner_matches( + selected_owner: MacosDaemonOwner, + observed_owner: SystemStatusMacosOwner, +) -> bool { + matches!( + (selected_owner, observed_owner), + ( + MacosDaemonOwner::AppSidecar, + SystemStatusMacosOwner::AppSidecar + ) | ( + MacosDaemonOwner::DirectLaunchd, + SystemStatusMacosOwner::LaunchdService + ) | ( + MacosDaemonOwner::Homebrew, + SystemStatusMacosOwner::HomebrewService + ) | ( + MacosDaemonOwner::Standalone, + SystemStatusMacosOwner::Standalone + ) + ) +} + +#[cfg(target_os = "macos")] +const fn authoritative_owner_matches( + selected_owner: MacosDaemonOwner, + after_epoch: Option, + ownership: &SystemStatusMacosOwnership, +) -> bool { + system_status_owner_matches(selected_owner, ownership.active_owner) + && match after_epoch { + Some(epoch) => ownership.owner_epoch > epoch, + None => true, + } +} + +#[cfg(target_os = "macos")] +async fn probe_authoritative_macos_owner( + client: &reqwest::Client, + base: &Url, + selected_owner: MacosDaemonOwner, + after_epoch: Option, +) -> bool { + if !probe_health(client, base, HEALTH_PROBE_TIMEOUT).await { + return false; + } + let response = client + .get(system_status_url(base)) + .timeout(HEALTH_PROBE_TIMEOUT) + .send() + .await; + let Ok(response) = response else { + return false; + }; + if !response.status().is_success() { + return false; + } + response + .json::() + .await + .ok() + .and_then(|envelope| envelope.data.macos_daemon_ownership) + .is_some_and(|ownership| { + authoritative_owner_matches(selected_owner, after_epoch, &ownership) + }) +} + +#[cfg(target_os = "macos")] +pub(crate) async fn wait_for_authoritative_macos_owner( + client: &reqwest::Client, + base: &Url, + selected_owner: MacosDaemonOwner, + after_epoch: Option, + timeout: Duration, +) -> bool { + let started = Instant::now(); + loop { + if probe_authoritative_macos_owner(client, base, selected_owner, after_epoch).await { + return true; + } + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return false; + }; + let Some(delay) = startup_retry_delay(remaining, DAEMON_STARTUP_POLL_INTERVAL) else { + return false; + }; + tokio::time::sleep(delay).await; + } +} + /// Probe whether a daemon is already accepting requests. pub async fn probe_health(client: &reqwest::Client, base: &Url, timeout: Duration) -> bool { let response = client.get(health_url(base)).timeout(timeout).send().await; @@ -474,6 +676,54 @@ fn first_systemctl_output_line(output: &str) -> &str { /// /// Returns an error if the app executable path or daemon URL cannot be resolved. pub fn start(app: &AppHandle, daemon_url: Url) -> Result<()> { + #[cfg(target_os = "macos")] + { + let store = MacosOwnerStore::new(data_dir()); + let state = app.state::().inner().clone(); + match crate::ownership::recover_daemon_owner_before_supervisor( + app, + state, + daemon_url.clone(), + store.clone(), + )? { + crate::ownership::MacosStartupRecoveryDisposition::Continue => {} + crate::ownership::MacosStartupRecoveryDisposition::SupervisorStarted + | crate::ownership::MacosStartupRecoveryDisposition::SuppressSupervisor => { + return Ok(()); + } + } + let external_owner = selected_external_owner_for_startup(&store)?; + start_with_external_owner(app, daemon_url, external_owner) + } + #[cfg(not(target_os = "macos"))] + { + start_with_external_owner(app, daemon_url, None) + } +} + +#[cfg(target_os = "macos")] +fn selected_external_owner_for_startup( + store: &MacosOwnerStore, +) -> Result> { + Ok(store + .load_owner_record() + .context("failed to read the selected macOS daemon owner before supervisor startup")? + .and_then(|record| record.selected_external_owner)) +} + +#[cfg(target_os = "macos")] +pub(crate) fn start_app_sidecar_for_handover( + app: &AppHandle, + daemon_url: Url, +) -> Result<()> { + start_with_external_owner(app, daemon_url, None) +} + +fn start_with_external_owner( + app: &AppHandle, + daemon_url: Url, + external_owner: Option, +) -> Result<()> { let current_exe = std::env::current_exe().context("failed to resolve app executable path")?; let resource_dir = app.path().resource_dir().ok(); let daemon_candidates = daemon_path_candidates(¤t_exe, resource_dir.as_deref()); @@ -491,14 +741,37 @@ pub fn start(app: &AppHandle, daemon_url: Url) -> Result<()> { .find(|path| path.is_dir()); let bind = bind_from_daemon_url(&daemon_url).unwrap_or_else(|| DEFAULT_DAEMON_BIND.to_owned()); let state = app.state::().inner().clone(); + state.set_macos_external_owner(external_owner); + let app = app.clone(); tauri::async_runtime::spawn(async move { let client = reqwest::Client::new(); - if probe_health(&client, &daemon_url, HEALTH_PROBE_TIMEOUT).await { + let authoritative_owner_online = if let Some(owner) = external_owner { + probe_authoritative_macos_owner(&client, &daemon_url, external_mode_owner(owner), None) + .await + } else { + probe_health(&client, &daemon_url, HEALTH_PROBE_TIMEOUT).await + }; + if authoritative_owner_online { + state.set_macos_owner_offline(None); tracing::info!(url = %daemon_url, "daemon already running; reusing existing instance"); return; } + if let Some(owner) = external_owner { + let status = macos_external_owner_offline(owner); + state.set_macos_owner_offline(Some(status)); + if let Err(error) = app.emit("macos_daemon_owner_offline", status) { + tracing::warn!(%error, "failed to publish app-local daemon owner status"); + } + tracing::warn!( + selected_owner = ?status.selected_owner, + remedy = ?status.remedy, + "persisted external macOS daemon owner is offline; sidecar remains suppressed" + ); + return; + } + #[cfg(target_os = "linux")] if try_start_systemd_user_service(&client, &daemon_url).await { return; @@ -540,6 +813,73 @@ pub const fn restart_backoff(attempt: u32) -> Duration { Duration::from_secs(secs) } +#[must_use] +pub fn is_terminal_daemon_exit_code(code: Option) -> bool { + cfg!(target_os = "macos") && code == Some(MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE) +} + +enum DaemonStartupOutcome { + Healthy, + Exited(std::process::ExitStatus), + TimedOut, +} + +#[derive(Debug, PartialEq, Eq)] +enum DaemonStartupObservation { + Healthy, + Exited(T), +} + +fn select_daemon_startup_observation( + child_exit: Option, + healthy: bool, +) -> Option> { + child_exit.map_or_else( + || healthy.then_some(DaemonStartupObservation::Healthy), + |status| Some(DaemonStartupObservation::Exited(status)), + ) +} + +fn poll_daemon_exit(daemon: &mut ManagedDaemon) -> Result> { + daemon + .child + .as_mut() + .context("daemon child is unavailable during startup")? + .try_wait() + .context("failed to poll daemon startup") +} + +async fn wait_for_daemon_startup( + client: &reqwest::Client, + base: &Url, + daemon: &mut ManagedDaemon, + timeout: Duration, + poll_interval: Duration, +) -> Result { + let started = Instant::now(); + loop { + if let Some(DaemonStartupObservation::Exited(status)) = + select_daemon_startup_observation(poll_daemon_exit(daemon)?, false) + { + return Ok(DaemonStartupOutcome::Exited(status)); + } + let healthy = probe_health(client, base, HEALTH_PROBE_TIMEOUT).await; + match select_daemon_startup_observation(poll_daemon_exit(daemon)?, healthy) { + Some(DaemonStartupObservation::Exited(status)) => { + return Ok(DaemonStartupOutcome::Exited(status)); + } + Some(DaemonStartupObservation::Healthy) => { + return Ok(DaemonStartupOutcome::Healthy); + } + None => {} + } + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Ok(DaemonStartupOutcome::TimedOut); + }; + tokio::time::sleep(poll_interval.min(remaining)).await; + } +} + /// Watchdog loop: keeps the daemon alive across crashes, with a /// circuit breaker that gives up after [`WATCHDOG_MAX_RAPID_RESTARTS`] /// restarts in [`WATCHDOG_FAILURE_WINDOW`]. @@ -568,6 +908,11 @@ async fn run_watchdog_loop( let mut window_anchor: Option = None; loop { + if state.owner_handover_stop() { + state.clear_child(); + tracing::info!("daemon watchdog suppressed for owner handover"); + return; + } if let Some(anchor) = window_anchor && anchor.elapsed() > WATCHDOG_FAILURE_WINDOW { @@ -592,7 +937,7 @@ async fn run_watchdog_loop( ui_dir.as_deref(), effects_dir.as_deref(), ); - let daemon = match spawn_daemon(&command) { + let mut daemon = match spawn_daemon(&command) { Ok(daemon) => daemon, Err(error) => { tracing::warn!(%error, attempt = restart_count + 1, "failed to spawn daemon"); @@ -609,22 +954,59 @@ async fn run_watchdog_loop( "supervisor: daemon spawned" ); - let healthy = wait_until_healthy( + let startup = wait_for_daemon_startup( &client, &daemon_url, + &mut daemon, DAEMON_STARTUP_TIMEOUT, DAEMON_STARTUP_POLL_INTERVAL, ) .await; - - if !healthy { - tracing::warn!( - pid, - timeout_ms = DAEMON_STARTUP_TIMEOUT.as_millis(), - "daemon did not become healthy before timeout; killing and retrying" - ); + let retry = match startup { + Ok(DaemonStartupOutcome::Healthy) => false, + Ok(DaemonStartupOutcome::Exited(status)) + if is_terminal_daemon_exit_code(status.code()) => + { + tracing::info!( + pid, + ?status, + "daemon ownership contender exited; supervisor will not restart it" + ); + state.clear_child(); + return; + } + Ok(DaemonStartupOutcome::Exited(status)) => { + tracing::warn!( + pid, + ?status, + "daemon exited before becoming healthy; supervisor will restart" + ); + true + } + Ok(DaemonStartupOutcome::TimedOut) => { + tracing::warn!( + pid, + timeout_ms = DAEMON_STARTUP_TIMEOUT.as_millis(), + "daemon did not become healthy before timeout; killing and retrying" + ); + true + } + Err(error) => { + tracing::warn!( + pid, + %error, + "daemon startup observation failed; supervisor will restart" + ); + true + } + }; + if retry { drop(daemon); state.clear_child(); + if state.owner_handover_stop() { + tracing::info!(pid, "daemon stopped for owner handover during startup"); + return; + } record_failure(&mut restart_count, &mut window_anchor); tokio::time::sleep(restart_backoff(restart_count)).await; continue; @@ -637,6 +1019,14 @@ async fn run_watchdog_loop( state.clear_child(); match exit { + Ok(status) if is_terminal_daemon_exit_code(status.code()) => { + tracing::info!( + pid, + ?status, + "daemon ownership contender exited after health observation; supervisor will not restart it" + ); + return; + } Ok(status) => tracing::warn!( pid, ?status, @@ -651,6 +1041,14 @@ async fn run_watchdog_loop( ), } + if state.owner_handover_stop() { + tracing::info!( + pid, + "daemon stopped for owner handover; watchdog remains suppressed" + ); + return; + } + if uptime >= WATCHDOG_STABLE_UPTIME { // Stable run — reset the budget so the next failure starts fresh. restart_count = 0; @@ -662,6 +1060,31 @@ async fn run_watchdog_loop( } } +const fn macos_external_owner_offline( + owner: MacosExternalOwnerMode, +) -> MacosDaemonOwnerOfflineStatus { + match owner { + MacosExternalOwnerMode::DirectLaunchd => MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::DirectLaunchd, + remedy: MacosOwnerRemedy::StartLaunchdService, + }, + MacosExternalOwnerMode::Homebrew => MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::Homebrew, + remedy: MacosOwnerRemedy::StartHomebrewService, + }, + } +} + +#[cfg(target_os = "macos")] +const fn external_mode_owner(owner: MacosExternalOwnerMode) -> MacosDaemonOwner { + match owner { + MacosExternalOwnerMode::DirectLaunchd => MacosDaemonOwner::DirectLaunchd, + MacosExternalOwnerMode::Homebrew => MacosDaemonOwner::Homebrew, + } +} + fn record_failure(count: &mut u32, anchor: &mut Option) { *count = count.saturating_add(1); if anchor.is_none() { @@ -669,6 +1092,188 @@ fn record_failure(count: &mut u32, anchor: &mut Option) { } } +#[cfg(test)] +mod tests { + use super::{ + DaemonStartupObservation, MacosDaemonOwnerOfflineStatus, macos_external_owner_offline, + select_daemon_startup_observation, + }; + use hypercolor_macos_owner::{MacosDaemonOwner, MacosExternalOwnerMode, MacosOwnerRemedy}; + + #[cfg(target_os = "macos")] + async fn authoritative_probe_fixture( + status_body: &'static str, + selected_owner: MacosDaemonOwner, + after_epoch: Option, + ) -> bool { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("fixture listener should bind"); + let address = listener + .local_addr() + .expect("fixture address should resolve"); + let server = tokio::spawn(async move { + for (expected_path, body) in [("/health", "{}"), ("/api/v1/status", status_body)] { + let (mut stream, _) = listener.accept().await.expect("request should connect"); + let mut request = [0_u8; 4_096]; + let read = stream + .read(&mut request) + .await + .expect("request should read"); + let request = std::str::from_utf8(&request[..read]) + .expect("request should contain UTF-8 headers"); + assert!(request.starts_with(&format!("GET {expected_path} HTTP/1.1"))); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + } + }); + let base = + url::Url::parse(&format!("http://{address}")).expect("fixture daemon URL should parse"); + let result = super::probe_authoritative_macos_owner( + &reqwest::Client::new(), + &base, + selected_owner, + after_epoch, + ) + .await; + server.await.expect("fixture server should finish"); + result + } + + #[test] + fn child_exit_precedes_shared_daemon_health() { + assert_eq!( + select_daemon_startup_observation(Some(73), true), + Some(DaemonStartupObservation::Exited(73)) + ); + assert_eq!( + select_daemon_startup_observation::(None, true), + Some(DaemonStartupObservation::Healthy) + ); + } + + #[test] + fn external_owner_offline_status_uses_stable_topology_remedies() { + assert_eq!( + macos_external_owner_offline(MacosExternalOwnerMode::DirectLaunchd), + MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::DirectLaunchd, + remedy: MacosOwnerRemedy::StartLaunchdService, + } + ); + assert_eq!( + macos_external_owner_offline(MacosExternalOwnerMode::Homebrew), + MacosDaemonOwnerOfflineStatus { + code: "macos_daemon_owner_offline", + selected_owner: MacosDaemonOwner::Homebrew, + remedy: MacosOwnerRemedy::StartHomebrewService, + } + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn authoritative_system_status_requires_exact_owner_and_newer_epoch() { + use super::{SystemStatusEnvelope, authoritative_owner_matches, system_status_url}; + + let base = url::Url::parse("http://127.0.0.1:9420").expect("URL should parse"); + assert_eq!( + system_status_url(&base).as_str(), + "http://127.0.0.1:9420/api/v1/status" + ); + + let launchd: SystemStatusEnvelope = serde_json::from_value(serde_json::json!({ + "data": { + "macos_daemon_ownership": { + "active_owner": "launchd_service", + "owner_epoch": 8 + } + } + })) + .expect("launchd status should decode"); + let launchd = launchd + .data + .macos_daemon_ownership + .expect("ownership should be present"); + assert!(authoritative_owner_matches( + MacosDaemonOwner::DirectLaunchd, + Some(7), + &launchd + )); + assert!(!authoritative_owner_matches( + MacosDaemonOwner::DirectLaunchd, + Some(8), + &launchd + )); + assert!(!authoritative_owner_matches( + MacosDaemonOwner::Homebrew, + None, + &launchd + )); + + let missing: SystemStatusEnvelope = serde_json::from_value(serde_json::json!({ + "data": { "macos_daemon_ownership": null } + })) + .expect("missing ownership status should decode"); + assert!(missing.data.macos_daemon_ownership.is_none()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn generic_health_does_not_satisfy_authoritative_owner_probe() { + let launchd = r#"{"data":{"macos_daemon_ownership":{"active_owner":"launchd_service","owner_epoch":8}}}"#; + let homebrew = r#"{"data":{"macos_daemon_ownership":{"active_owner":"homebrew_service","owner_epoch":9}}}"#; + + assert!( + authoritative_probe_fixture(launchd, MacosDaemonOwner::DirectLaunchd, Some(7)).await + ); + assert!( + !authoritative_probe_fixture(launchd, MacosDaemonOwner::DirectLaunchd, Some(8)).await + ); + assert!( + !authoritative_probe_fixture(homebrew, MacosDaemonOwner::DirectLaunchd, None).await + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn startup_reads_the_persisted_external_owner_mode() { + use hypercolor_macos_owner::{MacosOwnerIdentity, MacosOwnerStore}; + + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner( + MacosDaemonOwner::Homebrew, + MacosOwnerIdentity::new( + "audit-homebrew", + "/opt/homebrew/bin/hypercolor-daemon", + "requirement-homebrew", + 101, + ) + .expect("identity should build"), + ) + .expect("owner should publish"); + store + .set_external_owner_mode(Some(MacosExternalOwnerMode::Homebrew)) + .expect("external mode should persist"); + assert_eq!( + super::selected_external_owner_for_startup(&store) + .expect("startup selection should load"), + Some(MacosExternalOwnerMode::Homebrew) + ); + } +} + /// Block on a `ManagedDaemon` child until it exits. Runs the blocking /// `Child::wait()` on a dedicated thread so the watchdog task stays async. async fn wait_for_exit(daemon: ManagedDaemon) -> Result { diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 986548d94..eee8be5a4 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -37,8 +37,11 @@ const VERIFY_MACOS_DEPLOYMENT_TARGET_SH: &str = include_str!("../../../scripts/verify-macos-deployment-target.sh"); const SIGN_MACOS_ARTIFACTS_SH: &str = include_str!("../../../scripts/sign-macos-artifacts.sh"); const MACOS_SIGNING_MANIFEST: &str = include_str!("../../../packaging/macos/signing-manifest.tsv"); +const TAURI_CONFIG: &str = include_str!("../tauri.conf.json"); const MACOS_DAEMON_ENTITLEMENTS: &str = include_str!("../../../packaging/macos/daemon.entitlements.plist"); +const MACOS_LAUNCHD_PLIST: &str = + include_str!("../../../packaging/launchd/tech.hyperbliss.hypercolor.plist"); const STAGE_APP_BUNDLE_PS1: &str = include_str!("../../../scripts/stage-app-bundle-assets.ps1"); const STAGE_APP_BUNDLE_SH: &str = include_str!("../../../scripts/stage-app-bundle-assets.sh"); const INSTALLER_HOOKS_NSH: &str = include_str!("../installer-hooks.nsh"); @@ -122,6 +125,32 @@ fn macos_distribution_covers_arm64_and_amd64() { assert!(CI_WORKFLOW.contains("SHA256_MACOS_AMD64")); assert!(HOMEBREW_FORMULA.contains("SHA256_MACOS_AMD64")); assert!(HOMEBREW_FORMULA.contains("keep_alive successful_exit: false")); + assert!(HOMEBREW_FORMULA.contains(r#""--macos-owner", "homebrew""#)); +} + +#[test] +fn macos_launchers_identify_their_daemon_topology() { + assert!(MACOS_LAUNCHD_PLIST.contains("--macos-owner")); + assert!(MACOS_LAUNCHD_PLIST.contains("direct-launchd")); + assert!(HOMEBREW_FORMULA.contains(r#""--macos-owner", "homebrew""#)); +} + +#[test] +fn app_sidecar_identity_matches_tauri_and_signing_artifacts() { + let config: serde_json::Value = + serde_json::from_str(TAURI_CONFIG).expect("Tauri config should parse"); + assert_eq!( + config["productName"], + hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME + ); + assert_eq!( + hypercolor_macos_owner::MACOS_APP_LAUNCH_AGENT_PLIST_FILE_NAME, + format!("{}.plist", hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME) + ); + assert!(MACOS_SIGNING_MANIFEST.lines().any(|line| { + line.split('\t').nth(1) + == Some(hypercolor_macos_owner::MACOS_APP_BUNDLE_EXECUTABLE_RELATIVE_PATH) + })); } #[test] diff --git a/crates/hypercolor-app/tests/supervisor_tests.rs b/crates/hypercolor-app/tests/supervisor_tests.rs index 62fdf7d1f..e47bdc258 100644 --- a/crates/hypercolor-app/tests/supervisor_tests.rs +++ b/crates/hypercolor-app/tests/supervisor_tests.rs @@ -3,10 +3,10 @@ use std::path::Path; use hypercolor_app::supervisor::{ DEFAULT_DAEMON_BIND, SYSTEMD_USER_SERVICE, SupervisorState, SystemdUserServicePlan, SystemdUserServiceProbe, bind_from_daemon_url, build_daemon_command, daemon_executable_name, - daemon_path_candidates, health_url, macos_app_resource_dir, restart_backoff, - sibling_daemon_path, sibling_ui_dir, startup_retry_delay, systemctl_is_active_output, - systemctl_is_enabled_output, systemd_user_service_plan, target_triple_candidates, - tauri_sidecar_daemon_name, ui_dir_candidates, + daemon_path_candidates, health_url, is_terminal_daemon_exit_code, macos_app_resource_dir, + restart_backoff, sibling_daemon_path, sibling_ui_dir, startup_retry_delay, + systemctl_is_active_output, systemctl_is_enabled_output, systemd_user_service_plan, + target_triple_candidates, tauri_sidecar_daemon_name, ui_dir_candidates, }; use std::time::Duration; use url::Url; @@ -34,6 +34,18 @@ fn restart_backoff_grows_then_saturates() { assert_eq!(restart_backoff(100), Duration::from_secs(30)); } +#[test] +fn macos_owner_conflict_is_the_only_terminal_daemon_exit_code() { + assert_eq!( + is_terminal_daemon_exit_code(Some( + hypercolor_types::event::MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE + )), + cfg!(target_os = "macos") + ); + assert!(!is_terminal_daemon_exit_code(None)); + assert!(!is_terminal_daemon_exit_code(Some(1))); +} + #[test] fn sibling_paths_resolve_from_app_executable() { let app_path = if cfg!(target_os = "windows") { @@ -147,7 +159,7 @@ fn ui_dir_candidates_include_resource_dir_layouts() { #[test] fn candidates_include_macos_app_resources_from_contents_macos_exe() { - let app_path = Path::new("/Applications/Hypercolor.app/Contents/MacOS/hypercolor-app"); + let app_path = Path::new("/Applications/Hypercolor.app/Contents/MacOS/Hypercolor"); let resource_dir = macos_app_resource_dir(app_path).expect("resource dir should resolve"); assert!(normalized(&resource_dir).ends_with("Hypercolor.app/Contents/Resources")); @@ -172,14 +184,21 @@ fn build_daemon_command_includes_bind_ui_dir_and_effects_dir() { assert_eq!(command.program, Path::new("hypercolor-daemon")); assert_eq!( command.args, - vec![ + [ "--bind", DEFAULT_DAEMON_BIND, + #[cfg(target_os = "macos")] + "--macos-owner", + #[cfg(target_os = "macos")] + "app-sidecar", "--ui-dir", "ui", "--effects-dir", "effects" ] + .into_iter() + .map(str::to_owned) + .collect::>() ); } @@ -192,7 +211,18 @@ fn build_daemon_command_allows_missing_asset_dirs() { None, ); - assert_eq!(command.args, vec!["--bind", DEFAULT_DAEMON_BIND]); + let expected = [ + "--bind", + DEFAULT_DAEMON_BIND, + #[cfg(target_os = "macos")] + "--macos-owner", + #[cfg(target_os = "macos")] + "app-sidecar", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!(command.args, expected); } #[test] diff --git a/crates/hypercolor-cli/Cargo.toml b/crates/hypercolor-cli/Cargo.toml index 6c2e19733..6b42c1534 100644 --- a/crates/hypercolor-cli/Cargo.toml +++ b/crates/hypercolor-cli/Cargo.toml @@ -21,6 +21,7 @@ tui = ["dep:hypercolor-tui"] [dependencies] hypercolor-core = { workspace = true } +hypercolor-macos-owner = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/hypercolor-cli/src/commands/service.rs b/crates/hypercolor-cli/src/commands/service.rs index 8d033f92f..267738820 100644 --- a/crates/hypercolor-cli/src/commands/service.rs +++ b/crates/hypercolor-cli/src/commands/service.rs @@ -3,7 +3,7 @@ #[cfg(any(target_os = "linux", target_os = "macos"))] use anyhow::Context; use anyhow::{Result, bail}; -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; use crate::output::OutputContext; @@ -44,6 +44,27 @@ pub enum ServiceCommand { Disable, /// Show daemon logs. Logs(LogsArgs), + /// Select the local macOS daemon owner. + ChooseOwner(ChooseOwnerArgs), +} + +/// Arguments for `service choose-owner`. +#[derive(Debug, Args)] +pub struct ChooseOwnerArgs { + /// Installed service topology that should own the daemon. + #[arg(value_enum)] + pub owner: MacosServiceOwner, +} + +/// macOS service topologies selectable without the app UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum MacosServiceOwner { + /// Daemon supervised by the packaged Hypercolor app. + AppSidecar, + /// Hypercolor's directly installed per-user launchd service. + DirectLaunchd, + /// The Homebrew-managed per-user service. + Homebrew, } /// Arguments for `service logs`. @@ -83,9 +104,65 @@ pub async fn execute(args: &ServiceArgs, ctx: &OutputContext) -> Result<()> { ServiceCommand::Enable => execute_enable(ctx).await, ServiceCommand::Disable => execute_disable(ctx).await, ServiceCommand::Logs(logs_args) => execute_logs(logs_args, ctx).await, + ServiceCommand::ChooseOwner(owner_args) => execute_choose_owner(owner_args, ctx).await, } } +#[cfg(target_os = "macos")] +async fn execute_choose_owner(args: &ChooseOwnerArgs, ctx: &OutputContext) -> Result<()> { + use hypercolor_macos_owner::MacosDaemonOwner; + + let owner = match args.owner { + MacosServiceOwner::AppSidecar => MacosDaemonOwner::AppSidecar, + MacosServiceOwner::DirectLaunchd => MacosDaemonOwner::DirectLaunchd, + MacosServiceOwner::Homebrew => MacosDaemonOwner::Homebrew, + }; + let outcome = tokio::task::spawn_blocking(move || choose_owner_locally(owner)) + .await + .context("macOS daemon owner coordinator task failed")??; + if ctx.format == crate::output::OutputFormat::Json { + return ctx.print_json(&serde_json::to_value(&outcome)?); + } + match outcome { + hypercolor_macos_owner::MacosOwnerCoordinatorOutcome::Active { + owner, + owner_epoch, + } => ctx.success(&format!( + "macOS daemon owner is {owner:?} at epoch {owner_epoch}" + )), + hypercolor_macos_owner::MacosOwnerCoordinatorOutcome::PendingStandalone { + remedy: hypercolor_macos_owner::MacosOwnerRemedy::StopStandaloneOwner { pid }, + .. + } => ctx.warning(&format!( + "stop_standalone_owner: stop daemon PID {pid}, then repeat the command" + )), + hypercolor_macos_owner::MacosOwnerCoordinatorOutcome::PendingStandalone { + remedy, + .. + } => ctx.warning(&format!("macOS daemon owner handover is pending: {remedy:?}")), + hypercolor_macos_owner::MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner, + failure, + } => ctx.warning(&format!( + "owner handover rolled back to {prior_owner:?}: {failure}" + )), + hypercolor_macos_owner::MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner, + prior_owner, + phase, + } => ctx.warning(&format!( + "owner recovery remains pending: requested={requested_owner:?} prior={prior_owner:?} phase={phase:?}" + )), + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +#[expect(clippy::unused_async, reason = "async signature required by dispatch")] +async fn execute_choose_owner(_args: &ChooseOwnerArgs, _ctx: &OutputContext) -> Result<()> { + bail!("macOS daemon owner selection is unavailable on this platform") +} + // ── Linux (systemctl) ─────────────────────────────────────────────────── #[cfg(target_os = "linux")] @@ -430,6 +507,396 @@ fn get_uid() -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[cfg(target_os = "macos")] +fn choose_owner_locally( + requested_owner: hypercolor_macos_owner::MacosDaemonOwner, +) -> Result { + use hypercolor_core::config::paths::data_dir; + use hypercolor_macos_owner::{ + MacosHandoverTransactionId, MacosOwnerStore, choose_daemon_owner, + }; + + let store = MacosOwnerStore::new(data_dir()); + let mut executor = CliOwnerExecutor::new(store.clone())?; + let transaction_id = MacosHandoverTransactionId::new(format!( + "cli-owner-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ))?; + choose_daemon_owner(&store, &mut executor, requested_owner, transaction_id) + .map_err(anyhow::Error::from) +} + +#[cfg(target_os = "macos")] +struct CliOwnerExecutor { + store: hypercolor_macos_owner::MacosOwnerStore, + uid: String, + launch_agents: std::path::PathBuf, +} + +#[cfg(target_os = "macos")] +impl CliOwnerExecutor { + fn new(store: hypercolor_macos_owner::MacosOwnerStore) -> Result { + let uid = command_stdout("/usr/bin/id", &["-u"])?; + let launch_agents = dirs::home_dir() + .context("failed to resolve the user home directory")? + .join("Library/LaunchAgents"); + Ok(Self { + store, + uid, + launch_agents, + }) + } + + fn label( + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result<&'static str, hypercolor_macos_owner::MacosOwnerExecutionError> { + use hypercolor_macos_owner::{MacosDaemonOwner, MacosOwnerExecutionError}; + + match owner { + MacosDaemonOwner::AppSidecar => Ok(hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME), + MacosDaemonOwner::DirectLaunchd => Ok(LAUNCHD_LABEL), + MacosDaemonOwner::Homebrew => Ok("homebrew.mxcl.hypercolor"), + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone has no service label", + )), + } + } + + fn plist( + &self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result { + let file_name = if owner == hypercolor_macos_owner::MacosDaemonOwner::AppSidecar { + hypercolor_macos_owner::MACOS_APP_LAUNCH_AGENT_PLIST_FILE_NAME.to_owned() + } else { + format!("{}.plist", Self::label(owner)?) + }; + Ok(self.launch_agents.join(file_name)) + } + + fn target( + &self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result { + Ok(format!("gui/{}/{}", self.uid, Self::label(owner)?)) + } +} + +#[cfg(target_os = "macos")] +impl hypercolor_macos_owner::MacosOwnerExecutor for CliOwnerExecutor { + fn autostart_enabled( + &mut self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result { + let plist = self.plist(owner)?; + if !plist.is_file() { + return Ok(false); + } + let output = owner_command_output( + "/bin/launchctl", + &["print-disabled", &format!("gui/{}", self.uid)], + )?; + if !output.status.success() { + return Err(hypercolor_macos_owner::MacosOwnerExecutionError::new( + "launchctl failed to inspect service autostart state", + )); + } + Ok(!launchctl_service_disabled( + &String::from_utf8_lossy(&output.stdout), + Self::label(owner)?, + )) + } + + fn set_autostart( + &mut self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + enabled: bool, + ) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + let app_plist = (owner == hypercolor_macos_owner::MacosDaemonOwner::AppSidecar) + .then(|| self.plist(owner)) + .transpose()?; + if enabled + && let Some(path) = app_plist.as_ref() + && !path.is_file() + { + install_app_sidecar_launch_agent(path)?; + } + let action = if enabled { "enable" } else { "disable" }; + owner_run_command("/bin/launchctl", &[action, &self.target(owner)?])?; + if !enabled + && let Some(path) = app_plist + && path.is_file() + { + std::fs::remove_file(&path).map_err(|error| { + hypercolor_macos_owner::MacosOwnerExecutionError::new(error.to_string()) + })?; + std::fs::File::open(&self.launch_agents) + .and_then(|directory| directory.sync_all()) + .map_err(|error| { + hypercolor_macos_owner::MacosOwnerExecutionError::new(error.to_string()) + })?; + } + Ok(()) + } + + fn flush_and_stop( + &mut self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + pid: Option, + ) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + use hypercolor_macos_owner::{MacosDaemonOwner, MacosOwnerExecutionError}; + + if owner == MacosDaemonOwner::Standalone { + return Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )); + } + let pid = pid.or_else(|| { + self.store + .load_owner_record() + .ok() + .flatten() + .filter(|record| record.active_owner == owner) + .map(|record| record.active_identity.pid) + }); + if let Some(pid) = pid { + return hypercolor_macos_owner::terminate_macos_owner_process(pid); + } + if owner == MacosDaemonOwner::AppSidecar { + return Ok(()); + } + let target = self.target(owner)?; + let output = owner_command_output("/bin/launchctl", &["print", &target])?; + if !output.status.success() { + return Ok(()); + } + owner_run_command("/bin/launchctl", &["kill", "SIGTERM", &target]) + } + + fn start( + &mut self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + use hypercolor_macos_owner::{MacosDaemonOwner, MacosOwnerExecutionError}; + + match owner { + MacosDaemonOwner::AppSidecar => owner_run_command( + "/usr/bin/open", + &["-a", "Hypercolor", "--args", "--minimized"], + ), + MacosDaemonOwner::DirectLaunchd => { + let target = self.target(owner)?; + if owner_command_output("/bin/launchctl", &["print", &target])? + .status + .success() + { + owner_run_command("/bin/launchctl", &["kickstart", &target]) + } else { + owner_run_command( + "/bin/launchctl", + &[ + "bootstrap", + &format!("gui/{}", self.uid), + &self.plist(owner)?.to_string_lossy(), + ], + ) + } + } + MacosDaemonOwner::Homebrew => { + let brew = homebrew_binary()?; + owner_run_command( + &brew.to_string_lossy(), + &["services", "start", "hypercolor"], + ) + } + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone cannot be selected by the CLI coordinator", + )), + } + } + + fn wait_for_guard_release( + &mut self, + pid: u32, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_macos_guard_release( + pid, + timeout, + &std::env::temp_dir() + .join("hypercolor-daemon.lock") + .to_string_lossy(), + ) + } + + fn wait_for_owner( + &mut self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + after_epoch: u64, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_owner_publication(&self.store, owner, after_epoch, timeout) + } +} + +#[cfg(target_os = "macos")] +fn launchctl_service_disabled(output: &str, label: &str) -> bool { + output.lines().any(|line| { + let line = line.trim(); + line.contains(&format!("\"{label}\"")) && line.ends_with("=> true") + }) +} + +#[cfg(target_os = "macos")] +fn homebrew_binary() -> Result +{ + ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] + .into_iter() + .map(std::path::PathBuf::from) + .find(|path| path.is_file()) + .ok_or_else(|| { + hypercolor_macos_owner::MacosOwnerExecutionError::new( + "Homebrew executable is unavailable", + ) + }) +} + +#[cfg(target_os = "macos")] +fn command_stdout(program: &str, args: &[&str]) -> Result { + let output = std::process::Command::new(program).args(args).output()?; + if !output.status.success() { + bail!("{program} failed with {}", output.status); + } + if output.stdout.len() > 64 * 1024 { + bail!("{program} output exceeds 64 KiB"); + } + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +#[cfg(target_os = "macos")] +fn install_app_sidecar_launch_agent( + path: &std::path::Path, +) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + + let bundle = [ + std::path::PathBuf::from("/Applications/Hypercolor.app"), + dirs::home_dir() + .unwrap_or_default() + .join("Applications/Hypercolor.app"), + ] + .into_iter() + .find(|candidate| candidate.is_dir()) + .ok_or_else(|| { + hypercolor_macos_owner::MacosOwnerExecutionError::new( + "Hypercolor.app is not installed in a standard Applications directory", + ) + })?; + let executable = bundle.join(hypercolor_macos_owner::MACOS_APP_BUNDLE_EXECUTABLE_RELATIVE_PATH); + if !bundle.is_absolute() + || bundle + .extension() + .is_none_or(|extension| extension != "app") + { + return Err(hypercolor_macos_owner::MacosOwnerExecutionError::new( + "Hypercolor.app resolved to an invalid bundle path", + )); + } + if !executable.is_file() { + return Err(hypercolor_macos_owner::MacosOwnerExecutionError::new( + "Hypercolor.app does not contain its expected executable", + )); + } + let executable = xml_escape(&executable.to_string_lossy()); + let contents = format!( + "\n\ + \n\ + \n\ + Label{}\n\ + ProgramArguments{executable}\ + --minimized\n\ + RunAtLoad\n\ + \n", + hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME, + ); + let parent = path.parent().ok_or_else(|| { + hypercolor_macos_owner::MacosOwnerExecutionError::new( + "app-sidecar LaunchAgent has no parent directory", + ) + })?; + std::fs::create_dir_all(parent).map_err(|error| { + hypercolor_macos_owner::MacosOwnerExecutionError::new(error.to_string()) + })?; + let temporary = parent.join(format!( + ".{}.{}.tmp", + hypercolor_macos_owner::MACOS_APP_LAUNCH_AGENT_PLIST_FILE_NAME, + std::process::id() + )); + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| { + hypercolor_macos_owner::MacosOwnerExecutionError::new(error.to_string()) + })?; + let result = (|| { + file.write_all(contents.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + std::fs::File::open(parent)?.sync_all() + })(); + if let Err(error) = result { + let _ = std::fs::remove_file(&temporary); + return Err(hypercolor_macos_owner::MacosOwnerExecutionError::new( + error.to_string(), + )); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(target_os = "macos")] +fn owner_command_output( + program: &str, + args: &[&str], +) -> Result { + std::process::Command::new(program) + .args(args) + .output() + .map_err(|error| hypercolor_macos_owner::MacosOwnerExecutionError::new(error.to_string())) +} + +#[cfg(target_os = "macos")] +fn owner_run_command( + program: &str, + args: &[&str], +) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + let output = owner_command_output(program, args)?; + if output.status.success() { + return Ok(()); + } + let mut stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + stderr.truncate(4_096); + Err(hypercolor_macos_owner::MacosOwnerExecutionError::new( + format!("{program} failed with {}: {}", output.status, stderr.trim()), + )) +} + // ── Unsupported platforms ─────────────────────────────────────────────── #[cfg(not(any(target_os = "linux", target_os = "macos")))] @@ -512,3 +979,84 @@ fn format_bytes(bytes: u64) -> String { format!("{bytes} B") } } + +#[cfg(test)] +mod tests { + use clap::{Parser, ValueEnum}; + + use super::MacosServiceOwner; + + #[test] + fn macos_owner_values_use_stable_local_cli_names() { + assert_eq!( + MacosServiceOwner::from_str("app-sidecar", false), + Ok(MacosServiceOwner::AppSidecar) + ); + assert_eq!( + MacosServiceOwner::from_str("direct-launchd", false), + Ok(MacosServiceOwner::DirectLaunchd) + ); + assert_eq!( + MacosServiceOwner::from_str("homebrew", false), + Ok(MacosServiceOwner::Homebrew) + ); + } + + #[test] + fn local_owner_choice_is_wired_into_the_service_command_tree() { + let cli = + crate::Cli::try_parse_from(["hypercolor", "service", "choose-owner", "direct-launchd"]) + .expect("local owner command should parse"); + assert!(matches!( + cli.command, + crate::Commands::Service(super::ServiceArgs { + command: super::ServiceCommand::ChooseOwner(super::ChooseOwnerArgs { + owner: MacosServiceOwner::DirectLaunchd, + }), + }) + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn disabled_service_parser_is_exact_to_the_requested_label() { + let output = r#"disabled services = { + "tech.hyperbliss.hypercolor" => true + "homebrew.mxcl.hypercolor" => false + }"#; + assert!(super::launchctl_service_disabled( + output, + "tech.hyperbliss.hypercolor" + )); + assert!(!super::launchctl_service_disabled( + output, + "homebrew.mxcl.hypercolor" + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn app_sidecar_service_identity_matches_tauri_artifacts() { + use hypercolor_macos_owner::{MacosDaemonOwner, MacosOwnerStore}; + + let directory = tempfile::tempdir().expect("temporary directory should build"); + let executor = super::CliOwnerExecutor { + store: MacosOwnerStore::new(directory.path()), + uid: "501".to_owned(), + launch_agents: directory.path().to_path_buf(), + }; + assert_eq!( + super::CliOwnerExecutor::label(MacosDaemonOwner::AppSidecar) + .expect("app label should resolve"), + hypercolor_macos_owner::MACOS_APP_PRODUCT_NAME + ); + assert_eq!( + executor + .plist(MacosDaemonOwner::AppSidecar) + .expect("app plist should resolve") + .file_name() + .and_then(std::ffi::OsStr::to_str), + Some(hypercolor_macos_owner::MACOS_APP_LAUNCH_AGENT_PLIST_FILE_NAME) + ); + } +} diff --git a/crates/hypercolor-daemon/Cargo.toml b/crates/hypercolor-daemon/Cargo.toml index 1e485eb51..e24b36123 100644 --- a/crates/hypercolor-daemon/Cargo.toml +++ b/crates/hypercolor-daemon/Cargo.toml @@ -51,6 +51,7 @@ servo = ["hypercolor-core/servo"] hypercolor-types = { workspace = true } hypercolor-core = { path = "../hypercolor-core", default-features = false } hypercolor-platform-fs = { workspace = true } +hypercolor-macos-owner = { workspace = true } hypercolor-driver-api = { workspace = true } hypercolor-driver-builtin = { workspace = true, default-features = false, optional = true } hypercolor-network = { workspace = true } @@ -60,6 +61,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } +sha2 = { workspace = true } single-instance = "0.3.3" socket2 = "0.6.3" tokio = { workspace = true } @@ -84,6 +86,7 @@ fast_image_resize = { workspace = true } tokio-util = { workspace = true } if-addrs = { workspace = true } mdns-sd = { workspace = true } +notify = { workspace = true } owo-colors = { workspace = true } utoipa = { workspace = true } utoipa-swagger-ui = { workspace = true } @@ -99,6 +102,7 @@ sysinfo = { workspace = true } dispatch2 = "0.3.1" hypercolor-macos-capture = { workspace = true, optional = true } hypercolor-macos-gpu-interop = { workspace = true, optional = true } +hypercolor-macos-input = { workspace = true } objc2-core-foundation = { workspace = true, features = ["std", "CFRunLoop"] } sysinfo = { workspace = true } diff --git a/crates/hypercolor-daemon/src/api/config.rs b/crates/hypercolor-daemon/src/api/config.rs index 017ad0d4e..3cf51c824 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -710,6 +710,16 @@ async fn apply_capture_config_transaction( "config manager unavailable" ))); }; + #[cfg(target_os = "macos")] + if capture_diff_is_processing_only(&expected_config.capture, &capture) { + return apply_macos_capture_processing_transaction( + state, + manager, + expected_config, + capture, + ) + .await; + } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let (plan, capacity_plan, capacity_preparation, admission_coordinator) = { let input_manager = state.input_manager.lock().await; @@ -903,6 +913,59 @@ async fn apply_capture_config_transaction( Ok(()) } +#[cfg(target_os = "macos")] +fn capture_diff_is_processing_only(previous: &CaptureConfig, next: &CaptureConfig) -> bool { + let mut normalized = previous.clone(); + normalized.target_led_white_x = next.target_led_white_x; + normalized.target_led_white_y = next.target_led_white_y; + normalized.target_led_reference_white_nits = next.target_led_reference_white_nits; + normalized.target_led_peak_nits = next.target_led_peak_nits; + normalized.exposure_ev = next.exposure_ev; + normalized == *next +} + +#[cfg(target_os = "macos")] +async fn apply_macos_capture_processing_transaction( + state: &Arc, + manager: &Arc, + expected_config: &Arc, + capture: CaptureConfig, +) -> Result<(), CaptureConfigTransactionError> { + let next = crate::startup::services::screen_capture_config_from(&capture) + .map_err(CaptureConfigTransactionError::Prepare)?; + let previous = crate::startup::services::screen_capture_config_from(&expected_config.capture) + .map_err(CaptureConfigTransactionError::Prepare)?; + let mut input_manager = state.input_manager.lock().await; + if !manager.is_current(expected_config) { + return Err(CaptureConfigTransactionError::Conflict); + } + input_manager + .reconfigure_screen_processing(&next) + .map_err(CaptureConfigTransactionError::Prepare)?; + let persisted = manager.modify_and_save_if_current(expected_config, |config| { + config.capture.clone_from(&capture); + }); + match persisted { + Ok(true) => {} + Ok(false) => { + input_manager + .reconfigure_screen_processing(&previous) + .map_err(CaptureConfigTransactionError::Prepare)?; + return Err(CaptureConfigTransactionError::Conflict); + } + Err(error) => { + input_manager + .reconfigure_screen_processing(&previous) + .map_err(CaptureConfigTransactionError::Prepare)?; + return Err(CaptureConfigTransactionError::Persist(error)); + } + } + manager.mark_capture_runtime_applied(&capture); + drop(input_manager); + info!("Applied live macOS screen processing config without reopening capture"); + Ok(()) +} + /// How long a prepared replacement source may take to become usable. /// /// Windows rebuilds in-process and settles in tens of milliseconds. A @@ -1246,6 +1309,8 @@ mod tests { }; use hypercolor_types::config::InteractionRoutePolicy; + #[cfg(target_os = "macos")] + use super::capture_diff_is_processing_only; use super::{ CAPTURE_CALIBRATION_RESET_KEY, CaptureConfigTransactionError, ResetConfigRequest, SetConfigRequest, apply_capture_config_transaction, canvas_dimensions_differ, @@ -1512,6 +1577,44 @@ mod tests { assert!((reset.capture.exposure_ev - 2.5).abs() < f32::EPSILON); } + #[cfg(target_os = "macos")] + #[test] + fn macos_processing_only_diff_accepts_exactly_the_five_tone_fields() { + let original = hypercolor_types::config::CaptureConfig::default(); + let mut calibration = original.clone(); + calibration.target_led_white_x = 0.3000; + calibration.target_led_white_y = 0.3200; + calibration.target_led_reference_white_nits = 180.0; + calibration.target_led_peak_nits = 500.0; + calibration.exposure_ev = 1.25; + assert!(capture_diff_is_processing_only(&original, &calibration)); + + for divergent in [ + { + let mut config = calibration.clone(); + config.enabled = !original.enabled; + config + }, + { + let mut config = calibration.clone(); + config.source = "display:other".to_owned(); + config + }, + { + let mut config = calibration.clone(); + config.capture_fps = original.capture_fps + 1; + config + }, + { + let mut config = calibration.clone(); + config.smoothing = 0.75; + config + }, + ] { + assert!(!capture_diff_is_processing_only(&original, &divergent)); + } + } + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[tokio::test] async fn calibration_reset_endpoint_commits_one_valid_capture_config() { diff --git a/crates/hypercolor-daemon/src/api/mod.rs b/crates/hypercolor-daemon/src/api/mod.rs index 793e91df2..185c84be9 100644 --- a/crates/hypercolor-daemon/src/api/mod.rs +++ b/crates/hypercolor-daemon/src/api/mod.rs @@ -43,7 +43,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; -use arc_swap::ArcSwap; +use arc_swap::{ArcSwap, ArcSwapOption}; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::http::{HeaderValue, Method, header}; @@ -136,6 +136,9 @@ pub struct AppState { /// System-wide event bus (broadcast + watch channels). pub event_bus: Arc, + /// Latest durable macOS daemon ownership state. + pub macos_daemon_ownership: Arc>, + /// Daemon-managed user media asset library. pub asset_library: Arc>, @@ -570,6 +573,7 @@ impl AppState { scene_manager, scene_store, event_bus, + macos_daemon_ownership: Arc::new(ArcSwapOption::empty()), asset_library: Arc::new(RwLock::new(asset_library)), preview_runtime, zone_layout_previews, @@ -654,6 +658,7 @@ impl AppState { scene_manager: Arc::clone(&daemon.scene_manager), scene_store: Arc::clone(&daemon.scene_store), event_bus: Arc::clone(&daemon.event_bus), + macos_daemon_ownership: Arc::clone(&daemon.macos_daemon_ownership), asset_library: Arc::clone(&daemon.asset_library), preview_runtime: Arc::clone(&daemon.preview_runtime), zone_layout_previews: Arc::clone(&daemon.zone_layout_previews), diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index 18343aa17..81aacd625 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -27,6 +27,7 @@ use utoipa::ToSchema; use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; use crate::api::settings; +use crate::macos_owner::{MacosDaemonOwner, MacosHandoverPhase, MacosOwnerSnapshot}; use crate::performance::LatestFrameMetrics; use crate::preview_runtime::{PreviewDemandSummary, PreviewRuntime}; use crate::session::current_global_brightness; @@ -66,6 +67,8 @@ pub struct SystemStatus { pub capture_available: bool, pub screen_capture_capacity: ScreenCaptureCapacityStatus, pub input: InputStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_daemon_ownership: Option, pub compositor_acceleration: RenderAccelerationStatus, pub render_loop: RenderLoopStatus, pub latest_frame: Option, @@ -220,6 +223,48 @@ pub struct MacosDaemonOwnerConflictApiStatus { pub observed_at_ms: u64, } +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MacosDaemonHandoverPhaseApi { + Prepared, + AutostartsConfigured, + StopRequested, + OutgoingOwnerStopped, + AwaitingGuardRelease, + GuardReleased, + StartRequested, + RequestedOwnerStarted, + CommitPending, + Committed, + RollbackPending, + RollbackAutostartsRestored, + RollbackStopRequested, + RollbackOwnerStopped, + RollbackAwaitingGuardRelease, + RollbackGuardReleased, + RollbackStartRequested, + PriorOwnerStarted, + RollbackCommitPending, + RolledBack, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosDaemonOwnerRecoveryRequiredApiStatus { + pub requested_owner: MacosCapabilityOwnerApi, + pub prior_owner: MacosCapabilityOwnerApi, + pub phase: MacosDaemonHandoverPhaseApi, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosDaemonOwnershipApiStatus { + pub active_owner: MacosCapabilityOwnerApi, + pub owner_epoch: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub conflict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_required: Option, +} + #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum MacosSelectionStateApi { @@ -786,6 +831,83 @@ fn macos_daemon_owner_conflict( } } +const fn macos_daemon_owner(owner: MacosDaemonOwner) -> MacosCapabilityOwnerApi { + match owner { + MacosDaemonOwner::AppSidecar => MacosCapabilityOwnerApi::AppSidecar, + MacosDaemonOwner::DirectLaunchd => MacosCapabilityOwnerApi::LaunchdService, + MacosDaemonOwner::Homebrew => MacosCapabilityOwnerApi::HomebrewService, + MacosDaemonOwner::Standalone => MacosCapabilityOwnerApi::Standalone, + } +} + +fn macos_daemon_ownership(snapshot: &MacosOwnerSnapshot) -> MacosDaemonOwnershipApiStatus { + MacosDaemonOwnershipApiStatus { + active_owner: macos_daemon_owner(snapshot.active_owner), + owner_epoch: snapshot.owner_epoch, + conflict: snapshot + .conflict + .map(|conflict| MacosDaemonOwnerConflictApiStatus { + active: macos_daemon_owner(conflict.active_owner), + contender: macos_daemon_owner(conflict.contender_owner), + observed_at_ms: conflict.observed_at_ms, + }), + recovery_required: snapshot.recovery_required.map(|recovery| { + MacosDaemonOwnerRecoveryRequiredApiStatus { + requested_owner: macos_daemon_owner(recovery.requested_owner), + prior_owner: macos_daemon_owner(recovery.prior_owner), + phase: macos_daemon_handover_phase(recovery.phase), + } + }), + } +} + +const fn macos_daemon_handover_phase(phase: MacosHandoverPhase) -> MacosDaemonHandoverPhaseApi { + match phase { + MacosHandoverPhase::Prepared => MacosDaemonHandoverPhaseApi::Prepared, + MacosHandoverPhase::AutostartsConfigured => { + MacosDaemonHandoverPhaseApi::AutostartsConfigured + } + MacosHandoverPhase::StopRequested => MacosDaemonHandoverPhaseApi::StopRequested, + MacosHandoverPhase::OutgoingOwnerStopped => { + MacosDaemonHandoverPhaseApi::OutgoingOwnerStopped + } + MacosHandoverPhase::AwaitingGuardRelease => { + MacosDaemonHandoverPhaseApi::AwaitingGuardRelease + } + MacosHandoverPhase::GuardReleased => MacosDaemonHandoverPhaseApi::GuardReleased, + MacosHandoverPhase::StartRequested => MacosDaemonHandoverPhaseApi::StartRequested, + MacosHandoverPhase::RequestedOwnerStarted => { + MacosDaemonHandoverPhaseApi::RequestedOwnerStarted + } + MacosHandoverPhase::CommitPending => MacosDaemonHandoverPhaseApi::CommitPending, + MacosHandoverPhase::Committed => MacosDaemonHandoverPhaseApi::Committed, + MacosHandoverPhase::RollbackPending => MacosDaemonHandoverPhaseApi::RollbackPending, + MacosHandoverPhase::RollbackAutostartsRestored => { + MacosDaemonHandoverPhaseApi::RollbackAutostartsRestored + } + MacosHandoverPhase::RollbackStopRequested => { + MacosDaemonHandoverPhaseApi::RollbackStopRequested + } + MacosHandoverPhase::RollbackOwnerStopped => { + MacosDaemonHandoverPhaseApi::RollbackOwnerStopped + } + MacosHandoverPhase::RollbackAwaitingGuardRelease => { + MacosDaemonHandoverPhaseApi::RollbackAwaitingGuardRelease + } + MacosHandoverPhase::RollbackGuardReleased => { + MacosDaemonHandoverPhaseApi::RollbackGuardReleased + } + MacosHandoverPhase::RollbackStartRequested => { + MacosDaemonHandoverPhaseApi::RollbackStartRequested + } + MacosHandoverPhase::PriorOwnerStarted => MacosDaemonHandoverPhaseApi::PriorOwnerStarted, + MacosHandoverPhase::RollbackCommitPending => { + MacosDaemonHandoverPhaseApi::RollbackCommitPending + } + MacosHandoverPhase::RolledBack => MacosDaemonHandoverPhaseApi::RolledBack, + } +} + fn macos_selection_state(selection: &MacosSelectionState) -> MacosSelectionStateApi { match selection { MacosSelectionState::None => MacosSelectionStateApi::None, @@ -1097,6 +1219,11 @@ pub async fn get_status(State(state): State>) -> Response { let config_path = config_path(&state).display().to_string(); let data_dir = ConfigManager::data_dir().display().to_string(); let cache_dir = ConfigManager::cache_dir().display().to_string(); + let macos_daemon_ownership = state + .macos_daemon_ownership + .load_full() + .as_deref() + .map(macos_daemon_ownership); ApiResponse::ok(SystemStatus { running, @@ -1117,6 +1244,7 @@ pub async fn get_status(State(state): State>) -> Response { capture_available: settings::capture_input_available(), screen_capture_capacity, input: input_status, + macos_daemon_ownership, compositor_acceleration: render_acceleration_status(&state.render_acceleration), render_loop: render_loop_status, latest_frame, @@ -1782,10 +1910,14 @@ fn round_2(value: f64) -> f64 { #[cfg(test)] mod tests { use super::{ - get_sensor, get_sensors, get_status, input_source_status, macos_selection_state, - us_to_ms_f64, + get_sensor, get_sensors, get_status, input_source_status, macos_daemon_ownership, + macos_selection_state, us_to_ms_f64, }; use crate::api::AppState; + use crate::macos_owner::{ + MacosDaemonOwner, MacosHandoverPhase, MacosOwnerConflict, MacosOwnerRecoveryRequired, + MacosOwnerSnapshot, + }; use crate::performance::{ CompositorBackendKind, FrameTimeline, FullFrameCopyMetrics, LatestFrameMetrics, OutputFrameSourceKind, @@ -1867,6 +1999,44 @@ mod tests { ); } + #[test] + fn system_status_serializes_authoritative_macos_daemon_ownership() { + let value = serde_json::to_value(macos_daemon_ownership(&MacosOwnerSnapshot { + active_owner: MacosDaemonOwner::DirectLaunchd, + owner_epoch: 42, + conflict: Some(MacosOwnerConflict { + active_owner: MacosDaemonOwner::DirectLaunchd, + active_epoch: 42, + contender_owner: MacosDaemonOwner::Homebrew, + observed_at_ms: 1_725_000_000_789, + }), + recovery_required: Some(MacosOwnerRecoveryRequired { + requested_owner: MacosDaemonOwner::AppSidecar, + prior_owner: MacosDaemonOwner::Homebrew, + phase: MacosHandoverPhase::RollbackStopRequested, + }), + })) + .expect("macOS daemon ownership should serialize"); + + assert_eq!( + value, + json!({ + "active_owner": "launchd_service", + "owner_epoch": 42, + "conflict": { + "active": "launchd_service", + "contender": "homebrew_service", + "observed_at_ms": 1_725_000_000_789_u64 + }, + "recovery_required": { + "requested_owner": "app_sidecar", + "prior_owner": "homebrew_service", + "phase": "rollback_stop_requested" + } + }) + ); + } + #[test] fn input_source_status_serializes_macos_screen_platform() { let platform = SourcePlatformStatus::MacosScreen(MacosScreenPlatformStatus { @@ -1982,6 +2152,10 @@ mod tests { .expect("OpenAPI should contain component schemas"); assert!(schemas.contains_key("InputSourcePlatformStatus")); + assert!(schemas.contains_key("MacosDaemonOwnershipApiStatus")); + assert!(schemas.contains_key("MacosDaemonOwnerConflictApiStatus")); + assert!(schemas.contains_key("MacosDaemonOwnerRecoveryRequiredApiStatus")); + assert!(schemas.contains_key("MacosDaemonHandoverPhaseApi")); assert!(schemas.contains_key("MacosSelectionStateApi")); assert!(schemas.contains_key("MacosTahoeSelectionCapabilitiesApiStatus")); let platform_schema = &schemas["InputSourcePlatformStatus"]; diff --git a/crates/hypercolor-daemon/src/daemon.rs b/crates/hypercolor-daemon/src/daemon.rs index c3ec35acc..e6a964721 100644 --- a/crates/hypercolor-daemon/src/daemon.rs +++ b/crates/hypercolor-daemon/src/daemon.rs @@ -18,6 +18,7 @@ use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use crate::api::{self, AppState}; +use crate::macos_owner::{MacosDaemonOwner, MacosOwnerSnapshot}; use crate::mdns::MdnsPublisher; use crate::startup::{DaemonState, load_config}; @@ -48,6 +49,10 @@ pub struct DaemonRunOptions { pub ui_dir: Option, /// Bundled effects directory, overriding the install layout. pub effects_dir: Option, + /// Explicit macOS daemon topology supplied by the local launcher. + pub macos_owner: Option, + /// Durable ownership snapshot published before input source construction. + pub macos_owner_snapshot: Option, } pub trait DaemonExtensionInstaller: Send + Sync { @@ -169,7 +174,11 @@ pub async fn run_with_extensions( .local_addr() .context("failed to read API listener address")?; - let mut daemon_state = DaemonState::initialize(&config, config_path)?; + let mut daemon_state = DaemonState::initialize_with_macos_owner( + &config, + config_path, + options.macos_owner_snapshot, + )?; for installer in extension_installers { installer.install(&mut daemon_state)?; } diff --git a/crates/hypercolor-daemon/src/macos_owner.rs b/crates/hypercolor-daemon/src/macos_owner.rs index 0eba7b176..e1e81fac2 100644 --- a/crates/hypercolor-daemon/src/macos_owner.rs +++ b/crates/hypercolor-daemon/src/macos_owner.rs @@ -1,1167 +1,3 @@ //! Durable macOS daemon ownership and handover state. -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use serde::{Deserialize, Serialize}; - -/// Current owner-record schema version. -pub const MACOS_OWNER_RECORD_SCHEMA_VERSION: u32 = 1; -/// Current handover-journal schema version. -pub const MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION: u32 = 1; -/// Stable owner-record file name within the per-user data directory. -pub const MACOS_OWNER_RECORD_FILE_NAME: &str = "macos-daemon-owner.json"; -/// Stable handover-journal file name within the per-user data directory. -pub const MACOS_HANDOVER_JOURNAL_FILE_NAME: &str = "macos-daemon-handover.json"; -/// Stable coordination-lock file name shared by both durable artifacts. -pub const MACOS_OWNER_COORDINATION_LOCK_FILE_NAME: &str = "macos-daemon-owner.lock"; -/// Maximum UTF-8 byte length for an audit-token identity. -pub const MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES: usize = 256; -/// Maximum UTF-8 byte length for a diagnostic executable path. -pub const MAX_MACOS_EXECUTABLE_PATH_BYTES: usize = 4_096; -/// Maximum UTF-8 byte length for a designated-requirement hash. -pub const MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES: usize = 256; -/// Maximum byte length accepted for either durable JSON artifact. -pub const MAX_MACOS_OWNER_ARTIFACT_BYTES: usize = 256 * 1_024; -/// Maximum number of closed rollback operations in one journal. -pub const MAX_MACOS_HANDOVER_OPERATIONS: usize = 64; -const MAX_TEMPORARY_CREATE_ATTEMPTS: usize = 64; - -static TEMPORARY_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); - -/// A daemon topology that can own protected macOS capabilities. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MacosDaemonOwner { - /// Daemon supervised by the packaged app. - AppSidecar, - /// Daemon managed by Hypercolor's direct per-user launchd service. - DirectLaunchd, - /// Daemon managed by Homebrew services. - Homebrew, - /// Daemon started directly from a terminal. - Standalone, -} - -/// An external daemon topology selected by the local app. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MacosExternalOwnerMode { - /// Connect to Hypercolor's direct per-user launchd service. - DirectLaunchd, - /// Connect to the Homebrew-managed service. - Homebrew, -} - -/// Bounded diagnostic identity for the process that attempted ownership. -/// -/// The executable path is diagnostic data only. It is never an executable, -/// command, or recovery authority. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct MacosOwnerIdentity { - /// Stable representation of the process audit token. - pub audit_token_identity: String, - /// Absolute path observed for the process executable. - pub executable_path: PathBuf, - /// Hash of the process designated requirement. - pub designated_requirement_hash: String, - /// Process identifier observed with this identity. - pub pid: u32, -} - -impl MacosOwnerIdentity { - /// Validate and construct a diagnostic process identity. - pub fn new( - audit_token_identity: impl Into, - executable_path: impl Into, - designated_requirement_hash: impl Into, - pid: u32, - ) -> Result { - let identity = Self { - audit_token_identity: audit_token_identity.into(), - executable_path: executable_path.into(), - designated_requirement_hash: designated_requirement_hash.into(), - pid, - }; - validate_owner_identity(&identity)?; - Ok(identity) - } -} - -impl<'de> Deserialize<'de> for MacosOwnerIdentity { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct RawIdentity { - audit_token_identity: String, - executable_path: PathBuf, - designated_requirement_hash: String, - pid: u32, - } - - let raw = RawIdentity::deserialize(deserializer)?; - Self::new( - raw.audit_token_identity, - raw.executable_path, - raw.designated_requirement_hash, - raw.pid, - ) - .map_err(serde::de::Error::custom) - } -} - -/// Bounded conflict status for a contender that failed to acquire the guard. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosOwnerConflict { - /// Owner holding the guard when the conflict was observed. - pub active_owner: MacosDaemonOwner, - /// Active owner's acquisition epoch. - pub active_epoch: u64, - /// Topology of the losing contender. - pub contender_owner: MacosDaemonOwner, - /// Millisecond timestamp supplied by the observer. - pub observed_at_ms: u64, -} - -/// Durable conflict record including the contender's diagnostic identity. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosOwnerConflictRecord { - /// Owner holding the guard when the conflict was observed. - pub active_owner: MacosDaemonOwner, - /// Active owner's acquisition epoch. - pub active_epoch: u64, - /// Topology of the losing contender. - pub contender_owner: MacosDaemonOwner, - /// Diagnostic identity of the losing contender. - pub contender_identity: MacosOwnerIdentity, - /// Millisecond timestamp supplied by the observer. - pub observed_at_ms: u64, -} - -impl MacosOwnerConflictRecord { - fn has_same_identity(&self, other: &Self) -> bool { - self.active_owner == other.active_owner - && self.active_epoch == other.active_epoch - && self.contender_owner == other.contender_owner - && self.contender_identity.executable_path == other.contender_identity.executable_path - && self.contender_identity.designated_requirement_hash - == other.contender_identity.designated_requirement_hash - } - - const fn snapshot(&self) -> MacosOwnerConflict { - MacosOwnerConflict { - active_owner: self.active_owner, - active_epoch: self.active_epoch, - contender_owner: self.contender_owner, - observed_at_ms: self.observed_at_ms, - } - } -} - -/// Bounded status snapshot derived from the durable owner record. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosOwnerSnapshot { - /// Current daemon owner. - pub active_owner: MacosDaemonOwner, - /// Current owner's acquisition epoch. - pub owner_epoch: u64, - /// Latest distinct owner conflict, when present. - pub conflict: Option, -} - -/// Versioned durable owner state for one macOS user. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosOwnerRecord { - /// Durable schema version. - pub schema_version: u32, - /// Current daemon owner. - pub active_owner: MacosDaemonOwner, - /// Diagnostic identity of the current owner process. - pub active_identity: MacosOwnerIdentity, - /// Monotonically increasing owner acquisition epoch. - pub owner_epoch: u64, - /// Latest distinct losing contender, when present. - pub conflict: Option, - /// Persisted app preference for an externally managed daemon. - pub selected_external_owner: Option, -} - -impl MacosOwnerRecord { - /// Construct an initial owner record at epoch one. - pub const fn new( - active_owner: MacosDaemonOwner, - active_identity: MacosOwnerIdentity, - selected_external_owner: Option, - ) -> Self { - Self { - schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, - active_owner, - active_identity, - owner_epoch: 1, - conflict: None, - selected_external_owner, - } - } - - /// Return the bounded status surface for this record. - pub fn snapshot(&self) -> MacosOwnerSnapshot { - MacosOwnerSnapshot { - active_owner: self.active_owner, - owner_epoch: self.owner_epoch, - conflict: self - .conflict - .as_ref() - .map(MacosOwnerConflictRecord::snapshot), - } - } -} - -/// Result of publishing a contender against the current owner epoch. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MacosConflictUpdate { - /// A distinct contender state was durably recorded. - Recorded(MacosOwnerSnapshot), - /// The contender matched the existing conflict identity. - Coalesced(MacosOwnerSnapshot), -} - -impl MacosConflictUpdate { - /// Return the owner snapshot associated with this update. - pub const fn snapshot(self) -> MacosOwnerSnapshot { - match self { - Self::Recorded(snapshot) | Self::Coalesced(snapshot) => snapshot, - } - } -} - -/// Installed-state snapshot captured before a daemon handover. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosAutostartStates { - /// Whether app-sidecar autostart was enabled. - pub app_sidecar: bool, - /// Whether the direct launchd service was enabled. - pub direct_launchd: bool, - /// Whether the Homebrew service was enabled. - pub homebrew: bool, -} - -impl MacosAutostartStates { - /// Construct an installed-state snapshot. - pub const fn new(app_sidecar: bool, direct_launchd: bool, homebrew: bool) -> Self { - Self { - app_sidecar, - direct_launchd, - homebrew, - } - } -} - -/// A validated path-free handover or rollback operation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum MacosHandoverOperation { - /// Set app-sidecar autostart state. - SetAppSidecarAutostart { - /// Desired installed state. - enabled: bool, - }, - /// Flush and stop the app-supervised sidecar. - FlushAndStopAppSidecar {}, - /// Start the app-supervised sidecar. - StartAppSidecar {}, - /// Set direct-launchd autostart state. - SetDirectLaunchdAutostart { - /// Desired installed state. - enabled: bool, - }, - /// Flush and stop the direct launchd service. - FlushAndStopDirectLaunchd {}, - /// Start the direct launchd service. - StartDirectLaunchd {}, - /// Set Homebrew-service autostart state. - SetHomebrewAutostart { - /// Desired installed state. - enabled: bool, - }, - /// Flush and stop the Homebrew service. - FlushAndStopHomebrew {}, - /// Start the Homebrew service. - StartHomebrew {}, - /// Await user-directed termination of a standalone owner. - AwaitStandaloneExit { - /// Authoritative process identifier shown to the user. - pid: u32, - }, -} - -/// Durable handover phase used to resume or reverse interrupted work. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MacosHandoverPhase { - /// Journal exists and no external mutation has begun. - Prepared, - /// Nonselected autostarts have been disabled. - AutostartsConfigured, - /// Stop of the outgoing managed owner has been requested. - StopRequested, - /// The outgoing managed owner has stopped. - OutgoingOwnerStopped, - /// The coordinator is waiting for the instance guard to release. - AwaitingGuardRelease, - /// The instance guard is free. - GuardReleased, - /// Startup of the requested owner has been requested. - StartRequested, - /// The requested owner has started. - RequestedOwnerStarted, - /// The requested owner is ready for the ownership commit. - CommitPending, - /// The requested owner committed the handover. - Committed, - /// Forward progress failed and rollback must begin or resume. - RollbackPending, - /// Prior autostart state has been restored. - RollbackAutostartsRestored, - /// Stop of a partially started requested owner was requested. - RollbackStopRequested, - /// The partially started requested owner has stopped. - RollbackOwnerStopped, - /// Rollback is waiting for the instance guard to release. - RollbackAwaitingGuardRelease, - /// The instance guard is free for the prior owner. - RollbackGuardReleased, - /// Restart of the prior managed owner was requested. - RollbackStartRequested, - /// The prior managed owner has restarted. - PriorOwnerStarted, - /// The prior owner is ready for the rollback commit. - RollbackCommitPending, - /// The prior owner committed rollback completion. - RolledBack, -} - -impl MacosHandoverPhase { - /// Every stable journal phase, in forward then rollback order. - pub const ALL: [Self; 20] = [ - Self::Prepared, - Self::AutostartsConfigured, - Self::StopRequested, - Self::OutgoingOwnerStopped, - Self::AwaitingGuardRelease, - Self::GuardReleased, - Self::StartRequested, - Self::RequestedOwnerStarted, - Self::CommitPending, - Self::Committed, - Self::RollbackPending, - Self::RollbackAutostartsRestored, - Self::RollbackStopRequested, - Self::RollbackOwnerStopped, - Self::RollbackAwaitingGuardRelease, - Self::RollbackGuardReleased, - Self::RollbackStartRequested, - Self::PriorOwnerStarted, - Self::RollbackCommitPending, - Self::RolledBack, - ]; - - /// Whether this phase closes the transaction. - pub const fn is_terminal(self) -> bool { - matches!(self, Self::Committed | Self::RolledBack) - } -} - -/// Stable, path-free identifier for one handover transaction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(transparent)] -pub struct MacosHandoverTransactionId(String); - -impl MacosHandoverTransactionId { - /// Validate and construct a handover transaction identifier. - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if is_valid_transaction_id(&value) { - Ok(Self(value)) - } else { - Err(MacosOwnerStoreError::InvalidTransactionId) - } - } - - /// Borrow the validated identifier. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl<'de> Deserialize<'de> for MacosHandoverTransactionId { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(serde::de::Error::custom) - } -} - -/// Versioned durable journal for a local daemon-owner handover. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MacosHandoverJournal { - /// Durable schema version. - pub schema_version: u32, - /// Monotonic mutation count within this journal transaction. - pub journal_revision: u64, - /// Stable transaction identifier. - pub transaction_id: MacosHandoverTransactionId, - /// Desired owner after a successful handover. - pub requested_owner: MacosDaemonOwner, - /// Owner to restore if the handover rolls back. - pub prior_owner: MacosDaemonOwner, - /// Installed states to restore during rollback. - pub prior_autostart_states: MacosAutostartStates, - /// Closed operations recovery is permitted to execute. - pub allowed_rollback_operations: Vec, - /// Last durably completed transaction phase. - pub phase: MacosHandoverPhase, - /// Owner epoch observed before mutation began. - pub active_epoch: u64, - /// Contender epoch associated with the request, when one exists. - pub contender_epoch: Option, - /// Standalone process whose user-directed exit is pending. - pub pending_standalone_pid: Option, -} - -impl MacosHandoverJournal { - /// Construct a prepared journal. The store assigns its first revision. - pub fn new( - transaction_id: MacosHandoverTransactionId, - requested_owner: MacosDaemonOwner, - prior_owner: MacosDaemonOwner, - prior_autostart_states: MacosAutostartStates, - allowed_rollback_operations: Vec, - active_epoch: u64, - contender_epoch: Option, - pending_standalone_pid: Option, - ) -> Self { - Self { - schema_version: MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, - journal_revision: 0, - transaction_id, - requested_owner, - prior_owner, - prior_autostart_states, - allowed_rollback_operations, - phase: MacosHandoverPhase::Prepared, - active_epoch, - contender_epoch, - pending_standalone_pid, - } - } -} - -/// Typed durable owner-store failure. -#[derive(Debug, thiserror::Error)] -pub enum MacosOwnerStoreError { - /// The explicit data directory could not be created. - #[error("failed to create macOS owner data directory {path}: {source}")] - CreateDirectory { - /// Data directory. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// The stable coordination lock could not be opened. - #[error("failed to open macOS owner coordination lock {path}: {source}")] - OpenCoordinationLock { - /// Lock path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// The stable coordination lock could not be acquired. - #[error("failed to acquire macOS owner coordination lock {path}: {source}")] - AcquireCoordinationLock { - /// Lock path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// A durable artifact could not be read. - #[error("failed to read macOS {artifact} at {path}: {source}")] - Read { - /// Artifact kind. - artifact: &'static str, - /// Artifact path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// A durable artifact could not be decoded. - #[error("failed to decode macOS {artifact}: {source}")] - Decode { - /// Artifact kind. - artifact: &'static str, - /// JSON failure. - #[source] - source: serde_json::Error, - }, - /// A durable artifact has an unsupported schema version. - #[error("unsupported macOS {artifact} schema version {found}; expected {expected}")] - UnsupportedVersion { - /// Artifact kind. - artifact: &'static str, - /// Version found on disk. - found: u32, - /// Version supported by this build. - expected: u32, - }, - /// A durable artifact violates a semantic invariant. - #[error("invalid macOS {artifact}: {detail}")] - InvalidArtifact { - /// Artifact kind. - artifact: &'static str, - /// Stable validation detail. - detail: &'static str, - }, - /// JSON serialization failed before any bytes were replaced. - #[error("failed to serialize macOS {artifact}: {source}")] - Encode { - /// Artifact kind. - artifact: &'static str, - /// JSON failure. - #[source] - source: serde_json::Error, - }, - /// A same-directory temporary file could not be created. - #[error("failed to create temporary file beside {path}: {source}")] - CreateTemporary { - /// Destination path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// A complete temporary artifact could not be written. - #[error("failed to write temporary file for {path}: {source}")] - WriteTemporary { - /// Destination path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// Temporary artifact contents could not be synced. - #[error("failed to sync temporary file for {path}: {source}")] - SyncTemporary { - /// Destination path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// The durable destination could not be atomically replaced. - #[error("failed to atomically replace {path}: {source}")] - Replace { - /// Destination path. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// The parent directory could not be synced after replacement. - #[cfg(unix)] - #[error("failed to sync parent directory {path}: {source}")] - SyncDirectory { - /// Parent directory. - path: PathBuf, - /// Filesystem failure. - #[source] - source: std::io::Error, - }, - /// No owner record exists for the requested mutation. - #[error("macOS owner record does not exist")] - MissingOwnerRecord, - /// The owner acquisition epoch cannot advance further. - #[error("macOS owner epoch overflow")] - OwnerEpochOverflow, - /// A nonterminal handover journal must be recovered first. - #[error("macOS handover {transaction_id} is still pending")] - HandoverAlreadyPending { - /// Existing transaction identifier. - transaction_id: String, - }, - /// No handover journal exists for the requested mutation. - #[error("macOS handover journal does not exist")] - MissingHandoverJournal, - /// A caller attempted to advance a different transaction. - #[error("macOS handover transaction does not match the durable journal")] - HandoverTransactionMismatch, - /// The handover journal revision cannot advance further. - #[error("macOS handover journal revision overflow")] - JournalRevisionOverflow, - /// A transaction identifier is not a bounded path-free token. - #[error("macOS handover transaction ID must be 1-64 ASCII letters, digits, '_' or '-'")] - InvalidTransactionId, - /// An owner identity field is empty, oversized, or structurally invalid. - #[error("invalid macOS owner identity field {field}: {detail}")] - InvalidOwnerIdentity { - /// Invalid identity field. - field: &'static str, - /// Stable validation detail. - detail: &'static str, - }, - /// A durable artifact exceeds the bounded decoder input size. - #[error("macOS {artifact} exceeds the {maximum_bytes}-byte limit")] - ArtifactTooLarge { - /// Artifact kind. - artifact: &'static str, - /// Maximum accepted byte length. - maximum_bytes: usize, - }, - /// A completed or rolled-back transaction cannot be advanced. - #[error("terminal macOS handover {transaction_id} cannot advance")] - TerminalHandover { - /// Completed transaction identifier. - transaction_id: String, - }, -} - -/// Durable owner state rooted in an explicit per-user data directory. -#[derive(Debug, Clone)] -pub struct MacosOwnerStore { - data_dir: PathBuf, -} - -impl MacosOwnerStore { - /// Construct a store without reading or creating any files. - pub fn new(data_dir: impl Into) -> Self { - Self { - data_dir: data_dir.into(), - } - } - - /// Return the owner-record path. - pub fn owner_record_path(&self) -> PathBuf { - self.data_dir.join(MACOS_OWNER_RECORD_FILE_NAME) - } - - /// Return the handover-journal path. - pub fn handover_journal_path(&self) -> PathBuf { - self.data_dir.join(MACOS_HANDOVER_JOURNAL_FILE_NAME) - } - - /// Return the stable lock path shared by every writer. - pub fn coordination_lock_path(&self) -> PathBuf { - self.data_dir.join(MACOS_OWNER_COORDINATION_LOCK_FILE_NAME) - } - - /// Load and validate the current owner record. - pub fn load_owner_record(&self) -> Result, MacosOwnerStoreError> { - read_owner_record(&self.owner_record_path()) - } - - /// Publish a newly acquired owner and advance the durable owner epoch. - pub fn publish_owner( - &self, - active_owner: MacosDaemonOwner, - active_identity: MacosOwnerIdentity, - selected_external_owner: Option, - ) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.owner_record_path(); - let record = match read_owner_record(&path)? { - Some(previous) => MacosOwnerRecord { - owner_epoch: previous - .owner_epoch - .checked_add(1) - .ok_or(MacosOwnerStoreError::OwnerEpochOverflow)?, - schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, - active_owner, - active_identity, - conflict: None, - selected_external_owner, - }, - None => MacosOwnerRecord::new(active_owner, active_identity, selected_external_owner), - }; - write_json_atomic(&self.data_dir, &path, "owner record", &record)?; - Ok(record) - } - - /// Record a distinct contender or coalesce one already observed this epoch. - pub fn record_conflict( - &self, - contender_owner: MacosDaemonOwner, - contender_identity: MacosOwnerIdentity, - observed_at_ms: u64, - ) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.owner_record_path(); - let mut record = - read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; - let conflict = MacosOwnerConflictRecord { - active_owner: record.active_owner, - active_epoch: record.owner_epoch, - contender_owner, - contender_identity, - observed_at_ms, - }; - if record - .conflict - .as_ref() - .is_some_and(|existing| existing.has_same_identity(&conflict)) - { - return Ok(MacosConflictUpdate::Coalesced(record.snapshot())); - } - record.conflict = Some(conflict); - write_json_atomic(&self.data_dir, &path, "owner record", &record)?; - Ok(MacosConflictUpdate::Recorded(record.snapshot())) - } - - /// Clear the current conflict without changing the owner epoch. - pub fn clear_conflict(&self) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.owner_record_path(); - let mut record = - read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; - if record.conflict.take().is_some() { - write_json_atomic(&self.data_dir, &path, "owner record", &record)?; - } - Ok(record) - } - - /// Persist or clear the selected external-owner mode. - pub fn set_external_owner_mode( - &self, - selected_external_owner: Option, - ) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.owner_record_path(); - let mut record = - read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; - if record.selected_external_owner != selected_external_owner { - record.selected_external_owner = selected_external_owner; - write_json_atomic(&self.data_dir, &path, "owner record", &record)?; - } - Ok(record) - } - - /// Load and validate the current handover journal. - pub fn load_handover_journal( - &self, - ) -> Result, MacosOwnerStoreError> { - read_handover_journal(&self.handover_journal_path()) - } - - /// Begin a handover unless a nonterminal journal requires recovery. - pub fn begin_handover( - &self, - mut journal: MacosHandoverJournal, - ) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.handover_journal_path(); - if let Some(existing) = read_handover_journal(&path)? - && !existing.phase.is_terminal() - { - return Err(MacosOwnerStoreError::HandoverAlreadyPending { - transaction_id: existing.transaction_id.0, - }); - } - validate_handover_journal(&journal)?; - journal.schema_version = MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION; - journal.journal_revision = 1; - journal.phase = MacosHandoverPhase::Prepared; - write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; - Ok(journal) - } - - /// Durably advance one handover phase under one read-modify-write lock hold. - pub fn advance_handover( - &self, - transaction_id: &MacosHandoverTransactionId, - phase: MacosHandoverPhase, - ) -> Result { - let _lock = self.acquire_coordination_lock()?; - let path = self.handover_journal_path(); - let mut journal = - read_handover_journal(&path)?.ok_or(MacosOwnerStoreError::MissingHandoverJournal)?; - if journal.transaction_id != *transaction_id { - return Err(MacosOwnerStoreError::HandoverTransactionMismatch); - } - if journal.phase.is_terminal() { - return Err(MacosOwnerStoreError::TerminalHandover { - transaction_id: journal.transaction_id.0, - }); - } - journal.journal_revision = journal - .journal_revision - .checked_add(1) - .ok_or(MacosOwnerStoreError::JournalRevisionOverflow)?; - journal.phase = phase; - write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; - Ok(journal) - } - - fn acquire_coordination_lock(&self) -> Result { - fs::create_dir_all(&self.data_dir).map_err(|source| { - MacosOwnerStoreError::CreateDirectory { - path: self.data_dir.clone(), - source, - } - })?; - let path = self.coordination_lock_path(); - let mut options = OpenOptions::new(); - options.create(true).read(true).write(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let file = - options - .open(&path) - .map_err(|source| MacosOwnerStoreError::OpenCoordinationLock { - path: path.clone(), - source, - })?; - file.lock() - .map_err(|source| MacosOwnerStoreError::AcquireCoordinationLock { path, source })?; - Ok(CoordinationLock { file }) - } -} - -struct CoordinationLock { - file: File, -} - -impl Drop for CoordinationLock { - fn drop(&mut self) { - drop(self.file.unlock()); - } -} - -fn read_owner_record(path: &Path) -> Result, MacosOwnerStoreError> { - let Some(bytes) = read_optional(path, "owner record")? else { - return Ok(None); - }; - let record = serde_json::from_slice::(&bytes).map_err(|source| { - MacosOwnerStoreError::Decode { - artifact: "owner record", - source, - } - })?; - validate_owner_record(&record)?; - Ok(Some(record)) -} - -fn read_handover_journal( - path: &Path, -) -> Result, MacosOwnerStoreError> { - let Some(bytes) = read_optional(path, "handover journal")? else { - return Ok(None); - }; - let journal = serde_json::from_slice::(&bytes).map_err(|source| { - MacosOwnerStoreError::Decode { - artifact: "handover journal", - source, - } - })?; - validate_handover_journal(&journal)?; - Ok(Some(journal)) -} - -fn read_optional( - path: &Path, - artifact: &'static str, -) -> Result>, MacosOwnerStoreError> { - match File::open(path) { - Ok(file) => { - let mut bytes = Vec::new(); - file.take((MAX_MACOS_OWNER_ARTIFACT_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|source| MacosOwnerStoreError::Read { - artifact, - path: path.to_path_buf(), - source, - })?; - if bytes.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { - return Err(MacosOwnerStoreError::ArtifactTooLarge { - artifact, - maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, - }); - } - Ok(Some(bytes)) - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(source) => Err(MacosOwnerStoreError::Read { - artifact, - path: path.to_path_buf(), - source, - }), - } -} - -fn validate_owner_record(record: &MacosOwnerRecord) -> Result<(), MacosOwnerStoreError> { - validate_version( - "owner record", - record.schema_version, - MACOS_OWNER_RECORD_SCHEMA_VERSION, - )?; - if record.owner_epoch == 0 { - return Err(MacosOwnerStoreError::InvalidArtifact { - artifact: "owner record", - detail: "owner_epoch must be positive", - }); - } - validate_owner_identity(&record.active_identity)?; - if let Some(conflict) = &record.conflict - && (conflict.active_owner != record.active_owner - || conflict.active_epoch != record.owner_epoch) - { - return Err(MacosOwnerStoreError::InvalidArtifact { - artifact: "owner record", - detail: "conflict must identify the active owner epoch", - }); - } - if let Some(conflict) = &record.conflict { - validate_owner_identity(&conflict.contender_identity)?; - } - Ok(()) -} - -fn validate_owner_identity(identity: &MacosOwnerIdentity) -> Result<(), MacosOwnerStoreError> { - validate_bounded_identity_text( - "audit_token_identity", - &identity.audit_token_identity, - MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES, - )?; - let executable_path = - identity - .executable_path - .to_str() - .ok_or(MacosOwnerStoreError::InvalidOwnerIdentity { - field: "executable_path", - detail: "must be valid UTF-8", - })?; - validate_bounded_identity_text( - "executable_path", - executable_path, - MAX_MACOS_EXECUTABLE_PATH_BYTES, - )?; - if !identity.executable_path.is_absolute() { - return Err(MacosOwnerStoreError::InvalidOwnerIdentity { - field: "executable_path", - detail: "must be absolute", - }); - } - validate_bounded_identity_text( - "designated_requirement_hash", - &identity.designated_requirement_hash, - MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES, - )?; - if identity.pid == 0 { - return Err(MacosOwnerStoreError::InvalidOwnerIdentity { - field: "pid", - detail: "must be positive", - }); - } - Ok(()) -} - -fn validate_bounded_identity_text( - field: &'static str, - value: &str, - maximum_bytes: usize, -) -> Result<(), MacosOwnerStoreError> { - if value.is_empty() { - Err(MacosOwnerStoreError::InvalidOwnerIdentity { - field, - detail: "must not be empty", - }) - } else if value.len() > maximum_bytes { - Err(MacosOwnerStoreError::InvalidOwnerIdentity { - field, - detail: "exceeds its byte limit", - }) - } else { - Ok(()) - } -} - -fn validate_handover_journal(journal: &MacosHandoverJournal) -> Result<(), MacosOwnerStoreError> { - validate_version( - "handover journal", - journal.schema_version, - MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, - )?; - if !is_valid_transaction_id(journal.transaction_id.as_str()) { - return Err(MacosOwnerStoreError::InvalidTransactionId); - } - if journal.active_epoch == 0 { - return Err(MacosOwnerStoreError::InvalidArtifact { - artifact: "handover journal", - detail: "active_epoch must be positive", - }); - } - if journal.allowed_rollback_operations.len() > MAX_MACOS_HANDOVER_OPERATIONS { - return Err(MacosOwnerStoreError::InvalidArtifact { - artifact: "handover journal", - detail: "allowed_rollback_operations exceeds its item limit", - }); - } - if journal.pending_standalone_pid == Some(0) - || journal.allowed_rollback_operations.iter().any(|operation| { - matches!( - operation, - MacosHandoverOperation::AwaitStandaloneExit { pid: 0 } - ) - }) - { - return Err(MacosOwnerStoreError::InvalidArtifact { - artifact: "handover journal", - detail: "standalone PID must be positive", - }); - } - Ok(()) -} - -fn validate_version( - artifact: &'static str, - found: u32, - expected: u32, -) -> Result<(), MacosOwnerStoreError> { - if found == expected { - Ok(()) - } else { - Err(MacosOwnerStoreError::UnsupportedVersion { - artifact, - found, - expected, - }) - } -} - -fn is_valid_transaction_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - -fn write_json_atomic( - data_dir: &Path, - path: &Path, - artifact: &'static str, - value: &T, -) -> Result<(), MacosOwnerStoreError> -where - T: Serialize + ?Sized, -{ - let mut payload = serde_json::to_vec_pretty(value) - .map_err(|source| MacosOwnerStoreError::Encode { artifact, source })?; - payload.push(b'\n'); - if payload.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { - return Err(MacosOwnerStoreError::ArtifactTooLarge { - artifact, - maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, - }); - } - let (mut temporary, temporary_path) = create_temporary_file(data_dir, path)?; - let result = (|| { - temporary - .write_all(&payload) - .map_err(|source| MacosOwnerStoreError::WriteTemporary { - path: path.to_path_buf(), - source, - })?; - temporary - .sync_all() - .map_err(|source| MacosOwnerStoreError::SyncTemporary { - path: path.to_path_buf(), - source, - })?; - drop(temporary); - hypercolor_platform_fs::replace_file(&temporary_path, path).map_err(|source| { - MacosOwnerStoreError::Replace { - path: path.to_path_buf(), - source, - } - })?; - sync_parent_directory(data_dir) - })(); - if result.is_err() { - drop(fs::remove_file(&temporary_path)); - } - result -} - -fn create_temporary_file( - data_dir: &Path, - path: &Path, -) -> Result<(File, PathBuf), MacosOwnerStoreError> { - for _ in 0..MAX_TEMPORARY_CREATE_ATTEMPTS { - let sequence = TEMPORARY_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let temporary_path = data_dir.join(format!( - ".{}.{}.{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("macos-owner"), - std::process::id(), - sequence - )); - let mut options = OpenOptions::new(); - options.create_new(true).write(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - match options.open(&temporary_path) { - Ok(file) => return Ok((file, temporary_path)), - Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(source) => { - return Err(MacosOwnerStoreError::CreateTemporary { - path: path.to_path_buf(), - source, - }); - } - } - } - Err(MacosOwnerStoreError::CreateTemporary { - path: path.to_path_buf(), - source: std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "temporary file collision limit reached", - ), - }) -} - -#[cfg(unix)] -fn sync_parent_directory(data_dir: &Path) -> Result<(), MacosOwnerStoreError> { - File::open(data_dir) - .and_then(|directory| directory.sync_all()) - .map_err(|source| MacosOwnerStoreError::SyncDirectory { - path: data_dir.to_path_buf(), - source, - }) -} - -#[cfg(not(unix))] -fn sync_parent_directory(_data_dir: &Path) -> Result<(), MacosOwnerStoreError> { - Ok(()) -} +pub use hypercolor_macos_owner::*; diff --git a/crates/hypercolor-daemon/src/main.rs b/crates/hypercolor-daemon/src/main.rs index 822b6ec29..88055e2e1 100644 --- a/crates/hypercolor-daemon/src/main.rs +++ b/crates/hypercolor-daemon/src/main.rs @@ -6,11 +6,33 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; +#[cfg(target_os = "macos")] +use hypercolor_core::config::ConfigManager; use hypercolor_daemon::daemon::{self, DaemonRunOptions}; +#[cfg(target_os = "macos")] +use hypercolor_daemon::macos_owner::{ + MacosDaemonGuard, MacosDaemonOwner, MacosOwnerCoordinatorOutcome, MacosOwnerIdentity, + MacosOwnerRecoveryRequired, MacosOwnerSnapshot, MacosOwnerStore, acquire_macos_daemon_guard, + recover_incoming_daemon_owner, try_acquire_macos_daemon_guard, +}; use hypercolor_daemon::startup::install_signal_handlers; +#[cfg(target_os = "macos")] +use hypercolor_macos_input::current_process_audit_token_identity; use hypercolor_types::config::{RenderAccelerationMode, ServoGpuImportMode}; +#[cfg(target_os = "macos")] +use hypercolor_types::event::MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE; +#[cfg(target_os = "macos")] +use sha2::{Digest, Sha256}; +#[cfg(not(target_os = "macos"))] use single_instance::SingleInstance; +#[cfg(target_os = "macos")] +use std::fmt::Write as _; use std::path::PathBuf; +#[cfg(target_os = "macos")] +use std::process::Command; + +#[cfg(target_os = "macos")] +const MACOS_OWNER_ARBITRATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[cfg(target_os = "windows")] mod windows_service; @@ -55,6 +77,11 @@ struct DaemonArgs { #[arg(long, env = hypercolor_core::effect::EFFECTS_DIR_ENV)] effects_dir: Option, + /// Local macOS daemon topology selected by the process launcher. + #[cfg(target_os = "macos")] + #[arg(long, hide = true, value_enum, default_value_t = MacosDaemonOwnerArg::Standalone)] + macos_owner: MacosDaemonOwnerArg, + /// Run under the Windows Service Control Manager. #[cfg(target_os = "windows")] #[arg(long, hide = true)] @@ -73,6 +100,33 @@ impl DaemonArgs { servo_gpu_import_mode: self.servo_gpu_import_mode.map(Into::into), ui_dir: self.ui_dir, effects_dir: self.effects_dir, + #[cfg(target_os = "macos")] + macos_owner: Some(self.macos_owner.into()), + #[cfg(not(target_os = "macos"))] + macos_owner: None, + macos_owner_snapshot: None, + } + } +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +enum MacosDaemonOwnerArg { + AppSidecar, + DirectLaunchd, + Homebrew, + #[default] + Standalone, +} + +#[cfg(target_os = "macos")] +impl From for MacosDaemonOwner { + fn from(value: MacosDaemonOwnerArg) -> Self { + match value { + MacosDaemonOwnerArg::AppSidecar => Self::AppSidecar, + MacosDaemonOwnerArg::DirectLaunchd => Self::DirectLaunchd, + MacosDaemonOwnerArg::Homebrew => Self::Homebrew, + MacosDaemonOwnerArg::Standalone => Self::Standalone, } } } @@ -113,19 +167,318 @@ impl From for ServoGpuImportMode { fn main() -> Result<()> { let args = DaemonArgs::parse(); + #[cfg(target_os = "macos")] + let macos_owner = args.macos_owner.into(); + #[cfg(target_os = "macos")] + let macos_owner_store = MacosOwnerStore::new(ConfigManager::data_dir()); + #[cfg(target_os = "macos")] + let macos_owner_identity = current_macos_owner_identity()?; + #[cfg(target_os = "macos")] + let macos_instance_guard = match try_acquire_macos_daemon_guard(&daemon_instance_name()) + .map_err(anyhow::Error::msg) + .context("failed to acquire daemon single-instance guard")? + { + Some(guard) => guard, + None => match arbitrate_macos_owner_contention( + &macos_owner_store, + macos_owner, + &macos_owner_identity, + )? { + MacosOwnerContention::GuardHeld => { + let exit_code = macos_contender_exit_code(args.macos_owner); + if exit_code == 0 { + return Ok(()); + } + std::process::exit(exit_code); + } + MacosOwnerContention::Reacquired(guard) => guard, + }, + }; + #[cfg(not(target_os = "macos"))] let instance = SingleInstance::new(&daemon_instance_name()) .context("failed to acquire daemon single-instance guard")?; + #[cfg(not(target_os = "macos"))] if !instance.is_single() { eprintln!("hypercolor-daemon is already running; exiting"); return Ok(()); } + #[cfg(not(target_os = "macos"))] + let _instance_guard = instance; + + #[cfg(target_os = "macos")] + let mut owner_snapshot = publish_macos_owner( + &macos_owner_store, + &macos_instance_guard, + macos_owner, + macos_owner_identity, + )?; + #[cfg(target_os = "macos")] + if let Some(MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner, + prior_owner, + phase, + }) = recover_incoming_daemon_owner(&macos_owner_store, macos_owner) + .context("failed to recover the macOS daemon owner journal before runtime startup")? + { + owner_snapshot = owner_snapshot.with_recovery_required(Some(MacosOwnerRecoveryRequired { + requested_owner, + prior_owner, + phase, + })); + eprintln!( + "macos_daemon_owner_recovery_required: requested={requested_owner:?} prior={prior_owner:?} phase={phase:?}" + ); + } #[cfg(target_os = "windows")] if args.windows_service { return windows_service::run(args.into_run_options()); } - run_daemon(args.into_run_options()) + let mut options = args.into_run_options(); + #[cfg(target_os = "macos")] + { + options.macos_owner_snapshot = Some(owner_snapshot); + } + run_daemon(options) +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +enum MacosOwnerContention { + GuardHeld, + Reacquired(MacosDaemonGuard), +} + +#[cfg(target_os = "macos")] +fn arbitrate_macos_owner_contention( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, + identity: &MacosOwnerIdentity, +) -> Result { + arbitrate_macos_owner_contention_with( + store, + owner, + identity, + &daemon_instance_name(), + MACOS_OWNER_ARBITRATION_TIMEOUT, + ) +} + +#[cfg(target_os = "macos")] +fn arbitrate_macos_owner_contention_with( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, + identity: &MacosOwnerIdentity, + instance_name: &str, + timeout: std::time::Duration, +) -> Result { + use notify::{RecursiveMode, Watcher}; + use std::sync::mpsc; + + if try_record_macos_owner_conflict(store, owner, identity) { + return resolve_macos_guard_state(instance_name); + } + + let owner_path = store.owner_record_path(); + let directory = owner_path + .parent() + .context("macOS owner record has no parent directory")? + .to_path_buf(); + let directory_ready = std::fs::create_dir_all(&directory).is_ok(); + enum ArbitrationSignal { + OwnerRecordChanged, + GuardAcquired(Result), + } + + let (signal_tx, signal_rx) = mpsc::sync_channel(2); + let watched_path = owner_path.clone(); + let owner_signal_tx = signal_tx.clone(); + let mut watcher = directory_ready + .then(|| { + notify::recommended_watcher(move |event: notify::Result| { + if event.is_ok_and(|event| event.paths.iter().any(|path| path == &watched_path)) { + let _ = owner_signal_tx.try_send(ArbitrationSignal::OwnerRecordChanged); + } + }) + }) + .transpose() + .ok() + .flatten(); + if let Some(active_watcher) = watcher.as_mut() { + let _ = active_watcher.watch(&directory, RecursiveMode::NonRecursive); + } + + if try_record_macos_owner_conflict(store, owner, identity) { + return resolve_macos_guard_state(instance_name); + } + + let guard_signal_tx = signal_tx; + let guard_instance_name = instance_name.to_owned(); + std::thread::Builder::new() + .name("hypercolor-macos-owner-arbitration".to_owned()) + .spawn(move || { + let result = + acquire_macos_daemon_guard(&guard_instance_name).map_err(|error| error.to_string()); + let _ = guard_signal_tx.send(ArbitrationSignal::GuardAcquired(result)); + }) + .context("failed to start the macOS owner guard waiter")?; + + let started = std::time::Instant::now(); + while let Some(remaining) = timeout.checked_sub(started.elapsed()) { + match signal_rx.recv_timeout(remaining) { + Ok(ArbitrationSignal::OwnerRecordChanged) => { + if try_record_macos_owner_conflict(store, owner, identity) { + return resolve_macos_guard_state(instance_name); + } + } + Ok(ArbitrationSignal::GuardAcquired(Ok(guard))) => { + return Ok(MacosOwnerContention::Reacquired(guard)); + } + Ok(ArbitrationSignal::GuardAcquired(Err(error))) => { + anyhow::bail!("failed to reacquire the daemon single-instance guard: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("macOS owner arbitration watch disconnected") + } + } + } + + resolve_macos_guard_state(instance_name) +} + +#[cfg(target_os = "macos")] +fn try_record_macos_owner_conflict( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, + identity: &MacosOwnerIdentity, +) -> bool { + match record_macos_owner_conflict(store, owner, identity.clone()) { + Ok(()) => true, + Err(error) => { + eprintln!("macos_daemon_owner_diagnostic_unavailable: {error:#}"); + false + } + } +} + +#[cfg(target_os = "macos")] +fn resolve_macos_guard_state(instance_name: &str) -> Result { + match try_acquire_macos_daemon_guard(instance_name) + .map_err(anyhow::Error::msg) + .context("failed to inspect the authoritative daemon guard")? + { + Some(guard) => Ok(MacosOwnerContention::Reacquired(guard)), + None => Ok(MacosOwnerContention::GuardHeld), + } +} + +#[cfg(target_os = "macos")] +const fn launchd_contender_exits_zero(owner: MacosDaemonOwnerArg) -> bool { + matches!( + owner, + MacosDaemonOwnerArg::DirectLaunchd | MacosDaemonOwnerArg::Homebrew + ) +} + +#[cfg(target_os = "macos")] +const fn macos_contender_exit_code(owner: MacosDaemonOwnerArg) -> i32 { + if launchd_contender_exits_zero(owner) { + 0 + } else { + MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE + } +} + +#[cfg(target_os = "macos")] +fn publish_macos_owner( + store: &MacosOwnerStore, + guard: &MacosDaemonGuard, + owner: MacosDaemonOwner, + identity: MacosOwnerIdentity, +) -> Result { + let record = store + .publish_guard_winner(guard, owner, identity) + .context("failed to publish the macOS daemon owner")?; + Ok(record.snapshot()) +} + +#[cfg(target_os = "macos")] +fn record_macos_owner_conflict( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, + identity: MacosOwnerIdentity, +) -> Result<()> { + let observed_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock predates the Unix epoch")? + .as_millis() + .try_into() + .context("macOS owner conflict timestamp exceeds u64")?; + let update = store + .record_conflict(owner, identity, observed_at_ms) + .context("failed to publish the macOS daemon owner conflict")?; + let snapshot = update.snapshot(); + eprintln!( + "macos_daemon_owner_conflict: active={:?} epoch={} contender={owner:?}", + snapshot.active_owner, snapshot.owner_epoch + ); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn current_macos_owner_identity() -> Result { + let executable_path = + std::env::current_exe().context("failed to resolve the current daemon executable")?; + let requirement = designated_requirement(&executable_path)?; + let digest = Sha256::digest(requirement.as_bytes()); + let mut designated_requirement_hash = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut designated_requirement_hash, "{byte:02x}") + .expect("writing into a String cannot fail"); + } + MacosOwnerIdentity::new( + current_process_audit_token_identity()?, + executable_path, + designated_requirement_hash, + std::process::id(), + ) + .map_err(anyhow::Error::from) +} + +#[cfg(target_os = "macos")] +fn designated_requirement(executable_path: &std::path::Path) -> Result { + let output = Command::new("/usr/bin/codesign") + .args(["-d", "-r-"]) + .arg(executable_path) + .output() + .context("failed to inspect the daemon code signature")?; + if !output.status.success() { + anyhow::bail!("codesign could not read the daemon designated requirement"); + } + parse_designated_requirement(&output.stdout) +} + +#[cfg(target_os = "macos")] +fn parse_designated_requirement(stdout: &[u8]) -> Result { + const MAX_CODESIGN_STDOUT_BYTES: usize = 16 * 1024; + const MAX_DESIGNATED_REQUIREMENT_BYTES: usize = 8 * 1024; + + if stdout.len() > MAX_CODESIGN_STDOUT_BYTES { + anyhow::bail!("codesign designated-requirement output exceeds 16 KiB"); + } + let stdout = std::str::from_utf8(stdout) + .context("codesign returned a non-UTF-8 designated requirement")?; + let requirement = stdout.lines().find_map(|line| { + line.strip_prefix("designated => ") + .or_else(|| line.strip_prefix("# designated => ")) + }); + let requirement = requirement.context("codesign omitted the daemon designated requirement")?; + if requirement.is_empty() || requirement.len() > MAX_DESIGNATED_REQUIREMENT_BYTES { + anyhow::bail!("codesign designated requirement is empty or exceeds 8 KiB"); + } + Ok(requirement.to_owned()) } #[cfg(not(target_os = "macos"))] @@ -198,7 +551,18 @@ mod tests { use super::{ DaemonArgs, RenderAccelerationModeArg, ServoGpuImportModeArg, daemon_instance_name, }; + #[cfg(target_os = "macos")] + use super::{ + MacosDaemonOwnerArg, MacosOwnerContention, arbitrate_macos_owner_contention_with, + launchd_contender_exits_zero, macos_contender_exit_code, parse_designated_requirement, + }; + #[cfg(target_os = "macos")] + use hypercolor_daemon::macos_owner::{ + MacosDaemonOwner, MacosOwnerIdentity, MacosOwnerStore, try_acquire_macos_daemon_guard, + }; use hypercolor_types::config::{HypercolorConfig, RenderAccelerationMode, ServoGpuImportMode}; + #[cfg(target_os = "macos")] + use hypercolor_types::event::MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE; #[test] fn compositor_acceleration_mode_cli_override_updates_config() { @@ -270,6 +634,244 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn launchd_managed_contenders_exit_zero_without_respawn() { + assert!(launchd_contender_exits_zero( + MacosDaemonOwnerArg::DirectLaunchd + )); + assert!(launchd_contender_exits_zero(MacosDaemonOwnerArg::Homebrew)); + assert!(!launchd_contender_exits_zero( + MacosDaemonOwnerArg::AppSidecar + )); + assert!(!launchd_contender_exits_zero( + MacosDaemonOwnerArg::Standalone + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn held_guard_applies_topology_policy_without_an_owner_record() { + for (owner, owner_arg, exits_zero) in [ + ( + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwnerArg::DirectLaunchd, + true, + ), + ( + MacosDaemonOwner::Homebrew, + MacosDaemonOwnerArg::Homebrew, + true, + ), + ( + MacosDaemonOwner::AppSidecar, + MacosDaemonOwnerArg::AppSidecar, + false, + ), + ( + MacosDaemonOwner::Standalone, + MacosDaemonOwnerArg::Standalone, + false, + ), + ] { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let guard_path = directory.path().join(format!("{owner:?}.lock")); + let guard_name = guard_path.to_string_lossy().into_owned(); + let _winner = try_acquire_macos_daemon_guard(&guard_name) + .expect("guard inspection should succeed") + .expect("fixture winner should acquire the guard"); + let outcome = arbitrate_macos_owner_contention_with( + &store, + owner, + &owner_identity(owner, 200), + &guard_name, + std::time::Duration::ZERO, + ) + .expect("held guard should produce a terminal contention outcome"); + assert!(matches!(outcome, MacosOwnerContention::GuardHeld)); + assert_eq!(launchd_contender_exits_zero(owner_arg), exits_zero); + assert_eq!( + macos_contender_exit_code(owner_arg), + if exits_zero { + 0 + } else { + MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE + } + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn malformed_diagnostics_never_override_held_guard_policy() { + for (owner, owner_arg, bytes, exits_zero) in [ + ( + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwnerArg::DirectLaunchd, + b"{ malformed".to_vec(), + true, + ), + ( + MacosDaemonOwner::Homebrew, + MacosDaemonOwnerArg::Homebrew, + future_owner_record(), + true, + ), + ( + MacosDaemonOwner::AppSidecar, + MacosDaemonOwnerArg::AppSidecar, + b"{ malformed".to_vec(), + false, + ), + ( + MacosDaemonOwner::Standalone, + MacosDaemonOwnerArg::Standalone, + future_owner_record(), + false, + ), + ] { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + std::fs::write(store.owner_record_path(), bytes) + .expect("diagnostic fixture should write"); + let guard_path = directory.path().join(format!("{owner:?}.lock")); + let guard_name = guard_path.to_string_lossy().into_owned(); + let _winner = try_acquire_macos_daemon_guard(&guard_name) + .expect("guard inspection should succeed") + .expect("fixture winner should acquire the guard"); + let outcome = arbitrate_macos_owner_contention_with( + &store, + owner, + &owner_identity(owner, 201), + &guard_name, + std::time::Duration::ZERO, + ) + .expect("invalid diagnostics should not override the held guard"); + assert!(matches!(outcome, MacosOwnerContention::GuardHeld)); + assert_eq!(launchd_contender_exits_zero(owner_arg), exits_zero); + assert_eq!( + macos_contender_exit_code(owner_arg), + if exits_zero { + 0 + } else { + MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE + } + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn owner_record_alone_never_authorizes_a_contender_loss() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + owner_identity(MacosDaemonOwner::DirectLaunchd, 101), + ) + .expect("diagnostic owner should publish"); + let guard_name = directory + .path() + .join("unheld.lock") + .to_string_lossy() + .into_owned(); + let outcome = arbitrate_macos_owner_contention_with( + &store, + MacosDaemonOwner::AppSidecar, + &owner_identity(MacosDaemonOwner::AppSidecar, 202), + &guard_name, + std::time::Duration::ZERO, + ) + .expect("free guard should be acquired despite a durable owner record"); + assert!(matches!(outcome, MacosOwnerContention::Reacquired(_))); + } + + #[cfg(target_os = "macos")] + #[test] + fn authoritative_guard_acquisition_failures_remain_fatal() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path().join("owner-state")); + store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + owner_identity(MacosDaemonOwner::DirectLaunchd, 101), + ) + .expect("diagnostic owner should publish"); + let error = arbitrate_macos_owner_contention_with( + &store, + MacosDaemonOwner::AppSidecar, + &owner_identity(MacosDaemonOwner::AppSidecar, 202), + &directory.path().to_string_lossy(), + std::time::Duration::ZERO, + ) + .expect_err("opening a directory as the guard file must remain fatal"); + assert!( + error + .to_string() + .contains("failed to inspect the authoritative daemon guard") + ); + } + + #[cfg(target_os = "macos")] + fn owner_identity(owner: MacosDaemonOwner, pid: u32) -> MacosOwnerIdentity { + MacosOwnerIdentity::new( + format!("audit-{owner:?}-{pid}"), + format!("/Applications/{owner:?}/hypercolor-daemon"), + format!("requirement-{owner:?}"), + pid, + ) + .expect("fixture identity should build") + } + + #[cfg(target_os = "macos")] + fn future_owner_record() -> Vec { + serde_json::to_vec(&serde_json::json!({ + "schema_version": 99, + "owner_epoch": 1, + "active_owner": "app_sidecar", + "active_identity": { + "audit_token_identity": "audit-winner", + "executable_path": "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "designated_requirement_hash": "requirement-winner", + "pid": 100 + }, + "conflict": null, + "selected_external_owner": null + })) + .expect("future owner fixture should serialize") + } + + #[cfg(target_os = "macos")] + #[test] + fn designated_requirement_parser_accepts_signed_and_ad_hoc_stdout() { + assert_eq!( + parse_designated_requirement( + b"designated => identifier \"tech.hyperbliss.hypercolor.daemon\" and anchor apple generic\n" + ) + .expect("signed requirement should parse"), + "identifier \"tech.hyperbliss.hypercolor.daemon\" and anchor apple generic" + ); + assert_eq!( + parse_designated_requirement(b"# designated => cdhash H\"0123456789abcdef\"\n") + .expect("ad-hoc requirement should parse"), + "cdhash H\"0123456789abcdef\"" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn designated_requirement_parser_rejects_near_matches_and_oversized_output() { + assert!(parse_designated_requirement(b"Executable=/tmp/hypercolor-daemon\n").is_err()); + assert!(parse_designated_requirement(b" designated => identifier \"wrong\"\n").is_err()); + assert!(parse_designated_requirement(b"designated => \n").is_err()); + assert!(parse_designated_requirement(&[0xff]).is_err()); + assert!(parse_designated_requirement(&vec![b'x'; 16 * 1024 + 1]).is_err()); + let oversized_requirement = format!("designated => {}\n", "x".repeat(8 * 1024 + 1)); + assert!(parse_designated_requirement(oversized_requirement.as_bytes()).is_err()); + } + #[test] fn servo_gpu_import_arg_maps_all_modes() { assert_eq!( diff --git a/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs new file mode 100644 index 000000000..7e9eecdab --- /dev/null +++ b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs @@ -0,0 +1,736 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use anyhow::Context; +use arc_swap::ArcSwapOption; +use hypercolor_core::bus::HypercolorBus; +use hypercolor_core::input::{InputManager, MacosCapabilityOwner, MacosDaemonOwnerConflict}; +use hypercolor_types::event::{ + HypercolorEvent, MacosDaemonHandoverPhaseEvent, MacosDaemonOwnerConflictEvent, + MacosDaemonOwnerEvent, MacosDaemonOwnerRecoveryRequiredEvent, +}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::Mutex; +use tracing::warn; + +use crate::macos_owner::{ + MacosDaemonOwner, MacosHandoverPhase, MacosOwnerRecord, MacosOwnerSnapshot, MacosOwnerStore, +}; + +const WATCH_WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); + +enum WatchSignal { + Changed, +} + +pub(crate) struct PendingMacosOwnerWatch { + watcher: RecommendedWatcher, + signal_tx: SyncSender, + signal_rx: Receiver, + stopping: Arc, + store: MacosOwnerStore, + snapshots: Arc>, + event_bus: Arc, + startup_snapshot: MacosOwnerSnapshot, + reconciled_fingerprint: Option, +} + +impl PendingMacosOwnerWatch { + pub(crate) fn start( + data_dir: PathBuf, + snapshots: Arc>, + event_bus: Arc, + startup_snapshot: MacosOwnerSnapshot, + ) -> anyhow::Result { + let store = MacosOwnerStore::new(&data_dir); + let (signal_tx, signal_rx) = mpsc::sync_channel(1); + let callback_tx = signal_tx.clone(); + let callback_owner_record_path = store.owner_record_path(); + let mut watcher = notify::recommended_watcher( + move |result: notify::Result| match result { + Ok(event) if event_touches_owner_record(&event, &callback_owner_record_path) => { + enqueue_change(&callback_tx); + } + Ok(_) => {} + Err(error) => warn!(%error, "macOS daemon owner watch failed"), + }, + ) + .context("failed to create the macOS daemon owner watch")?; + watcher + .watch(&data_dir, RecursiveMode::NonRecursive) + .with_context(|| { + format!( + "failed to watch the macOS daemon owner directory {}", + data_dir.display() + ) + })?; + Ok(Self { + watcher, + signal_tx, + signal_rx, + stopping: Arc::new(AtomicBool::new(false)), + store, + snapshots, + event_bus, + startup_snapshot, + reconciled_fingerprint: None, + }) + } + + pub(crate) fn reconcile_snapshot( + &mut self, + fallback: MacosOwnerSnapshot, + ) -> Result { + let Some(record) = self.store.load_owner_record()? else { + self.reconciled_fingerprint = None; + return Ok(fallback); + }; + self.reconciled_fingerprint = Some(MacosOwnerIdentityFingerprint::from(&record)); + Ok(snapshot_with_startup_recovery( + &record, + self.startup_snapshot, + )) + } + + pub(crate) fn attach( + self, + input_manager: Arc>, + ) -> anyhow::Result { + let Self { + watcher, + signal_tx, + signal_rx, + stopping, + store, + snapshots, + event_bus, + startup_snapshot, + reconciled_fingerprint, + } = self; + let (snapshot_tx, mut snapshot_rx) = tokio::sync::watch::channel(None); + let (worker_done_tx, worker_done_rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("hypercolor-macos-owner-watch".to_owned()) + .spawn({ + let stopping = Arc::clone(&stopping); + move || { + watch_worker( + signal_rx, + &stopping, + &store, + &snapshot_tx, + startup_snapshot, + reconciled_fingerprint, + ); + let _ = worker_done_tx.try_send(()); + } + }) + .context("failed to spawn the macOS daemon owner watch worker")?; + let publisher = tokio::spawn(async move { + while snapshot_rx.changed().await.is_ok() { + let Some(snapshot) = *snapshot_rx.borrow_and_update() else { + continue; + }; + let mut input_manager = input_manager.lock().await; + if let Err(error) = + publish_owner_snapshot(&snapshots, &mut input_manager, &event_bus, snapshot) + { + warn!(%error, "failed to publish macOS daemon ownership"); + } + } + }); + Ok(MacosOwnerWatch { + watcher: Some(watcher), + signal_tx, + stopping, + worker: Some(worker), + worker_done_rx, + publisher: Some(publisher), + }) + } +} + +pub(crate) struct MacosOwnerWatch { + watcher: Option, + signal_tx: SyncSender, + stopping: Arc, + worker: Option>, + worker_done_rx: Receiver<()>, + publisher: Option>, +} + +impl Drop for MacosOwnerWatch { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + drop(self.watcher.take()); + enqueue_change(&self.signal_tx); + if let Some(publisher) = self.publisher.take() { + publisher.abort(); + } + if self + .worker_done_rx + .recv_timeout(WATCH_WORKER_SHUTDOWN_TIMEOUT) + .is_ok() + && let Some(worker) = self.worker.take() + { + let _ = worker.join(); + } + } +} + +fn event_touches_owner_record(event: ¬ify::Event, owner_record_path: &Path) -> bool { + event.paths.iter().any(|path| path == owner_record_path) +} + +fn enqueue_change(signal_tx: &SyncSender) { + let _ = signal_tx.try_send(WatchSignal::Changed); +} + +fn watch_worker( + signal_rx: Receiver, + stopping: &AtomicBool, + store: &MacosOwnerStore, + snapshots: &tokio::sync::watch::Sender>, + startup_snapshot: MacosOwnerSnapshot, + mut fingerprint: Option, +) { + while !stopping.load(Ordering::Acquire) && signal_rx.recv().is_ok() { + if stopping.load(Ordering::Acquire) { + break; + } + if let Err(error) = + refresh_owner_snapshot(store, snapshots, &mut fingerprint, startup_snapshot) + { + warn!(%error, "failed to refresh macOS daemon ownership"); + } + } +} + +pub(crate) fn publish_owner_snapshot( + snapshots: &ArcSwapOption, + input_manager: &mut InputManager, + event_bus: &HypercolorBus, + snapshot: MacosOwnerSnapshot, +) -> anyhow::Result<()> { + publish_owner_snapshot_with(snapshots, input_manager, snapshot, |published_snapshot| { + event_bus.publish(owner_event(published_snapshot)); + }) +} + +fn publish_owner_snapshot_with( + snapshots: &ArcSwapOption, + input_manager: &mut InputManager, + snapshot: MacosOwnerSnapshot, + publish_event: impl FnOnce(MacosOwnerSnapshot), +) -> anyhow::Result<()> { + input_manager.set_macos_daemon_ownership( + capability_owner(snapshot.active_owner), + snapshot.conflict.map(|conflict| MacosDaemonOwnerConflict { + active: capability_owner(conflict.active_owner), + contender: capability_owner(conflict.contender_owner), + observed_at_ms: conflict.observed_at_ms, + }), + )?; + snapshots.store(Some(Arc::new(snapshot))); + publish_event(snapshot); + Ok(()) +} + +fn refresh_owner_snapshot( + store: &MacosOwnerStore, + snapshots: &tokio::sync::watch::Sender>, + fingerprint: &mut Option, + startup_snapshot: MacosOwnerSnapshot, +) -> anyhow::Result<()> { + let Some(record) = store.load_owner_record()? else { + return Ok(()); + }; + let next_fingerprint = MacosOwnerIdentityFingerprint::from(&record); + if fingerprint.as_ref() == Some(&next_fingerprint) { + return Ok(()); + } + *fingerprint = Some(next_fingerprint); + snapshots.send_replace(Some(snapshot_with_startup_recovery( + &record, + startup_snapshot, + ))); + Ok(()) +} + +fn snapshot_with_startup_recovery( + record: &MacosOwnerRecord, + startup_snapshot: MacosOwnerSnapshot, +) -> MacosOwnerSnapshot { + let recovery_required = (record.active_owner == startup_snapshot.active_owner + && record.owner_epoch == startup_snapshot.owner_epoch) + .then_some(startup_snapshot.recovery_required) + .flatten(); + record.snapshot().with_recovery_required(recovery_required) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MacosOwnerIdentityFingerprint { + active_owner: MacosDaemonOwner, + owner_epoch: u64, + active_audit_token_identity: String, + active_executable_path: PathBuf, + active_designated_requirement_hash: String, + active_pid: u32, + conflict: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MacosConflictIdentityFingerprint { + active_owner: MacosDaemonOwner, + active_epoch: u64, + contender_owner: MacosDaemonOwner, + audit_token_identity: String, + executable_path: PathBuf, + designated_requirement_hash: String, + pid: u32, +} + +impl From<&MacosOwnerRecord> for MacosOwnerIdentityFingerprint { + fn from(record: &MacosOwnerRecord) -> Self { + Self { + active_owner: record.active_owner, + owner_epoch: record.owner_epoch, + active_audit_token_identity: record.active_identity.audit_token_identity.clone(), + active_executable_path: record.active_identity.executable_path.clone(), + active_designated_requirement_hash: record + .active_identity + .designated_requirement_hash + .clone(), + active_pid: record.active_identity.pid, + conflict: record + .conflict + .as_ref() + .map(|conflict| MacosConflictIdentityFingerprint { + active_owner: conflict.active_owner, + active_epoch: conflict.active_epoch, + contender_owner: conflict.contender_owner, + audit_token_identity: conflict.contender_identity.audit_token_identity.clone(), + executable_path: conflict.contender_identity.executable_path.clone(), + designated_requirement_hash: conflict + .contender_identity + .designated_requirement_hash + .clone(), + pid: conflict.contender_identity.pid, + }), + } + } +} + +const fn capability_owner(owner: MacosDaemonOwner) -> MacosCapabilityOwner { + match owner { + MacosDaemonOwner::AppSidecar => MacosCapabilityOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd => MacosCapabilityOwner::LaunchdService, + MacosDaemonOwner::Homebrew => MacosCapabilityOwner::HomebrewService, + MacosDaemonOwner::Standalone => MacosCapabilityOwner::Standalone, + } +} + +const fn owner_event_owner(owner: MacosDaemonOwner) -> MacosDaemonOwnerEvent { + match owner { + MacosDaemonOwner::AppSidecar => MacosDaemonOwnerEvent::AppSidecar, + MacosDaemonOwner::DirectLaunchd => MacosDaemonOwnerEvent::LaunchdService, + MacosDaemonOwner::Homebrew => MacosDaemonOwnerEvent::HomebrewService, + MacosDaemonOwner::Standalone => MacosDaemonOwnerEvent::Standalone, + } +} + +fn owner_event(snapshot: MacosOwnerSnapshot) -> HypercolorEvent { + HypercolorEvent::MacosDaemonOwnershipChanged { + active_owner: owner_event_owner(snapshot.active_owner), + owner_epoch: snapshot.owner_epoch, + conflict: snapshot + .conflict + .map(|conflict| MacosDaemonOwnerConflictEvent { + active: owner_event_owner(conflict.active_owner), + contender: owner_event_owner(conflict.contender_owner), + observed_at_ms: conflict.observed_at_ms, + }), + recovery_required: snapshot.recovery_required.map(|recovery| { + MacosDaemonOwnerRecoveryRequiredEvent { + requested_owner: owner_event_owner(recovery.requested_owner), + prior_owner: owner_event_owner(recovery.prior_owner), + phase: owner_event_phase(recovery.phase), + } + }), + } +} + +const fn owner_event_phase(phase: MacosHandoverPhase) -> MacosDaemonHandoverPhaseEvent { + match phase { + MacosHandoverPhase::Prepared => MacosDaemonHandoverPhaseEvent::Prepared, + MacosHandoverPhase::AutostartsConfigured => { + MacosDaemonHandoverPhaseEvent::AutostartsConfigured + } + MacosHandoverPhase::StopRequested => MacosDaemonHandoverPhaseEvent::StopRequested, + MacosHandoverPhase::OutgoingOwnerStopped => { + MacosDaemonHandoverPhaseEvent::OutgoingOwnerStopped + } + MacosHandoverPhase::AwaitingGuardRelease => { + MacosDaemonHandoverPhaseEvent::AwaitingGuardRelease + } + MacosHandoverPhase::GuardReleased => MacosDaemonHandoverPhaseEvent::GuardReleased, + MacosHandoverPhase::StartRequested => MacosDaemonHandoverPhaseEvent::StartRequested, + MacosHandoverPhase::RequestedOwnerStarted => { + MacosDaemonHandoverPhaseEvent::RequestedOwnerStarted + } + MacosHandoverPhase::CommitPending => MacosDaemonHandoverPhaseEvent::CommitPending, + MacosHandoverPhase::Committed => MacosDaemonHandoverPhaseEvent::Committed, + MacosHandoverPhase::RollbackPending => MacosDaemonHandoverPhaseEvent::RollbackPending, + MacosHandoverPhase::RollbackAutostartsRestored => { + MacosDaemonHandoverPhaseEvent::RollbackAutostartsRestored + } + MacosHandoverPhase::RollbackStopRequested => { + MacosDaemonHandoverPhaseEvent::RollbackStopRequested + } + MacosHandoverPhase::RollbackOwnerStopped => { + MacosDaemonHandoverPhaseEvent::RollbackOwnerStopped + } + MacosHandoverPhase::RollbackAwaitingGuardRelease => { + MacosDaemonHandoverPhaseEvent::RollbackAwaitingGuardRelease + } + MacosHandoverPhase::RollbackGuardReleased => { + MacosDaemonHandoverPhaseEvent::RollbackGuardReleased + } + MacosHandoverPhase::RollbackStartRequested => { + MacosDaemonHandoverPhaseEvent::RollbackStartRequested + } + MacosHandoverPhase::PriorOwnerStarted => MacosDaemonHandoverPhaseEvent::PriorOwnerStarted, + MacosHandoverPhase::RollbackCommitPending => { + MacosDaemonHandoverPhaseEvent::RollbackCommitPending + } + MacosHandoverPhase::RolledBack => MacosDaemonHandoverPhaseEvent::RolledBack, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use std::sync::mpsc; + use std::time::Duration; + + use super::{ + MacosOwnerIdentityFingerprint, PendingMacosOwnerWatch, enqueue_change, + event_touches_owner_record, owner_event, publish_owner_snapshot_with, + refresh_owner_snapshot, snapshot_with_startup_recovery, watch_worker, + }; + use crate::macos_owner::{ + MacosDaemonOwner, MacosHandoverPhase, MacosOwnerIdentity, MacosOwnerRecoveryRequired, + MacosOwnerStore, + }; + use arc_swap::ArcSwapOption; + use hypercolor_core::bus::HypercolorBus; + use hypercolor_core::input::InputManager; + use notify::{Event, EventKind}; + + fn identity(path: &std::path::Path, pid: u32) -> MacosOwnerIdentity { + MacosOwnerIdentity::new("00000001", path, "deadbeef", pid) + .expect("fixture owner identity is valid") + } + + #[test] + fn refresh_coalesces_identical_snapshots_and_publishes_distinct_conflicts() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let active = identity(&directory.path().join("active-daemon"), 10); + let record = store + .publish_owner(MacosDaemonOwner::AppSidecar, active) + .expect("fixture owner should publish"); + let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(None); + let startup_snapshot = record.snapshot(); + let mut fingerprint = Some(MacosOwnerIdentityFingerprint::from(&record)); + + refresh_owner_snapshot(&store, &snapshot_tx, &mut fingerprint, startup_snapshot) + .expect("identical snapshot should coalesce"); + assert!(snapshot_rx.borrow().is_none()); + + store + .record_conflict( + MacosDaemonOwner::Homebrew, + identity(&directory.path().join("contender-daemon"), 20), + 42, + ) + .expect("distinct contender should publish"); + refresh_owner_snapshot(&store, &snapshot_tx, &mut fingerprint, startup_snapshot) + .expect("distinct conflict should refresh"); + + assert_eq!( + snapshot_rx + .borrow() + .expect("updated snapshot should remain installed") + .conflict + .expect("updated snapshot should include conflict") + .observed_at_ms, + 42 + ); + } + + #[test] + fn private_fingerprint_observes_identity_changes_hidden_from_public_status() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let first = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("first-daemon"), 10), + ) + .expect("fixture owner should publish"); + let mut second = first.clone(); + second.active_identity = identity(&directory.path().join("second-daemon"), 11); + + assert_eq!(first.snapshot(), second.snapshot()); + assert_ne!( + MacosOwnerIdentityFingerprint::from(&first), + MacosOwnerIdentityFingerprint::from(&second) + ); + let public = serde_json::to_string(&second.snapshot()).expect("snapshot should encode"); + assert!(!public.contains("second-daemon")); + assert!(!public.contains("executable")); + } + + #[test] + fn recovery_status_survives_same_epoch_refresh_and_clears_for_a_new_owner() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner( + MacosDaemonOwner::Homebrew, + identity(&directory.path().join("unrelated-daemon"), 10), + ) + .expect("fixture owner should publish"); + let recovery = MacosOwnerRecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: MacosHandoverPhase::Prepared, + }; + let startup_snapshot = record.snapshot().with_recovery_required(Some(recovery)); + + let refreshed = snapshot_with_startup_recovery(&record, startup_snapshot); + assert_eq!(refreshed.recovery_required, Some(recovery)); + let encoded = serde_json::to_value(owner_event(refreshed)) + .expect("ownership recovery event should serialize"); + assert_eq!(encoded["data"]["recovery_required"]["phase"], "prepared"); + + let next = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity(&directory.path().join("requested-daemon"), 20), + ) + .expect("replacement owner should publish"); + assert_eq!( + snapshot_with_startup_recovery(&next, startup_snapshot).recovery_required, + None + ); + } + + #[test] + fn snapshot_store_precedes_event_publication() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let snapshot = MacosOwnerStore::new(directory.path()) + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("active-daemon"), 10), + ) + .expect("fixture owner should publish") + .snapshot(); + let snapshots = ArcSwapOption::empty(); + let mut input_manager = InputManager::new(); + let mut event_published = false; + + publish_owner_snapshot_with( + &snapshots, + &mut input_manager, + snapshot, + |published_snapshot| { + assert_eq!(snapshots.load_full().as_deref(), Some(&published_snapshot)); + event_published = true; + }, + ) + .expect("snapshot should publish"); + + assert!(event_published); + } + + #[test] + fn reconcile_after_watch_registration_closes_the_pre_source_race() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let initial = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("active-daemon"), 10), + ) + .expect("fixture owner should publish") + .snapshot(); + let snapshots = Arc::new(ArcSwapOption::from(Some(Arc::new(initial)))); + let event_bus = Arc::new(HypercolorBus::new()); + let mut pending = PendingMacosOwnerWatch::start( + directory.path().to_path_buf(), + snapshots, + event_bus, + initial, + ) + .expect("owner watch should register"); + + store + .record_conflict( + MacosDaemonOwner::Homebrew, + identity(&directory.path().join("contender-daemon"), 20), + 42, + ) + .expect("conflict should publish after watch registration"); + let reconciled = pending + .reconcile_snapshot(initial) + .expect("pre-source reconcile should read the latest record"); + + assert_eq!( + reconciled + .conflict + .expect("reconciled snapshot should include the contender") + .observed_at_ms, + 42 + ); + let record = store + .load_owner_record() + .expect("owner record should load") + .expect("owner record should exist"); + assert_eq!( + pending.reconciled_fingerprint, + Some(MacosOwnerIdentityFingerprint::from(&record)) + ); + } + + #[test] + fn watch_signals_are_exact_path_and_latest_value_bounded() { + let owner_path = std::path::PathBuf::from("/tmp/macos-daemon-owner.json"); + let unrelated = + Event::new(EventKind::Any).add_path(std::path::PathBuf::from("/tmp/profiles.json")); + let owner = Event::new(EventKind::Any).add_path(owner_path.clone()); + assert!(!event_touches_owner_record(&unrelated, &owner_path)); + assert!(event_touches_owner_record(&owner, &owner_path)); + + let (signal_tx, signal_rx) = mpsc::sync_channel(1); + enqueue_change(&signal_tx); + enqueue_change(&signal_tx); + + assert!(signal_rx.recv().is_ok()); + assert!(signal_rx.try_recv().is_err()); + } + + #[test] + fn stopping_worker_does_not_drain_a_queued_refresh() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let startup_snapshot = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("active-daemon"), 10), + ) + .expect("fixture owner should publish") + .snapshot(); + store + .record_conflict( + MacosDaemonOwner::Homebrew, + identity(&directory.path().join("contender-daemon"), 20), + 42, + ) + .expect("conflict should publish"); + let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(None); + let (signal_tx, signal_rx) = mpsc::sync_channel(1); + enqueue_change(&signal_tx); + + watch_worker( + signal_rx, + &AtomicBool::new(true), + &store, + &snapshot_tx, + startup_snapshot, + None, + ); + + assert!(snapshot_rx.borrow().is_none()); + } + + #[test] + fn reconciled_fingerprint_coalesces_a_queued_startup_notification() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("active-daemon"), 10), + ) + .expect("fixture owner should publish"); + let startup_snapshot = record.snapshot(); + let fingerprint = MacosOwnerIdentityFingerprint::from(&record); + let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(None); + let (signal_tx, signal_rx) = mpsc::sync_channel(1); + enqueue_change(&signal_tx); + drop(signal_tx); + + watch_worker( + signal_rx, + &AtomicBool::new(false), + &store, + &snapshot_tx, + startup_snapshot, + Some(fingerprint), + ); + + assert!(snapshot_rx.borrow().is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_is_bounded_while_input_manager_is_locked() { + let directory = tempfile::tempdir().expect("temporary owner directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let initial = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity(&directory.path().join("active-daemon"), 10), + ) + .expect("fixture owner should publish") + .snapshot(); + let snapshots = Arc::new(ArcSwapOption::from(Some(Arc::new(initial)))); + let event_bus = Arc::new(HypercolorBus::new()); + let pending = PendingMacosOwnerWatch::start( + directory.path().to_path_buf(), + Arc::clone(&snapshots), + event_bus, + initial, + ) + .expect("owner watch should register"); + let input_manager = Arc::new(tokio::sync::Mutex::new(InputManager::new())); + let lock = input_manager.lock().await; + let watch = pending + .attach(Arc::clone(&input_manager)) + .expect("owner watch should attach"); + store + .record_conflict( + MacosDaemonOwner::Homebrew, + identity(&directory.path().join("contender-daemon"), 20), + 42, + ) + .expect("conflict should publish"); + enqueue_change(&watch.signal_tx); + tokio::task::yield_now().await; + + tokio::time::timeout( + Duration::from_secs(2), + tokio::task::spawn_blocking(move || drop(watch)), + ) + .await + .expect("watch shutdown must not wait for the input-manager lock") + .expect("watch shutdown task should finish"); + drop(lock); + } +} diff --git a/crates/hypercolor-daemon/src/startup/mod.rs b/crates/hypercolor-daemon/src/startup/mod.rs index e09367677..a8be36d49 100644 --- a/crates/hypercolor-daemon/src/startup/mod.rs +++ b/crates/hypercolor-daemon/src/startup/mod.rs @@ -64,6 +64,8 @@ mod discovery_worker; pub(crate) mod input_status_events; mod lifecycle; pub mod logging; +#[cfg(target_os = "macos")] +mod macos_owner_watch; pub(crate) mod services; mod signals; @@ -113,6 +115,13 @@ pub struct DaemonState { /// Event bus — broadcast events, frame data, spectrum data. pub event_bus: Arc, + /// Latest durable macOS daemon ownership state. + pub macos_daemon_ownership: + Arc>, + + #[cfg(target_os = "macos")] + _macos_owner_watch: Option, + /// Daemon-managed user media asset library. pub asset_library: Arc>, diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index cf7c3bedb..3e7ec4988 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -8,7 +8,7 @@ use std::sync::atomic::AtomicBool; use std::time::Instant; use anyhow::{Context, Result}; -use arc_swap::ArcSwap; +use arc_swap::{ArcSwap, ArcSwapOption}; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use sysinfo::{MemoryRefreshKind, RefreshKind, System}; use tokio::sync::{Mutex, RwLock, watch}; @@ -88,6 +88,18 @@ fn open_persisted_library_store( } impl DaemonState { + pub fn initialize(config: &HypercolorConfig, config_path: PathBuf) -> Result { + Self::initialize_with_macos_owner(config, config_path, None) + } + + pub fn initialize_with_macos_owner( + config: &HypercolorConfig, + config_path: PathBuf, + macos_owner_snapshot: Option, + ) -> Result { + Self::initialize_inner(config, config_path, macos_owner_snapshot) + } + /// Initialize all subsystems from a loaded configuration. /// /// This wires together the bus, registry, engines, and render loop @@ -102,8 +114,14 @@ impl DaemonState { clippy::too_many_lines, reason = "initialization is inherently sequential; splitting would scatter related setup across helpers" )] - pub fn initialize(config: &HypercolorConfig, config_path: PathBuf) -> Result { + fn initialize_inner( + config: &HypercolorConfig, + config_path: PathBuf, + macos_owner_snapshot: Option, + ) -> Result { info!("Initializing daemon subsystems"); + #[cfg(not(target_os = "macos"))] + let _ = macos_owner_snapshot; config .capture .validate() @@ -167,6 +185,18 @@ impl DaemonState { // ── Event Bus ─────────────────────────────────────────────────── let event_bus = Arc::new(HypercolorBus::new()); + let macos_daemon_ownership = Arc::new(ArcSwapOption::empty()); + #[cfg(target_os = "macos")] + let mut pending_macos_owner_watch = macos_owner_snapshot + .map(|snapshot| { + super::macos_owner_watch::PendingMacosOwnerWatch::start( + ConfigManager::data_dir(), + Arc::clone(&macos_daemon_ownership), + Arc::clone(&event_bus), + snapshot, + ) + }) + .transpose()?; let preview_runtime = Arc::new(PreviewRuntime::new(Arc::clone(&event_bus))); let zone_layout_previews = Arc::new(ZoneLayoutPreviewStore::default()); info!("Event bus created"); @@ -295,7 +325,29 @@ impl DaemonState { info!("Device lifecycle manager created"); // ── Input Manager ─────────────────────────────────────────────── + #[cfg(target_os = "macos")] + let macos_owner_snapshot = match (pending_macos_owner_watch.as_mut(), macos_owner_snapshot) + { + (Some(watch), Some(snapshot)) => Some( + watch + .reconcile_snapshot(snapshot) + .context("failed to reconcile macOS daemon ownership before source startup")?, + ), + (None, snapshot) => snapshot, + (Some(_), None) => None, + }; let (built_input_manager, browser_input) = build_input_manager(config, &config_manager)?; + #[cfg(target_os = "macos")] + let mut built_input_manager = built_input_manager; + #[cfg(target_os = "macos")] + if let Some(snapshot) = macos_owner_snapshot { + super::macos_owner_watch::publish_owner_snapshot( + &macos_daemon_ownership, + &mut built_input_manager, + &event_bus, + snapshot, + )?; + } let interaction_routing = InteractionRoutingControl::new( browser_input.registry(), 1, @@ -305,6 +357,10 @@ impl DaemonState { let input_status = built_input_manager.source_status_registry(); let screen_capacity_status = built_input_manager.screen_capacity_status_handle(); let input_manager = Arc::new(Mutex::new(built_input_manager)); + #[cfg(target_os = "macos")] + let macos_owner_watch = pending_macos_owner_watch + .map(|watch| watch.attach(Arc::clone(&input_manager))) + .transpose()?; info!( audio_enabled = config.audio.enabled, capture_enabled = config.capture.enabled, @@ -584,6 +640,9 @@ impl DaemonState { scene_manager, scene_store, event_bus, + macos_daemon_ownership, + #[cfg(target_os = "macos")] + _macos_owner_watch: macos_owner_watch, asset_library, library_store, profiles: Arc::new(RwLock::new(profiles)), diff --git a/crates/hypercolor-daemon/tests/macos_owner_tests.rs b/crates/hypercolor-daemon/tests/macos_owner_tests.rs index 5b936d809..eae72b054 100644 --- a/crates/hypercolor-daemon/tests/macos_owner_tests.rs +++ b/crates/hypercolor-daemon/tests/macos_owner_tests.rs @@ -2,6 +2,8 @@ use std::fs::{self, OpenOptions}; use std::sync::{Arc, Barrier}; use std::thread; +#[cfg(target_os = "macos")] +use hypercolor_daemon::macos_owner::try_acquire_macos_daemon_guard; use hypercolor_daemon::macos_owner::{ MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, MACOS_OWNER_RECORD_SCHEMA_VERSION, MAX_MACOS_HANDOVER_OPERATIONS, MAX_MACOS_OWNER_ARTIFACT_BYTES, MacosAutostartStates, @@ -59,27 +61,33 @@ fn owner_publication_advances_monotonic_epochs() { let store = MacosOwnerStore::new(directory.path()); let first = store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("first owner should publish"); + store + .set_external_owner_mode(Some(MacosExternalOwnerMode::DirectLaunchd)) + .expect("external owner mode should publish"); let second = store - .publish_owner( - MacosDaemonOwner::DirectLaunchd, - identity("launchd", 102), - Some(MacosExternalOwnerMode::DirectLaunchd), - ) + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("launchd", 102)) .expect("second owner should publish"); + store + .set_external_owner_mode(Some(MacosExternalOwnerMode::Homebrew)) + .expect("external owner mode should update"); let third = store - .publish_owner( - MacosDaemonOwner::Homebrew, - identity("homebrew", 103), - Some(MacosExternalOwnerMode::Homebrew), - ) + .publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 103)) .expect("third owner should publish"); assert_eq!( [first.owner_epoch, second.owner_epoch, third.owner_epoch], [1, 2, 3] ); + assert_eq!( + second.selected_external_owner, + Some(MacosExternalOwnerMode::DirectLaunchd) + ); + assert_eq!( + third.selected_external_owner, + Some(MacosExternalOwnerMode::Homebrew) + ); assert_eq!(third.schema_version, MACOS_OWNER_RECORD_SCHEMA_VERSION); assert_eq!( store @@ -90,12 +98,131 @@ fn owner_publication_advances_monotonic_epochs() { ); } +#[test] +fn owner_publication_cannot_overwrite_a_concurrent_external_mode_update() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = Arc::new(MacosOwnerStore::new(directory.path())); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let barrier = Arc::new(Barrier::new(3)); + + let publisher = { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + store + .publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 103)) + .expect("concurrent owner should publish"); + }) + }; + let selector = { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + store + .set_external_owner_mode(Some(MacosExternalOwnerMode::Homebrew)) + .expect("concurrent external mode should publish"); + }) + }; + + barrier.wait(); + publisher.join().expect("publisher should join"); + selector.join().expect("selector should join"); + assert_eq!( + store + .load_owner_record() + .expect("owner record should load") + .expect("owner record should exist") + .selected_external_owner, + Some(MacosExternalOwnerMode::Homebrew) + ); +} + +#[test] +fn owner_publication_rebases_a_prepublication_contender() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("old-sidecar", 101)) + .expect("prior owner should publish"); + store + .record_conflict( + MacosDaemonOwner::Homebrew, + identity("homebrew-contender", 201), + 100, + ) + .expect("prepublication contender should publish"); + + let owner = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("new-sidecar", 102)) + .expect("new owner should publish"); + let conflict = owner + .conflict + .expect("contender should survive publication"); + + assert_eq!(conflict.active_owner, MacosDaemonOwner::AppSidecar); + assert_eq!(conflict.active_epoch, owner.owner_epoch); + assert_eq!(conflict.contender_owner, MacosDaemonOwner::Homebrew); +} + +#[test] +fn owner_publication_preserves_a_distinct_same_topology_contender() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::Homebrew, identity("old-homebrew", 101)) + .expect("prior owner should publish"); + store + .record_conflict( + MacosDaemonOwner::AppSidecar, + identity("losing-sidecar", 201), + 100, + ) + .expect("prepublication contender should publish"); + + let owner = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity("winning-sidecar", 202), + ) + .expect("new owner should publish"); + let conflict = owner + .conflict + .expect("distinct same-topology contender should survive publication"); + + assert_eq!(conflict.active_owner, MacosDaemonOwner::AppSidecar); + assert_eq!(conflict.active_epoch, owner.owner_epoch); + assert_eq!(conflict.contender_owner, MacosDaemonOwner::AppSidecar); + assert_eq!(conflict.contender_identity.pid, 201); +} + +#[test] +fn owner_publication_clears_the_contender_that_became_active() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::Standalone, identity("standalone", 101)) + .expect("prior owner should publish"); + store + .record_conflict(MacosDaemonOwner::AppSidecar, identity("sidecar", 201), 100) + .expect("prepublication contender should publish"); + + let owner = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 202)) + .expect("contender should become the active owner"); + + assert!(owner.conflict.is_none()); +} + #[test] fn identical_conflicts_coalesce_with_the_original_observation() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = MacosOwnerStore::new(directory.path()); store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("owner should publish"); let first = store @@ -219,7 +346,7 @@ fn record_and_journal_writers_interleave_without_lost_updates() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = Arc::new(MacosOwnerStore::new(directory.path())); store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("initial owner should publish"); let id = transaction_id("concurrent-handover"); store @@ -235,7 +362,7 @@ fn record_and_journal_writers_interleave_without_lost_updates() { barrier.wait(); for _ in 0..WRITES_PER_THREAD { store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("concurrent owner publication should succeed"); } })); @@ -337,14 +464,14 @@ fn malformed_and_unknown_artifacts_reject_without_replacement() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = MacosOwnerStore::new(directory.path()); store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("owner should publish"); let valid_owner = fs::read(store.owner_record_path()).expect("owner bytes should exist"); let malformed = b"{ malformed owner record\n"; fs::write(store.owner_record_path(), malformed).expect("fixture corruption should write"); assert!(matches!( - store.publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 103), None), + store.publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 103)), Err(MacosOwnerStoreError::Decode { artifact: "owner record", .. @@ -452,6 +579,82 @@ fn malformed_and_unknown_artifacts_reject_without_replacement() { ); } +#[cfg(target_os = "macos")] +#[test] +fn proven_guard_winner_repairs_invalid_diagnostic_owner_records() { + let directory = tempfile::tempdir().expect("temporary directory should be available"); + let store = MacosOwnerStore::new(directory.path()); + let guard_name = directory + .path() + .join("daemon-instance.lock") + .to_string_lossy() + .into_owned(); + let guard = try_acquire_macos_daemon_guard(&guard_name) + .expect("guard inspection should succeed") + .expect("fixture winner should acquire the guard"); + + for invalid in [ + b"{ malformed owner record".to_vec(), + serde_json::to_vec(&json!({ + "schema_version": 99, + "owner_epoch": 1, + "active_owner": "app_sidecar", + "active_identity": { + "audit_token_identity": "audit-old", + "executable_path": "/Applications/old/hypercolor-daemon", + "designated_requirement_hash": "requirement-old", + "pid": 100 + }, + "conflict": null, + "selected_external_owner": null + })) + .expect("future-version fixture should serialize"), + serde_json::to_vec(&json!({ + "schema_version": MACOS_OWNER_RECORD_SCHEMA_VERSION, + "owner_epoch": 0, + "active_owner": "app_sidecar", + "active_identity": { + "audit_token_identity": "audit-old", + "executable_path": "/Applications/old/hypercolor-daemon", + "designated_requirement_hash": "requirement-old", + "pid": 100 + }, + "conflict": null, + "selected_external_owner": null + })) + .expect("semantically-invalid fixture should serialize"), + ] { + fs::write(store.owner_record_path(), &invalid).expect("invalid fixture should write"); + assert!( + store + .publish_owner(MacosDaemonOwner::Homebrew, identity("ordinary", 200)) + .is_err(), + "ordinary publication must remain fail-closed" + ); + assert_eq!( + fs::read(store.owner_record_path()).expect("invalid bytes should remain"), + invalid + ); + + let repaired = store + .publish_guard_winner( + &guard, + MacosDaemonOwner::Homebrew, + identity("guard-winner", 201), + ) + .expect("guard winner should atomically replace invalid diagnostics"); + assert_eq!(repaired.owner_epoch, 1); + assert_eq!(repaired.active_owner, MacosDaemonOwner::Homebrew); + assert_eq!( + store + .load_owner_record() + .expect("repaired owner should load") + .expect("repaired owner should exist"), + repaired + ); + } +} + #[test] fn oversized_artifacts_and_operation_lists_reject_without_mutation() { let directory = tempfile::tempdir().expect("temporary directory should be available"); @@ -460,7 +663,7 @@ fn oversized_artifacts_and_operation_lists_reject_without_mutation() { fs::write(store.owner_record_path(), &oversized).expect("oversized fixture should write"); assert!(matches!( - store.publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None), + store.publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)), Err(MacosOwnerStoreError::ArtifactTooLarge { artifact: "owner record", .. @@ -492,7 +695,7 @@ fn failed_mutation_releases_the_stable_coordination_lock() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = MacosOwnerStore::new(directory.path()); store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("owner should publish"); fs::write(store.owner_record_path(), b"not json").expect("fixture corruption should write"); @@ -519,11 +722,7 @@ fn diagnostic_owner_path_is_bounded_while_the_journal_stays_path_free() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = MacosOwnerStore::new(directory.path()); let owner = store - .publish_owner( - MacosDaemonOwner::DirectLaunchd, - identity("launchd", 102), - Some(MacosExternalOwnerMode::DirectLaunchd), - ) + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("launchd", 102)) .expect("owner should publish"); let journal = store .begin_handover(journal("path-free-shape")) @@ -545,7 +744,7 @@ fn durable_owner_artifacts_are_user_read_write_only() { let directory = tempfile::tempdir().expect("temporary directory should be available"); let store = MacosOwnerStore::new(directory.path()); store - .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None) + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) .expect("owner should publish"); store .begin_handover(journal("mode-check")) diff --git a/crates/hypercolor-macos-owner/Cargo.toml b/crates/hypercolor-macos-owner/Cargo.toml new file mode 100644 index 000000000..c00965cba --- /dev/null +++ b/crates/hypercolor-macos-owner/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "hypercolor-macos-owner" +description = "Durable macOS daemon ownership and handover coordination" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +hypercolor-platform-fs = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +notify = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +nix = { version = "0.29", features = ["event", "fs", "process", "signal"] } +single-instance = "0.3.3" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/hypercolor-macos-owner/src/lib.rs b/crates/hypercolor-macos-owner/src/lib.rs new file mode 100644 index 000000000..f4c408063 --- /dev/null +++ b/crates/hypercolor-macos-owner/src/lib.rs @@ -0,0 +1,2390 @@ +//! Durable macOS daemon ownership and handover state. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Current owner-record schema version. +pub const MACOS_OWNER_RECORD_SCHEMA_VERSION: u32 = 1; +/// Current handover-journal schema version. +pub const MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION: u32 = 1; +/// Stable owner-record file name within the per-user data directory. +pub const MACOS_OWNER_RECORD_FILE_NAME: &str = "macos-daemon-owner.json"; +/// Stable handover-journal file name within the per-user data directory. +pub const MACOS_HANDOVER_JOURNAL_FILE_NAME: &str = "macos-daemon-handover.json"; +/// Stable coordination-lock file name shared by both durable artifacts. +pub const MACOS_OWNER_COORDINATION_LOCK_FILE_NAME: &str = "macos-daemon-owner.lock"; +/// Tauri product name and app-sidecar LaunchAgent label. +pub const MACOS_APP_PRODUCT_NAME: &str = "Hypercolor"; +/// LaunchAgent property-list file installed by Tauri autostart. +pub const MACOS_APP_LAUNCH_AGENT_PLIST_FILE_NAME: &str = "Hypercolor.plist"; +/// Main executable location within the signed Tauri app bundle. +pub const MACOS_APP_BUNDLE_EXECUTABLE_RELATIVE_PATH: &str = "Contents/MacOS/Hypercolor"; +/// Maximum UTF-8 byte length for an audit-token identity. +pub const MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES: usize = 256; +/// Maximum UTF-8 byte length for a diagnostic executable path. +pub const MAX_MACOS_EXECUTABLE_PATH_BYTES: usize = 4_096; +/// Maximum UTF-8 byte length for a designated-requirement hash. +pub const MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES: usize = 256; +/// Maximum byte length accepted for either durable JSON artifact. +pub const MAX_MACOS_OWNER_ARTIFACT_BYTES: usize = 256 * 1_024; +/// Maximum number of closed rollback operations in one journal. +pub const MAX_MACOS_HANDOVER_OPERATIONS: usize = 64; +/// Maximum wait for a managed owner to release or acquire the daemon guard. +pub const MACOS_MANAGED_HANDOVER_TIMEOUT: Duration = Duration::from_secs(10); +/// Maximum wait for user-directed standalone-owner termination. +pub const MACOS_STANDALONE_HANDOVER_TIMEOUT: Duration = Duration::from_mins(1); +const MAX_TEMPORARY_CREATE_ATTEMPTS: usize = 64; + +static TEMPORARY_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// A daemon topology that can own protected macOS capabilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosDaemonOwner { + /// Daemon supervised by the packaged app. + AppSidecar, + /// Daemon managed by Hypercolor's direct per-user launchd service. + DirectLaunchd, + /// Daemon managed by Homebrew services. + Homebrew, + /// Daemon started directly from a terminal. + Standalone, +} + +/// An external daemon topology selected by the local app. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosExternalOwnerMode { + /// Connect to Hypercolor's direct per-user launchd service. + DirectLaunchd, + /// Connect to the Homebrew-managed service. + Homebrew, +} + +/// Bounded diagnostic identity for the process that attempted ownership. +/// +/// The executable path is diagnostic data only. It is never an executable, +/// command, or recovery authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerIdentity { + /// Stable representation of the process audit token. + pub audit_token_identity: String, + /// Absolute path observed for the process executable. + pub executable_path: PathBuf, + /// Hash of the process designated requirement. + pub designated_requirement_hash: String, + /// Process identifier observed with this identity. + pub pid: u32, +} + +impl MacosOwnerIdentity { + /// Validate and construct a diagnostic process identity. + pub fn new( + audit_token_identity: impl Into, + executable_path: impl Into, + designated_requirement_hash: impl Into, + pid: u32, + ) -> Result { + let identity = Self { + audit_token_identity: audit_token_identity.into(), + executable_path: executable_path.into(), + designated_requirement_hash: designated_requirement_hash.into(), + pid, + }; + validate_owner_identity(&identity)?; + Ok(identity) + } +} + +impl<'de> Deserialize<'de> for MacosOwnerIdentity { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct RawIdentity { + audit_token_identity: String, + executable_path: PathBuf, + designated_requirement_hash: String, + pid: u32, + } + + let raw = RawIdentity::deserialize(deserializer)?; + Self::new( + raw.audit_token_identity, + raw.executable_path, + raw.designated_requirement_hash, + raw.pid, + ) + .map_err(serde::de::Error::custom) + } +} + +/// Bounded conflict status for a contender that failed to acquire the guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerConflict { + /// Owner holding the guard when the conflict was observed. + pub active_owner: MacosDaemonOwner, + /// Active owner's acquisition epoch. + pub active_epoch: u64, + /// Topology of the losing contender. + pub contender_owner: MacosDaemonOwner, + /// Millisecond timestamp supplied by the observer. + pub observed_at_ms: u64, +} + +/// Durable conflict record including the contender's diagnostic identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerConflictRecord { + /// Owner holding the guard when the conflict was observed. + pub active_owner: MacosDaemonOwner, + /// Active owner's acquisition epoch. + pub active_epoch: u64, + /// Topology of the losing contender. + pub contender_owner: MacosDaemonOwner, + /// Diagnostic identity of the losing contender. + pub contender_identity: MacosOwnerIdentity, + /// Millisecond timestamp supplied by the observer. + pub observed_at_ms: u64, +} + +/// Path-free status for a nonterminal journal this daemon cannot complete. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerRecoveryRequired { + /// Owner requested by the pending handover. + pub requested_owner: MacosDaemonOwner, + /// Owner restored if the pending handover rolls back. + pub prior_owner: MacosDaemonOwner, + /// Durable phase at which local coordinator recovery must resume. + pub phase: MacosHandoverPhase, +} + +impl MacosOwnerConflictRecord { + fn has_same_identity(&self, other: &Self) -> bool { + self.active_owner == other.active_owner + && self.active_epoch == other.active_epoch + && self.contender_owner == other.contender_owner + && self.contender_identity.executable_path == other.contender_identity.executable_path + && self.contender_identity.designated_requirement_hash + == other.contender_identity.designated_requirement_hash + } + + const fn snapshot(&self) -> MacosOwnerConflict { + MacosOwnerConflict { + active_owner: self.active_owner, + active_epoch: self.active_epoch, + contender_owner: self.contender_owner, + observed_at_ms: self.observed_at_ms, + } + } +} + +/// Bounded status snapshot derived from the durable owner record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerSnapshot { + /// Current daemon owner. + pub active_owner: MacosDaemonOwner, + /// Current owner's acquisition epoch. + pub owner_epoch: u64, + /// Latest distinct owner conflict, when present. + pub conflict: Option, + /// Nonterminal handover this daemon is not authorized to complete. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_required: Option, +} + +impl MacosOwnerSnapshot { + /// Attach path-free recovery status after incoming-daemon reconciliation. + #[must_use] + pub const fn with_recovery_required( + mut self, + recovery_required: Option, + ) -> Self { + self.recovery_required = recovery_required; + self + } +} + +/// Versioned durable owner state for one macOS user. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerRecord { + /// Durable schema version. + pub schema_version: u32, + /// Current daemon owner. + pub active_owner: MacosDaemonOwner, + /// Diagnostic identity of the current owner process. + pub active_identity: MacosOwnerIdentity, + /// Monotonically increasing owner acquisition epoch. + pub owner_epoch: u64, + /// Latest distinct losing contender, when present. + pub conflict: Option, + /// Persisted app preference for an externally managed daemon. + pub selected_external_owner: Option, +} + +impl MacosOwnerRecord { + /// Construct an initial owner record at epoch one. + pub const fn new( + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, + selected_external_owner: Option, + ) -> Self { + Self { + schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, + active_owner, + active_identity, + owner_epoch: 1, + conflict: None, + selected_external_owner, + } + } + + /// Return the bounded status surface for this record. + pub fn snapshot(&self) -> MacosOwnerSnapshot { + MacosOwnerSnapshot { + active_owner: self.active_owner, + owner_epoch: self.owner_epoch, + conflict: self + .conflict + .as_ref() + .map(MacosOwnerConflictRecord::snapshot), + recovery_required: None, + } + } +} + +/// Result of publishing a contender against the current owner epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosConflictUpdate { + /// A distinct contender state was durably recorded. + Recorded(MacosOwnerSnapshot), + /// The contender matched the existing conflict identity. + Coalesced(MacosOwnerSnapshot), +} + +impl MacosConflictUpdate { + /// Return the owner snapshot associated with this update. + pub const fn snapshot(self) -> MacosOwnerSnapshot { + match self { + Self::Recorded(snapshot) | Self::Coalesced(snapshot) => snapshot, + } + } +} + +/// Installed-state snapshot captured before a daemon handover. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosAutostartStates { + /// Whether app-sidecar autostart was enabled. + pub app_sidecar: bool, + /// Whether the direct launchd service was enabled. + pub direct_launchd: bool, + /// Whether the Homebrew service was enabled. + pub homebrew: bool, +} + +impl MacosAutostartStates { + /// Construct an installed-state snapshot. + pub const fn new(app_sidecar: bool, direct_launchd: bool, homebrew: bool) -> Self { + Self { + app_sidecar, + direct_launchd, + homebrew, + } + } +} + +/// A validated path-free handover or rollback operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum MacosHandoverOperation { + /// Set app-sidecar autostart state. + SetAppSidecarAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the app-supervised sidecar. + FlushAndStopAppSidecar {}, + /// Start the app-supervised sidecar. + StartAppSidecar {}, + /// Set direct-launchd autostart state. + SetDirectLaunchdAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the direct launchd service. + FlushAndStopDirectLaunchd {}, + /// Start the direct launchd service. + StartDirectLaunchd {}, + /// Set Homebrew-service autostart state. + SetHomebrewAutostart { + /// Desired installed state. + enabled: bool, + }, + /// Flush and stop the Homebrew service. + FlushAndStopHomebrew {}, + /// Start the Homebrew service. + StartHomebrew {}, + /// Await user-directed termination of a standalone owner. + AwaitStandaloneExit { + /// Authoritative process identifier shown to the user. + pid: u32, + }, +} + +/// Durable handover phase used to resume or reverse interrupted work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosHandoverPhase { + /// Journal exists and no external mutation has begun. + Prepared, + /// Nonselected autostarts have been disabled. + AutostartsConfigured, + /// Stop of the outgoing managed owner has been requested. + StopRequested, + /// The outgoing managed owner has stopped. + OutgoingOwnerStopped, + /// The coordinator is waiting for the instance guard to release. + AwaitingGuardRelease, + /// The instance guard is free. + GuardReleased, + /// Startup of the requested owner has been requested. + StartRequested, + /// The requested owner has started. + RequestedOwnerStarted, + /// The requested owner is ready for the ownership commit. + CommitPending, + /// The requested owner committed the handover. + Committed, + /// Forward progress failed and rollback must begin or resume. + RollbackPending, + /// Prior autostart state has been restored. + RollbackAutostartsRestored, + /// Stop of a partially started requested owner was requested. + RollbackStopRequested, + /// The partially started requested owner has stopped. + RollbackOwnerStopped, + /// Rollback is waiting for the instance guard to release. + RollbackAwaitingGuardRelease, + /// The instance guard is free for the prior owner. + RollbackGuardReleased, + /// Restart of the prior managed owner was requested. + RollbackStartRequested, + /// The prior managed owner has restarted. + PriorOwnerStarted, + /// The prior owner is ready for the rollback commit. + RollbackCommitPending, + /// The prior owner committed rollback completion. + RolledBack, +} + +impl MacosHandoverPhase { + /// Every stable journal phase, in forward then rollback order. + pub const ALL: [Self; 20] = [ + Self::Prepared, + Self::AutostartsConfigured, + Self::StopRequested, + Self::OutgoingOwnerStopped, + Self::AwaitingGuardRelease, + Self::GuardReleased, + Self::StartRequested, + Self::RequestedOwnerStarted, + Self::CommitPending, + Self::Committed, + Self::RollbackPending, + Self::RollbackAutostartsRestored, + Self::RollbackStopRequested, + Self::RollbackOwnerStopped, + Self::RollbackAwaitingGuardRelease, + Self::RollbackGuardReleased, + Self::RollbackStartRequested, + Self::PriorOwnerStarted, + Self::RollbackCommitPending, + Self::RolledBack, + ]; + + /// Whether this phase closes the transaction. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Committed | Self::RolledBack) + } +} + +/// Stable, path-free identifier for one handover transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct MacosHandoverTransactionId(String); + +impl MacosHandoverTransactionId { + /// Validate and construct a handover transaction identifier. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if is_valid_transaction_id(&value) { + Ok(Self(value)) + } else { + Err(MacosOwnerStoreError::InvalidTransactionId) + } + } + + /// Borrow the validated identifier. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for MacosHandoverTransactionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// Versioned durable journal for a local daemon-owner handover. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosHandoverJournal { + /// Durable schema version. + pub schema_version: u32, + /// Monotonic mutation count within this journal transaction. + pub journal_revision: u64, + /// Stable transaction identifier. + pub transaction_id: MacosHandoverTransactionId, + /// Desired owner after a successful handover. + pub requested_owner: MacosDaemonOwner, + /// Owner to restore if the handover rolls back. + pub prior_owner: MacosDaemonOwner, + /// Installed states to restore during rollback. + pub prior_autostart_states: MacosAutostartStates, + /// Closed operations forward recovery is permitted to execute. + #[serde(default)] + pub allowed_forward_operations: Vec, + /// Closed operations recovery is permitted to execute. + pub allowed_rollback_operations: Vec, + /// Last durably completed transaction phase. + pub phase: MacosHandoverPhase, + /// Owner epoch observed before mutation began. + pub active_epoch: u64, + /// Contender epoch associated with the request, when one exists. + pub contender_epoch: Option, + /// Standalone process whose user-directed exit is pending. + pub pending_standalone_pid: Option, +} + +impl MacosHandoverJournal { + /// Construct a prepared journal. The store assigns its first revision. + pub fn new( + transaction_id: MacosHandoverTransactionId, + requested_owner: MacosDaemonOwner, + prior_owner: MacosDaemonOwner, + prior_autostart_states: MacosAutostartStates, + allowed_rollback_operations: Vec, + active_epoch: u64, + contender_epoch: Option, + pending_standalone_pid: Option, + ) -> Self { + Self { + schema_version: MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, + journal_revision: 0, + transaction_id, + requested_owner, + prior_owner, + prior_autostart_states, + allowed_forward_operations: Vec::new(), + allowed_rollback_operations, + phase: MacosHandoverPhase::Prepared, + active_epoch, + contender_epoch, + pending_standalone_pid, + } + } + + /// Construct a complete path-free handover journal for a local owner choice. + pub fn for_owner_choice( + transaction_id: MacosHandoverTransactionId, + requested_owner: MacosDaemonOwner, + prior_record: &MacosOwnerRecord, + prior_autostart_states: MacosAutostartStates, + ) -> Result { + if requested_owner == MacosDaemonOwner::Standalone { + return Err(MacosOwnerCoordinatorError::StandaloneCannotBeSelected); + } + let pending_standalone_pid = (prior_record.active_owner == MacosDaemonOwner::Standalone) + .then_some(prior_record.active_identity.pid); + let allowed_forward_operations = forward_operations( + requested_owner, + prior_record.active_owner, + pending_standalone_pid, + ); + let allowed_rollback_operations = rollback_operations( + requested_owner, + prior_record.active_owner, + prior_autostart_states, + ); + Ok(Self { + schema_version: MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, + journal_revision: 0, + transaction_id, + requested_owner, + prior_owner: prior_record.active_owner, + prior_autostart_states, + allowed_forward_operations, + allowed_rollback_operations, + phase: MacosHandoverPhase::Prepared, + active_epoch: prior_record.owner_epoch, + contender_epoch: prior_record + .conflict + .as_ref() + .map(|conflict| conflict.active_epoch), + pending_standalone_pid, + }) + } +} + +/// A topology-specific user action returned by the local coordinator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MacosOwnerRemedy { + /// The standalone owner must be stopped by its terminal user. + StopStandaloneOwner { pid: u32 }, + /// The standalone capture owner must be restarted by its terminal user. + RestartStandalone { pid: u32 }, + /// Start the packaged app sidecar locally. + StartAppSidecar, + /// Start the direct launchd service locally. + StartLaunchdService, + /// Start the Homebrew service locally. + StartHomebrewService, +} + +/// Synchronous result of a local owner selection or recovery. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MacosOwnerCoordinatorOutcome { + /// The requested owner published a matching durable epoch. + Active { + owner: MacosDaemonOwner, + owner_epoch: u64, + }, + /// A standalone process still owns the guard and must exit voluntarily. + PendingStandalone { + requested_owner: MacosDaemonOwner, + remedy: MacosOwnerRemedy, + }, + /// Forward progress failed and the prior managed owner was restored. + RolledBack { + prior_owner: MacosDaemonOwner, + failure: String, + }, + /// A validated journal belongs to another owner and remains pending. + RecoveryRequired { + requested_owner: MacosDaemonOwner, + prior_owner: MacosDaemonOwner, + phase: MacosHandoverPhase, + }, +} + +/// Closed local process and launcher operations used by the coordinator. +pub trait MacosOwnerExecutor { + /// Return whether one managed topology is configured for login startup. + fn autostart_enabled( + &mut self, + owner: MacosDaemonOwner, + ) -> Result; + + /// Idempotently set one managed topology's login-start state. + fn set_autostart( + &mut self, + owner: MacosDaemonOwner, + enabled: bool, + ) -> Result<(), MacosOwnerExecutionError>; + + /// Flush and stop one managed owner identified by the durable record. + fn flush_and_stop( + &mut self, + owner: MacosDaemonOwner, + pid: Option, + ) -> Result<(), MacosOwnerExecutionError>; + + /// Idempotently start one managed owner. + fn start(&mut self, owner: MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError>; + + /// Wait through a native process notification, then confirm guard release. + fn wait_for_guard_release( + &mut self, + pid: u32, + timeout: Duration, + ) -> Result; + + /// Wait for the requested owner to publish an epoch newer than `after_epoch`. + fn wait_for_owner( + &mut self, + owner: MacosDaemonOwner, + after_epoch: u64, + timeout: Duration, + ) -> Result; +} + +/// Owning handle for the same macOS `flock` used by the final daemon guard. +#[cfg(target_os = "macos")] +#[derive(Debug)] +pub struct MacosDaemonGuard { + _lock: nix::fcntl::Flock, +} + +/// Block until the final macOS daemon guard is acquired. +#[cfg(target_os = "macos")] +pub fn acquire_macos_daemon_guard( + instance_name: &str, +) -> Result { + use nix::errno::Errno; + use nix::fcntl::{Flock, FlockArg}; + + let mut file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(instance_name) + .map_err(|error| { + MacosOwnerExecutionError::new(format!("failed to open daemon guard: {error}")) + })?; + loop { + match Flock::lock(file, FlockArg::LockExclusive) { + Ok(lock) => return Ok(MacosDaemonGuard { _lock: lock }), + Err((returned, Errno::EINTR)) => file = returned, + Err((_, error)) => { + return Err(MacosOwnerExecutionError::new(format!( + "failed to acquire daemon guard: {error}" + ))); + } + } + } +} + +/// Attempt to acquire the final macOS daemon guard without blocking. +#[cfg(target_os = "macos")] +pub fn try_acquire_macos_daemon_guard( + instance_name: &str, +) -> Result, MacosOwnerExecutionError> { + use nix::errno::Errno; + use nix::fcntl::{Flock, FlockArg}; + + let mut file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(instance_name) + .map_err(|error| { + MacosOwnerExecutionError::new(format!("failed to open daemon guard: {error}")) + })?; + loop { + match Flock::lock(file, FlockArg::LockExclusiveNonblock) { + Ok(lock) => return Ok(Some(MacosDaemonGuard { _lock: lock })), + Err((returned, Errno::EINTR)) => file = returned, + Err((_, Errno::EAGAIN)) => return Ok(None), + Err((_, error)) => { + return Err(MacosOwnerExecutionError::new(format!( + "failed to acquire daemon guard: {error}" + ))); + } + } + } +} + +/// Request graceful termination of one local macOS owner process. +/// +/// A process that already exited is treated as successfully stopped so replay +/// after a crash remains idempotent. +#[cfg(target_os = "macos")] +pub fn terminate_macos_owner_process(pid: u32) -> Result<(), MacosOwnerExecutionError> { + use nix::errno::Errno; + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + let pid = i32::try_from(pid) + .map_err(|_| MacosOwnerExecutionError::new("process ID exceeds macOS pid_t"))?; + match kill(Pid::from_raw(pid), Signal::SIGTERM) { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(error) => Err(MacosOwnerExecutionError::new(format!( + "failed to terminate local daemon owner: {error}" + ))), + } +} + +/// Wait for one process to exit through macOS kernel process notification. +#[cfg(target_os = "macos")] +pub fn wait_for_macos_process_exit( + pid: u32, + timeout: Duration, +) -> Result { + use nix::errno::Errno; + use nix::sys::event::{EventFilter, EventFlag, FilterFlag, KEvent, Kqueue}; + use nix::sys::signal::kill; + use nix::unistd::Pid; + + let pid_i32 = i32::try_from(pid) + .map_err(|_| MacosOwnerExecutionError::new("process ID exceeds macOS pid_t"))?; + match kill(Pid::from_raw(pid_i32), None) { + Err(Errno::ESRCH) => return Ok(true), + Err(error) => { + return Err(MacosOwnerExecutionError::new(format!( + "failed to inspect owner process: {error}" + ))); + } + Ok(()) => {} + } + let queue = Kqueue::new().map_err(|error| { + MacosOwnerExecutionError::new(format!("failed to create process event queue: {error}")) + })?; + let change = KEvent::new( + pid as usize, + EventFilter::EVFILT_PROC, + EventFlag::EV_ADD | EventFlag::EV_ONESHOT, + FilterFlag::NOTE_EXIT, + 0, + 0, + ); + let mut events = [KEvent::new( + 0, + EventFilter::EVFILT_PROC, + EventFlag::empty(), + FilterFlag::empty(), + 0, + 0, + )]; + let timeout = nix::libc::timespec { + tv_sec: timeout + .as_secs() + .try_into() + .unwrap_or(nix::libc::time_t::MAX), + tv_nsec: timeout.subsec_nanos().into(), + }; + match queue.kevent(&[change], &mut events, Some(timeout)) { + Ok(0) => Ok(false), + Ok(_) if events[0].flags().contains(EventFlag::EV_ERROR) => { + if events[0].data() == i64::from(Errno::ESRCH as i32) as isize { + Ok(true) + } else { + Err(MacosOwnerExecutionError::new(format!( + "process event registration failed with errno {}", + events[0].data() + ))) + } + } + Ok(_) => Ok(true), + Err(Errno::EINTR) => Ok(false), + Err(error) => Err(MacosOwnerExecutionError::new(format!( + "failed to await owner process exit: {error}" + ))), + } +} + +/// Confirm process exit and reacquire the final single-instance guard. +#[cfg(target_os = "macos")] +pub fn wait_for_macos_guard_release( + pid: u32, + timeout: Duration, + instance_name: &str, +) -> Result { + if !wait_for_macos_process_exit(pid, timeout)? { + return Ok(false); + } + let guard = single_instance::SingleInstance::new(instance_name).map_err(|error| { + MacosOwnerExecutionError::new(format!("failed to inspect daemon guard: {error}")) + })?; + Ok(guard.is_single()) +} + +/// Wait for an exact durable owner publication through a native file watch. +pub fn wait_for_owner_publication( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, + after_epoch: u64, + timeout: Duration, +) -> Result { + use notify::{RecursiveMode, Watcher}; + use std::sync::mpsc; + use std::time::Instant; + + let matches = || { + store + .load_owner_record() + .map(|record| { + record.is_some_and(|record| { + record.active_owner == owner && record.owner_epoch > after_epoch + }) + }) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) + }; + if matches()? { + return Ok(true); + } + let owner_path = store.owner_record_path(); + let directory = owner_path + .parent() + .ok_or_else(|| MacosOwnerExecutionError::new("owner record has no parent directory"))? + .to_path_buf(); + fs::create_dir_all(&directory) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + let (signal_tx, signal_rx) = mpsc::sync_channel(1); + let watched_path = owner_path.clone(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + if event.is_ok_and(|event| event.paths.iter().any(|path| path == &watched_path)) { + let _ = signal_tx.try_send(()); + } + }) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + watcher + .watch(&directory, RecursiveMode::NonRecursive) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + if matches()? { + return Ok(true); + } + let started = Instant::now(); + loop { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Ok(false); + }; + match signal_rx.recv_timeout(remaining) { + Ok(()) if matches()? => return Ok(true), + Ok(()) => {} + Err(mpsc::RecvTimeoutError::Timeout) => return Ok(false), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(MacosOwnerExecutionError::new( + "owner publication watch disconnected", + )); + } + } + } +} + +/// Bounded failure returned by a typed local operation executor. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{detail}")] +pub struct MacosOwnerExecutionError { + detail: String, +} + +impl MacosOwnerExecutionError { + /// Construct an executor failure from a bounded operational detail. + pub fn new(detail: impl Into) -> Self { + let mut detail = detail.into(); + detail.truncate(4_096); + Self { detail } + } +} + +/// Typed local coordinator failure. +#[derive(Debug, thiserror::Error)] +pub enum MacosOwnerCoordinatorError { + /// Durable owner or journal I/O failed. + #[error(transparent)] + Store(#[from] MacosOwnerStoreError), + /// A typed local operation failed before rollback could finish. + #[error("macOS daemon owner operation {operation:?} failed: {source}")] + Operation { + operation: MacosHandoverOperation, + #[source] + source: MacosOwnerExecutionError, + }, + /// Inspecting one launcher's current installed state failed. + #[error("failed to inspect {owner:?} autostart state: {source}")] + InspectAutostart { + owner: MacosDaemonOwner, + #[source] + source: MacosOwnerExecutionError, + }, + /// The durable owner record does not exist. + #[error("macOS daemon owner selection requires an active owner record")] + MissingActiveOwner, + /// Standalone is observable but has no local launcher to select. + #[error("standalone daemon ownership cannot be selected by a coordinator")] + StandaloneCannotBeSelected, + /// Recovery attempted an operation absent from the validated journal. + #[error("macOS handover journal does not authorize operation {operation:?}")] + UnauthorizedOperation { operation: MacosHandoverOperation }, + /// A managed owner did not release the guard within ten seconds. + #[error("macOS daemon guard did not release within the managed handover timeout")] + GuardReleaseTimeout, + /// A requested owner did not publish a matching owner epoch in time. + #[error("requested macOS daemon owner did not publish before startup timeout")] + OwnerStartupTimeout, +} + +/// Typed durable owner-store failure. +#[derive(Debug, thiserror::Error)] +pub enum MacosOwnerStoreError { + /// The explicit data directory could not be created. + #[error("failed to create macOS owner data directory {path}: {source}")] + CreateDirectory { + /// Data directory. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The stable coordination lock could not be opened. + #[error("failed to open macOS owner coordination lock {path}: {source}")] + OpenCoordinationLock { + /// Lock path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The stable coordination lock could not be acquired. + #[error("failed to acquire macOS owner coordination lock {path}: {source}")] + AcquireCoordinationLock { + /// Lock path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A durable artifact could not be read. + #[error("failed to read macOS {artifact} at {path}: {source}")] + Read { + /// Artifact kind. + artifact: &'static str, + /// Artifact path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A durable artifact could not be decoded. + #[error("failed to decode macOS {artifact}: {source}")] + Decode { + /// Artifact kind. + artifact: &'static str, + /// JSON failure. + #[source] + source: serde_json::Error, + }, + /// A durable artifact has an unsupported schema version. + #[error("unsupported macOS {artifact} schema version {found}; expected {expected}")] + UnsupportedVersion { + /// Artifact kind. + artifact: &'static str, + /// Version found on disk. + found: u32, + /// Version supported by this build. + expected: u32, + }, + /// A durable artifact violates a semantic invariant. + #[error("invalid macOS {artifact}: {detail}")] + InvalidArtifact { + /// Artifact kind. + artifact: &'static str, + /// Stable validation detail. + detail: &'static str, + }, + /// JSON serialization failed before any bytes were replaced. + #[error("failed to serialize macOS {artifact}: {source}")] + Encode { + /// Artifact kind. + artifact: &'static str, + /// JSON failure. + #[source] + source: serde_json::Error, + }, + /// A same-directory temporary file could not be created. + #[error("failed to create temporary file beside {path}: {source}")] + CreateTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// A complete temporary artifact could not be written. + #[error("failed to write temporary file for {path}: {source}")] + WriteTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// Temporary artifact contents could not be synced. + #[error("failed to sync temporary file for {path}: {source}")] + SyncTemporary { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The durable destination could not be atomically replaced. + #[error("failed to atomically replace {path}: {source}")] + Replace { + /// Destination path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// The parent directory could not be synced after replacement. + #[cfg(unix)] + #[error("failed to sync parent directory {path}: {source}")] + SyncDirectory { + /// Parent directory. + path: PathBuf, + /// Filesystem failure. + #[source] + source: std::io::Error, + }, + /// No owner record exists for the requested mutation. + #[error("macOS owner record does not exist")] + MissingOwnerRecord, + /// The owner acquisition epoch cannot advance further. + #[error("macOS owner epoch overflow")] + OwnerEpochOverflow, + /// A nonterminal handover journal must be recovered first. + #[error("macOS handover {transaction_id} is still pending")] + HandoverAlreadyPending { + /// Existing transaction identifier. + transaction_id: String, + }, + /// No handover journal exists for the requested mutation. + #[error("macOS handover journal does not exist")] + MissingHandoverJournal, + /// A caller attempted to advance a different transaction. + #[error("macOS handover transaction does not match the durable journal")] + HandoverTransactionMismatch, + /// A concurrent recovery participant already advanced the journal. + #[error("macOS handover phase changed from {expected:?} to {found:?}")] + HandoverPhaseChanged { + /// Phase expected by the caller. + expected: MacosHandoverPhase, + /// Current durable phase. + found: MacosHandoverPhase, + }, + /// The handover journal revision cannot advance further. + #[error("macOS handover journal revision overflow")] + JournalRevisionOverflow, + /// A transaction identifier is not a bounded path-free token. + #[error("macOS handover transaction ID must be 1-64 ASCII letters, digits, '_' or '-'")] + InvalidTransactionId, + /// An owner identity field is empty, oversized, or structurally invalid. + #[error("invalid macOS owner identity field {field}: {detail}")] + InvalidOwnerIdentity { + /// Invalid identity field. + field: &'static str, + /// Stable validation detail. + detail: &'static str, + }, + /// A durable artifact exceeds the bounded decoder input size. + #[error("macOS {artifact} exceeds the {maximum_bytes}-byte limit")] + ArtifactTooLarge { + /// Artifact kind. + artifact: &'static str, + /// Maximum accepted byte length. + maximum_bytes: usize, + }, + /// A completed or rolled-back transaction cannot be advanced. + #[error("terminal macOS handover {transaction_id} cannot advance")] + TerminalHandover { + /// Completed transaction identifier. + transaction_id: String, + }, +} + +/// Durable owner state rooted in an explicit per-user data directory. +#[derive(Debug, Clone)] +pub struct MacosOwnerStore { + data_dir: PathBuf, +} + +impl MacosOwnerStore { + /// Construct a store without reading or creating any files. + pub fn new(data_dir: impl Into) -> Self { + Self { + data_dir: data_dir.into(), + } + } + + /// Return the owner-record path. + pub fn owner_record_path(&self) -> PathBuf { + self.data_dir.join(MACOS_OWNER_RECORD_FILE_NAME) + } + + /// Return the handover-journal path. + pub fn handover_journal_path(&self) -> PathBuf { + self.data_dir.join(MACOS_HANDOVER_JOURNAL_FILE_NAME) + } + + /// Return the stable lock path shared by every writer. + pub fn coordination_lock_path(&self) -> PathBuf { + self.data_dir.join(MACOS_OWNER_COORDINATION_LOCK_FILE_NAME) + } + + /// Load and validate the current owner record. + pub fn load_owner_record(&self) -> Result, MacosOwnerStoreError> { + read_owner_record(&self.owner_record_path()) + } + + /// Publish a newly acquired owner and advance the durable owner epoch. + /// + /// The locked record supplies the persisted external-owner mode and any + /// distinct contender, so publication cannot overwrite a concurrent choice + /// or erase a contender that arrived before the winning owner published. + pub fn publish_owner( + &self, + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let record = match read_owner_record(&path)? { + Some(previous) => successor_owner_record(previous, active_owner, active_identity)?, + None => MacosOwnerRecord::new(active_owner, active_identity, None), + }; + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + Ok(record) + } + + /// Publish an owner that already holds the authoritative daemon guard. + /// + /// The guard token permits repair of a corrupt diagnostic owner record. + /// Ordinary store mutations continue to reject the same invalid bytes. + #[cfg(target_os = "macos")] + pub fn publish_guard_winner( + &self, + _guard: &MacosDaemonGuard, + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let previous = read_owner_record(&path).ok().flatten(); + let record = previous + .and_then(|previous| { + successor_owner_record(previous, active_owner, active_identity.clone()).ok() + }) + .unwrap_or_else(|| MacosOwnerRecord::new(active_owner, active_identity, None)); + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + Ok(record) + } + + /// Record a distinct contender or coalesce one already observed this epoch. + pub fn record_conflict( + &self, + contender_owner: MacosDaemonOwner, + contender_identity: MacosOwnerIdentity, + observed_at_ms: u64, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + let conflict = MacosOwnerConflictRecord { + active_owner: record.active_owner, + active_epoch: record.owner_epoch, + contender_owner, + contender_identity, + observed_at_ms, + }; + if record + .conflict + .as_ref() + .is_some_and(|existing| existing.has_same_identity(&conflict)) + { + return Ok(MacosConflictUpdate::Coalesced(record.snapshot())); + } + record.conflict = Some(conflict); + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + Ok(MacosConflictUpdate::Recorded(record.snapshot())) + } + + /// Clear the current conflict without changing the owner epoch. + pub fn clear_conflict(&self) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if record.conflict.take().is_some() { + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + } + Ok(record) + } + + /// Persist or clear the selected external-owner mode. + pub fn set_external_owner_mode( + &self, + selected_external_owner: Option, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.owner_record_path(); + let mut record = + read_owner_record(&path)?.ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if record.selected_external_owner != selected_external_owner { + record.selected_external_owner = selected_external_owner; + write_json_atomic(&self.data_dir, &path, "owner record", &record)?; + } + Ok(record) + } + + /// Load and validate the current handover journal. + pub fn load_handover_journal( + &self, + ) -> Result, MacosOwnerStoreError> { + read_handover_journal(&self.handover_journal_path()) + } + + /// Begin a handover unless a nonterminal journal requires recovery. + pub fn begin_handover( + &self, + mut journal: MacosHandoverJournal, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.handover_journal_path(); + if let Some(existing) = read_handover_journal(&path)? + && !existing.phase.is_terminal() + { + return Err(MacosOwnerStoreError::HandoverAlreadyPending { + transaction_id: existing.transaction_id.0, + }); + } + validate_handover_journal(&journal)?; + journal.schema_version = MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION; + journal.journal_revision = 1; + journal.phase = MacosHandoverPhase::Prepared; + write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; + Ok(journal) + } + + /// Durably advance one handover phase under one read-modify-write lock hold. + pub fn advance_handover( + &self, + transaction_id: &MacosHandoverTransactionId, + phase: MacosHandoverPhase, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.handover_journal_path(); + let mut journal = + read_handover_journal(&path)?.ok_or(MacosOwnerStoreError::MissingHandoverJournal)?; + if journal.transaction_id != *transaction_id { + return Err(MacosOwnerStoreError::HandoverTransactionMismatch); + } + if journal.phase.is_terminal() { + return Err(MacosOwnerStoreError::TerminalHandover { + transaction_id: journal.transaction_id.0, + }); + } + journal.journal_revision = journal + .journal_revision + .checked_add(1) + .ok_or(MacosOwnerStoreError::JournalRevisionOverflow)?; + journal.phase = phase; + write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; + Ok(journal) + } + + /// Atomically advance one handover phase when its predecessor still matches. + pub fn advance_handover_from( + &self, + transaction_id: &MacosHandoverTransactionId, + expected_phase: MacosHandoverPhase, + phase: MacosHandoverPhase, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let path = self.handover_journal_path(); + let mut journal = + read_handover_journal(&path)?.ok_or(MacosOwnerStoreError::MissingHandoverJournal)?; + if journal.transaction_id != *transaction_id { + return Err(MacosOwnerStoreError::HandoverTransactionMismatch); + } + if journal.phase != expected_phase { + return Err(MacosOwnerStoreError::HandoverPhaseChanged { + expected: expected_phase, + found: journal.phase, + }); + } + if journal.phase.is_terminal() { + return Err(MacosOwnerStoreError::TerminalHandover { + transaction_id: journal.transaction_id.0, + }); + } + journal.journal_revision = journal + .journal_revision + .checked_add(1) + .ok_or(MacosOwnerStoreError::JournalRevisionOverflow)?; + journal.phase = phase; + write_json_atomic(&self.data_dir, &path, "handover journal", &journal)?; + Ok(journal) + } + + fn acquire_coordination_lock(&self) -> Result { + fs::create_dir_all(&self.data_dir).map_err(|source| { + MacosOwnerStoreError::CreateDirectory { + path: self.data_dir.clone(), + source, + } + })?; + let path = self.coordination_lock_path(); + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = + options + .open(&path) + .map_err(|source| MacosOwnerStoreError::OpenCoordinationLock { + path: path.clone(), + source, + })?; + file.lock() + .map_err(|source| MacosOwnerStoreError::AcquireCoordinationLock { path, source })?; + Ok(CoordinationLock { file }) + } +} + +/// Run a local, synchronous daemon-owner choice. +pub fn choose_daemon_owner( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, + requested_owner: MacosDaemonOwner, + transaction_id: MacosHandoverTransactionId, +) -> Result { + if let Some(existing) = store.load_handover_journal()? + && !existing.phase.is_terminal() + { + let recovered = run_handover(store, executor, existing)?; + if !matches!(recovered, MacosOwnerCoordinatorOutcome::Active { .. }) { + return Ok(recovered); + } + } + + let prior_record = store + .load_owner_record()? + .ok_or(MacosOwnerCoordinatorError::MissingActiveOwner)?; + let prior_autostart_states = MacosAutostartStates::new( + inspect_autostart(executor, MacosDaemonOwner::AppSidecar)?, + inspect_autostart(executor, MacosDaemonOwner::DirectLaunchd)?, + inspect_autostart(executor, MacosDaemonOwner::Homebrew)?, + ); + let journal = MacosHandoverJournal::for_owner_choice( + transaction_id, + requested_owner, + &prior_record, + prior_autostart_states, + )?; + let journal = store.begin_handover(journal)?; + run_handover(store, executor, journal) +} + +fn inspect_autostart( + executor: &mut impl MacosOwnerExecutor, + owner: MacosDaemonOwner, +) -> Result { + executor + .autostart_enabled(owner) + .map_err(|source| MacosOwnerCoordinatorError::InspectAutostart { owner, source }) +} + +/// Resume the current local transaction before accepting another owner choice. +pub fn recover_daemon_owner( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, +) -> Result, MacosOwnerCoordinatorError> { + let Some(journal) = store.load_handover_journal()? else { + return Ok(None); + }; + if journal.phase.is_terminal() { + return Ok(None); + } + run_handover(store, executor, journal).map(Some) +} + +/// Reconcile a journal from a daemon that already holds the process guard. +pub fn recover_incoming_daemon_owner( + store: &MacosOwnerStore, + current_owner: MacosDaemonOwner, +) -> Result, MacosOwnerCoordinatorError> { + let Some(mut journal) = store.load_handover_journal()? else { + return Ok(None); + }; + if journal.phase.is_terminal() { + return Ok(None); + } + if current_owner == journal.requested_owner && requested_owner_can_complete(&journal) { + return complete_requested_owner_recovery(store, journal).map(Some); + } + if current_owner == journal.prior_owner + && matches!( + journal.phase, + MacosHandoverPhase::RollbackStartRequested + | MacosHandoverPhase::PriorOwnerStarted + | MacosHandoverPhase::RollbackCommitPending + ) + { + if journal.phase == MacosHandoverPhase::RollbackStartRequested { + journal = advance(store, &journal, MacosHandoverPhase::PriorOwnerStarted)?; + if let Some(outcome) = terminal_outcome(store, &journal)? { + return Ok(Some(outcome)); + } + } + if journal.phase == MacosHandoverPhase::PriorOwnerStarted { + journal = advance(store, &journal, MacosHandoverPhase::RollbackCommitPending)?; + if let Some(outcome) = terminal_outcome(store, &journal)? { + return Ok(Some(outcome)); + } + } + store.set_external_owner_mode(external_owner_mode(journal.prior_owner))?; + clear_conflict_if_present(store)?; + let journal = advance(store, &journal, MacosHandoverPhase::RolledBack)?; + return Ok(Some(terminal_outcome(store, &journal)?.unwrap_or( + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: journal.requested_owner, + prior_owner: journal.prior_owner, + phase: journal.phase, + }, + ))); + } + Ok(Some(recovery_required(&journal))) +} + +const fn requested_owner_can_complete(journal: &MacosHandoverJournal) -> bool { + match journal.phase { + MacosHandoverPhase::AutostartsConfigured + | MacosHandoverPhase::StartRequested + | MacosHandoverPhase::RequestedOwnerStarted + | MacosHandoverPhase::CommitPending => true, + MacosHandoverPhase::StopRequested + | MacosHandoverPhase::OutgoingOwnerStopped + | MacosHandoverPhase::AwaitingGuardRelease + | MacosHandoverPhase::GuardReleased => journal.pending_standalone_pid.is_none(), + MacosHandoverPhase::Prepared + | MacosHandoverPhase::Committed + | MacosHandoverPhase::RollbackPending + | MacosHandoverPhase::RollbackAutostartsRestored + | MacosHandoverPhase::RollbackStopRequested + | MacosHandoverPhase::RollbackOwnerStopped + | MacosHandoverPhase::RollbackAwaitingGuardRelease + | MacosHandoverPhase::RollbackGuardReleased + | MacosHandoverPhase::RollbackStartRequested + | MacosHandoverPhase::PriorOwnerStarted + | MacosHandoverPhase::RollbackCommitPending + | MacosHandoverPhase::RolledBack => false, + } +} + +fn complete_requested_owner_recovery( + store: &MacosOwnerStore, + mut journal: MacosHandoverJournal, +) -> Result { + loop { + if !requested_owner_can_complete(&journal) { + return Ok(recovery_required(&journal)); + } + journal = match journal.phase { + MacosHandoverPhase::AutostartsConfigured => advance( + store, + &journal, + if journal.requested_owner == journal.prior_owner { + MacosHandoverPhase::CommitPending + } else if journal.pending_standalone_pid.is_some() { + MacosHandoverPhase::StartRequested + } else { + MacosHandoverPhase::StopRequested + }, + )?, + MacosHandoverPhase::StopRequested => { + advance(store, &journal, MacosHandoverPhase::OutgoingOwnerStopped)? + } + MacosHandoverPhase::OutgoingOwnerStopped => { + advance(store, &journal, MacosHandoverPhase::AwaitingGuardRelease)? + } + MacosHandoverPhase::AwaitingGuardRelease => { + advance(store, &journal, MacosHandoverPhase::GuardReleased)? + } + MacosHandoverPhase::GuardReleased => { + advance(store, &journal, MacosHandoverPhase::StartRequested)? + } + MacosHandoverPhase::StartRequested => { + advance(store, &journal, MacosHandoverPhase::RequestedOwnerStarted)? + } + MacosHandoverPhase::RequestedOwnerStarted => { + advance(store, &journal, MacosHandoverPhase::CommitPending)? + } + MacosHandoverPhase::CommitPending => { + store.set_external_owner_mode(external_owner_mode(journal.requested_owner))?; + clear_conflict_if_present(store)?; + let committed = advance(store, &journal, MacosHandoverPhase::Committed)?; + return Ok(terminal_outcome(store, &committed)? + .unwrap_or_else(|| recovery_required(&committed))); + } + MacosHandoverPhase::Prepared + | MacosHandoverPhase::Committed + | MacosHandoverPhase::RollbackPending + | MacosHandoverPhase::RollbackAutostartsRestored + | MacosHandoverPhase::RollbackStopRequested + | MacosHandoverPhase::RollbackOwnerStopped + | MacosHandoverPhase::RollbackAwaitingGuardRelease + | MacosHandoverPhase::RollbackGuardReleased + | MacosHandoverPhase::RollbackStartRequested + | MacosHandoverPhase::PriorOwnerStarted + | MacosHandoverPhase::RollbackCommitPending + | MacosHandoverPhase::RolledBack => return Ok(recovery_required(&journal)), + }; + } +} + +fn recovery_required(journal: &MacosHandoverJournal) -> MacosOwnerCoordinatorOutcome { + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: journal.requested_owner, + prior_owner: journal.prior_owner, + phase: journal.phase, + } +} + +fn terminal_outcome( + store: &MacosOwnerStore, + journal: &MacosHandoverJournal, +) -> Result, MacosOwnerStoreError> { + match journal.phase { + MacosHandoverPhase::Committed => { + let owner_epoch = store + .load_owner_record()? + .filter(|record| record.active_owner == journal.requested_owner) + .map_or(journal.active_epoch, |record| record.owner_epoch); + Ok(Some(MacosOwnerCoordinatorOutcome::Active { + owner: journal.requested_owner, + owner_epoch, + })) + } + MacosHandoverPhase::RolledBack => Ok(Some(MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: journal.prior_owner, + failure: "requested owner failed to become active".to_owned(), + })), + _ => Ok(None), + } +} + +#[expect( + clippy::too_many_lines, + reason = "the match is the auditable one-to-one encoding of all durable phases" +)] +fn run_handover( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, + mut journal: MacosHandoverJournal, +) -> Result { + loop { + match journal.phase { + MacosHandoverPhase::Prepared => { + if let Some(pid) = journal.pending_standalone_pid { + require_operation( + &journal.allowed_forward_operations, + MacosHandoverOperation::AwaitStandaloneExit { pid }, + )?; + journal = advance(store, &journal, MacosHandoverPhase::AwaitingGuardRelease)?; + } else { + for operation in autostart_operations_for(journal.requested_owner) { + if execute_operation(executor, &journal, operation, true).is_err() { + journal = begin_rollback(store, &journal)?; + break; + } + } + if journal.phase == MacosHandoverPhase::Prepared { + journal = + advance(store, &journal, MacosHandoverPhase::AutostartsConfigured)?; + } + } + } + MacosHandoverPhase::AutostartsConfigured => { + journal = advance( + store, + &journal, + if journal.requested_owner == journal.prior_owner { + MacosHandoverPhase::CommitPending + } else if journal.pending_standalone_pid.is_some() { + MacosHandoverPhase::StartRequested + } else { + MacosHandoverPhase::StopRequested + }, + )?; + } + MacosHandoverPhase::StopRequested => { + let operation = flush_stop_operation(journal.prior_owner)?; + if execute_operation(executor, &journal, operation, true).is_err() { + journal = begin_rollback(store, &journal)?; + } else { + journal = advance(store, &journal, MacosHandoverPhase::OutgoingOwnerStopped)?; + } + } + MacosHandoverPhase::OutgoingOwnerStopped => { + journal = advance(store, &journal, MacosHandoverPhase::AwaitingGuardRelease)?; + } + MacosHandoverPhase::AwaitingGuardRelease => { + let pid = if let Some(pid) = journal.pending_standalone_pid { + Some(pid) + } else { + active_pid(store, journal.prior_owner)? + }; + let Some(pid) = pid else { + journal = advance(store, &journal, MacosHandoverPhase::GuardReleased)?; + continue; + }; + let timeout = if journal.pending_standalone_pid.is_some() { + MACOS_STANDALONE_HANDOVER_TIMEOUT + } else { + MACOS_MANAGED_HANDOVER_TIMEOUT + }; + let released = executor.wait_for_guard_release(pid, timeout); + if released + .as_ref() + .is_err_and(|_| journal.pending_standalone_pid.is_some()) + { + return Err(MacosOwnerCoordinatorError::Operation { + operation: MacosHandoverOperation::AwaitStandaloneExit { pid }, + source: released.expect_err("checked error result"), + }); + } + if released.is_err() { + journal = begin_rollback(store, &journal)?; + continue; + } + if !released.expect("checked successful result") { + if journal.pending_standalone_pid.is_some() { + return Ok(MacosOwnerCoordinatorOutcome::PendingStandalone { + requested_owner: journal.requested_owner, + remedy: MacosOwnerRemedy::StopStandaloneOwner { pid }, + }); + } + journal = begin_rollback(store, &journal)?; + continue; + } + journal = advance(store, &journal, MacosHandoverPhase::GuardReleased)?; + } + MacosHandoverPhase::GuardReleased => { + if journal.pending_standalone_pid.is_some() { + for operation in autostart_operations_for(journal.requested_owner) { + if let Err(error) = execute_operation(executor, &journal, operation, true) { + return Err(error); + } + } + journal = advance(store, &journal, MacosHandoverPhase::AutostartsConfigured)?; + } else { + journal = advance(store, &journal, MacosHandoverPhase::StartRequested)?; + } + } + MacosHandoverPhase::StartRequested => { + let operation = start_operation(journal.requested_owner)?; + if let Err(error) = execute_operation(executor, &journal, operation, true) { + if journal.prior_owner == MacosDaemonOwner::Standalone { + return Err(error); + } + journal = begin_rollback(store, &journal)?; + continue; + } + journal = advance(store, &journal, MacosHandoverPhase::RequestedOwnerStarted)?; + } + MacosHandoverPhase::RequestedOwnerStarted => { + let started = executor.wait_for_owner( + journal.requested_owner, + journal.active_epoch, + MACOS_MANAGED_HANDOVER_TIMEOUT, + ); + if started.is_err() && journal.prior_owner == MacosDaemonOwner::Standalone { + return Err(MacosOwnerCoordinatorError::Operation { + operation: start_operation(journal.requested_owner) + .expect("validated requested owner is managed"), + source: started.expect_err("checked error result"), + }); + } + if !started.unwrap_or(false) { + if journal.prior_owner == MacosDaemonOwner::Standalone { + return Err(MacosOwnerCoordinatorError::OwnerStartupTimeout); + } + journal = begin_rollback(store, &journal)?; + continue; + } + journal = advance(store, &journal, MacosHandoverPhase::CommitPending)?; + } + MacosHandoverPhase::CommitPending => { + store.set_external_owner_mode(external_owner_mode(journal.requested_owner))?; + clear_conflict_if_present(store)?; + journal = advance(store, &journal, MacosHandoverPhase::Committed)?; + } + MacosHandoverPhase::Committed => { + let owner_epoch = store + .load_owner_record()? + .filter(|record| record.active_owner == journal.requested_owner) + .map_or(journal.active_epoch, |record| record.owner_epoch); + return Ok(MacosOwnerCoordinatorOutcome::Active { + owner: journal.requested_owner, + owner_epoch, + }); + } + MacosHandoverPhase::RollbackPending => { + for operation in autostart_operations_from(journal.prior_autostart_states) { + execute_operation(executor, &journal, operation, false)?; + } + journal = advance( + store, + &journal, + MacosHandoverPhase::RollbackAutostartsRestored, + )?; + } + MacosHandoverPhase::RollbackAutostartsRestored => { + journal = advance( + store, + &journal, + if journal.requested_owner == journal.prior_owner { + MacosHandoverPhase::RollbackCommitPending + } else { + MacosHandoverPhase::RollbackStopRequested + }, + )?; + } + MacosHandoverPhase::RollbackStopRequested => { + let operation = flush_stop_operation(journal.requested_owner)?; + execute_operation(executor, &journal, operation, false)?; + journal = advance(store, &journal, MacosHandoverPhase::RollbackOwnerStopped)?; + } + MacosHandoverPhase::RollbackOwnerStopped => { + journal = advance( + store, + &journal, + MacosHandoverPhase::RollbackAwaitingGuardRelease, + )?; + } + MacosHandoverPhase::RollbackAwaitingGuardRelease => { + if let Some(pid) = active_pid(store, journal.requested_owner)? + && !executor + .wait_for_guard_release(pid, MACOS_MANAGED_HANDOVER_TIMEOUT) + .map_err(|source| MacosOwnerCoordinatorError::Operation { + operation: flush_stop_operation(journal.requested_owner) + .expect("validated requested owner is managed"), + source, + })? + { + return Err(MacosOwnerCoordinatorError::GuardReleaseTimeout); + } + journal = advance(store, &journal, MacosHandoverPhase::RollbackGuardReleased)?; + } + MacosHandoverPhase::RollbackGuardReleased => { + journal = advance(store, &journal, MacosHandoverPhase::RollbackStartRequested)?; + } + MacosHandoverPhase::RollbackStartRequested => { + let operation = start_operation(journal.prior_owner)?; + execute_operation(executor, &journal, operation, false)?; + journal = advance(store, &journal, MacosHandoverPhase::PriorOwnerStarted)?; + } + MacosHandoverPhase::PriorOwnerStarted => { + if !executor + .wait_for_owner( + journal.prior_owner, + journal.active_epoch, + MACOS_MANAGED_HANDOVER_TIMEOUT, + ) + .map_err(|source| MacosOwnerCoordinatorError::Operation { + operation: start_operation(journal.prior_owner) + .expect("rollback prior owner is managed"), + source, + })? + { + return Err(MacosOwnerCoordinatorError::OwnerStartupTimeout); + } + journal = advance(store, &journal, MacosHandoverPhase::RollbackCommitPending)?; + } + MacosHandoverPhase::RollbackCommitPending => { + store.set_external_owner_mode(external_owner_mode(journal.prior_owner))?; + clear_conflict_if_present(store)?; + journal = advance(store, &journal, MacosHandoverPhase::RolledBack)?; + } + MacosHandoverPhase::RolledBack => { + return Ok(MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: journal.prior_owner, + failure: "requested owner failed to become active".to_owned(), + }); + } + } + } +} + +fn clear_conflict_if_present(store: &MacosOwnerStore) -> Result<(), MacosOwnerStoreError> { + if store.load_owner_record()?.is_some() { + store.clear_conflict()?; + } + Ok(()) +} + +fn advance( + store: &MacosOwnerStore, + journal: &MacosHandoverJournal, + phase: MacosHandoverPhase, +) -> Result { + match store.advance_handover_from(&journal.transaction_id, journal.phase, phase) { + Ok(advanced) => Ok(advanced), + Err(MacosOwnerStoreError::HandoverPhaseChanged { .. }) => store + .load_handover_journal()? + .filter(|current| current.transaction_id == journal.transaction_id) + .ok_or(MacosOwnerStoreError::HandoverTransactionMismatch), + Err(error) => Err(error), + } +} + +fn begin_rollback( + store: &MacosOwnerStore, + journal: &MacosHandoverJournal, +) -> Result { + advance(store, journal, MacosHandoverPhase::RollbackPending) +} + +fn execute_operation( + executor: &mut impl MacosOwnerExecutor, + journal: &MacosHandoverJournal, + operation: MacosHandoverOperation, + forward: bool, +) -> Result<(), MacosOwnerCoordinatorError> { + let allowed = if forward { + &journal.allowed_forward_operations + } else { + &journal.allowed_rollback_operations + }; + require_operation(allowed, operation)?; + match operation { + MacosHandoverOperation::SetAppSidecarAutostart { enabled } => { + executor.set_autostart(MacosDaemonOwner::AppSidecar, enabled) + } + MacosHandoverOperation::FlushAndStopAppSidecar {} => executor.flush_and_stop( + MacosDaemonOwner::AppSidecar, + operation_pid(journal, MacosDaemonOwner::AppSidecar, forward), + ), + MacosHandoverOperation::StartAppSidecar {} => executor.start(MacosDaemonOwner::AppSidecar), + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled } => { + executor.set_autostart(MacosDaemonOwner::DirectLaunchd, enabled) + } + MacosHandoverOperation::FlushAndStopDirectLaunchd {} => executor.flush_and_stop( + MacosDaemonOwner::DirectLaunchd, + operation_pid(journal, MacosDaemonOwner::DirectLaunchd, forward), + ), + MacosHandoverOperation::StartDirectLaunchd {} => { + executor.start(MacosDaemonOwner::DirectLaunchd) + } + MacosHandoverOperation::SetHomebrewAutostart { enabled } => { + executor.set_autostart(MacosDaemonOwner::Homebrew, enabled) + } + MacosHandoverOperation::FlushAndStopHomebrew {} => executor.flush_and_stop( + MacosDaemonOwner::Homebrew, + operation_pid(journal, MacosDaemonOwner::Homebrew, forward), + ), + MacosHandoverOperation::StartHomebrew {} => executor.start(MacosDaemonOwner::Homebrew), + MacosHandoverOperation::AwaitStandaloneExit { .. } => Ok(()), + } + .map_err(|source| MacosOwnerCoordinatorError::Operation { operation, source }) +} + +fn operation_pid( + journal: &MacosHandoverJournal, + owner: MacosDaemonOwner, + forward: bool, +) -> Option { + if forward && journal.prior_owner == owner { + journal.pending_standalone_pid + } else { + None + } +} + +fn require_operation( + allowed: &[MacosHandoverOperation], + operation: MacosHandoverOperation, +) -> Result<(), MacosOwnerCoordinatorError> { + if allowed.contains(&operation) { + Ok(()) + } else { + Err(MacosOwnerCoordinatorError::UnauthorizedOperation { operation }) + } +} + +fn active_pid( + store: &MacosOwnerStore, + owner: MacosDaemonOwner, +) -> Result, MacosOwnerStoreError> { + Ok(store + .load_owner_record()? + .filter(|record| record.active_owner == owner) + .map(|record| record.active_identity.pid)) +} + +const fn external_owner_mode(owner: MacosDaemonOwner) -> Option { + match owner { + MacosDaemonOwner::DirectLaunchd => Some(MacosExternalOwnerMode::DirectLaunchd), + MacosDaemonOwner::Homebrew => Some(MacosExternalOwnerMode::Homebrew), + MacosDaemonOwner::AppSidecar | MacosDaemonOwner::Standalone => None, + } +} + +fn forward_operations( + requested_owner: MacosDaemonOwner, + prior_owner: MacosDaemonOwner, + pending_standalone_pid: Option, +) -> Vec { + let mut operations = autostart_operations_for(requested_owner).to_vec(); + if requested_owner == prior_owner { + return operations; + } + if let Some(pid) = pending_standalone_pid { + operations.push(MacosHandoverOperation::AwaitStandaloneExit { pid }); + } else if let Ok(stop) = flush_stop_operation(prior_owner) { + operations.push(stop); + } + if let Ok(start) = start_operation(requested_owner) { + operations.push(start); + } + operations +} + +fn rollback_operations( + requested_owner: MacosDaemonOwner, + prior_owner: MacosDaemonOwner, + prior_states: MacosAutostartStates, +) -> Vec { + let mut operations = autostart_operations_from(prior_states).to_vec(); + if requested_owner == prior_owner { + return operations; + } + if let Ok(stop) = flush_stop_operation(requested_owner) { + operations.push(stop); + } + if let Ok(start) = start_operation(prior_owner) { + operations.push(start); + } + operations +} + +const fn autostart_operations_for(owner: MacosDaemonOwner) -> [MacosHandoverOperation; 3] { + [ + MacosHandoverOperation::SetAppSidecarAutostart { + enabled: matches!(owner, MacosDaemonOwner::AppSidecar), + }, + MacosHandoverOperation::SetDirectLaunchdAutostart { + enabled: matches!(owner, MacosDaemonOwner::DirectLaunchd), + }, + MacosHandoverOperation::SetHomebrewAutostart { + enabled: matches!(owner, MacosDaemonOwner::Homebrew), + }, + ] +} + +const fn autostart_operations_from(states: MacosAutostartStates) -> [MacosHandoverOperation; 3] { + [ + MacosHandoverOperation::SetAppSidecarAutostart { + enabled: states.app_sidecar, + }, + MacosHandoverOperation::SetDirectLaunchdAutostart { + enabled: states.direct_launchd, + }, + MacosHandoverOperation::SetHomebrewAutostart { + enabled: states.homebrew, + }, + ] +} + +const fn flush_stop_operation( + owner: MacosDaemonOwner, +) -> Result { + match owner { + MacosDaemonOwner::AppSidecar => Ok(MacosHandoverOperation::FlushAndStopAppSidecar {}), + MacosDaemonOwner::DirectLaunchd => Ok(MacosHandoverOperation::FlushAndStopDirectLaunchd {}), + MacosDaemonOwner::Homebrew => Ok(MacosHandoverOperation::FlushAndStopHomebrew {}), + MacosDaemonOwner::Standalone => Err(MacosOwnerCoordinatorError::StandaloneCannotBeSelected), + } +} + +const fn start_operation( + owner: MacosDaemonOwner, +) -> Result { + match owner { + MacosDaemonOwner::AppSidecar => Ok(MacosHandoverOperation::StartAppSidecar {}), + MacosDaemonOwner::DirectLaunchd => Ok(MacosHandoverOperation::StartDirectLaunchd {}), + MacosDaemonOwner::Homebrew => Ok(MacosHandoverOperation::StartHomebrew {}), + MacosDaemonOwner::Standalone => Err(MacosOwnerCoordinatorError::StandaloneCannotBeSelected), + } +} + +struct CoordinationLock { + file: File, +} + +impl Drop for CoordinationLock { + fn drop(&mut self) { + drop(self.file.unlock()); + } +} + +fn successor_owner_record( + previous: MacosOwnerRecord, + active_owner: MacosDaemonOwner, + active_identity: MacosOwnerIdentity, +) -> Result { + let owner_epoch = previous + .owner_epoch + .checked_add(1) + .ok_or(MacosOwnerStoreError::OwnerEpochOverflow)?; + let conflict = previous + .conflict + .filter(|conflict| { + conflict.contender_owner != active_owner + || conflict.contender_identity.executable_path != active_identity.executable_path + || conflict.contender_identity.designated_requirement_hash + != active_identity.designated_requirement_hash + }) + .map(|mut conflict| { + conflict.active_owner = active_owner; + conflict.active_epoch = owner_epoch; + conflict + }); + Ok(MacosOwnerRecord { + owner_epoch, + schema_version: MACOS_OWNER_RECORD_SCHEMA_VERSION, + active_owner, + active_identity, + conflict, + selected_external_owner: previous.selected_external_owner, + }) +} + +fn read_owner_record(path: &Path) -> Result, MacosOwnerStoreError> { + let Some(bytes) = read_optional(path, "owner record")? else { + return Ok(None); + }; + let record = serde_json::from_slice::(&bytes).map_err(|source| { + MacosOwnerStoreError::Decode { + artifact: "owner record", + source, + } + })?; + validate_owner_record(&record)?; + Ok(Some(record)) +} + +fn read_handover_journal( + path: &Path, +) -> Result, MacosOwnerStoreError> { + let Some(bytes) = read_optional(path, "handover journal")? else { + return Ok(None); + }; + let journal = serde_json::from_slice::(&bytes).map_err(|source| { + MacosOwnerStoreError::Decode { + artifact: "handover journal", + source, + } + })?; + validate_handover_journal(&journal)?; + Ok(Some(journal)) +} + +fn read_optional( + path: &Path, + artifact: &'static str, +) -> Result>, MacosOwnerStoreError> { + match File::open(path) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take((MAX_MACOS_OWNER_ARTIFACT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|source| MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + })?; + if bytes.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { + return Err(MacosOwnerStoreError::ArtifactTooLarge { + artifact, + maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, + }); + } + Ok(Some(bytes)) + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + }), + } +} + +fn validate_owner_record(record: &MacosOwnerRecord) -> Result<(), MacosOwnerStoreError> { + validate_version( + "owner record", + record.schema_version, + MACOS_OWNER_RECORD_SCHEMA_VERSION, + )?; + if record.owner_epoch == 0 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "owner record", + detail: "owner_epoch must be positive", + }); + } + validate_owner_identity(&record.active_identity)?; + if let Some(conflict) = &record.conflict + && (conflict.active_owner != record.active_owner + || conflict.active_epoch != record.owner_epoch) + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "owner record", + detail: "conflict must identify the active owner epoch", + }); + } + if let Some(conflict) = &record.conflict { + validate_owner_identity(&conflict.contender_identity)?; + } + Ok(()) +} + +fn validate_owner_identity(identity: &MacosOwnerIdentity) -> Result<(), MacosOwnerStoreError> { + validate_bounded_identity_text( + "audit_token_identity", + &identity.audit_token_identity, + MAX_MACOS_AUDIT_TOKEN_IDENTITY_BYTES, + )?; + let executable_path = + identity + .executable_path + .to_str() + .ok_or(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + detail: "must be valid UTF-8", + })?; + validate_bounded_identity_text( + "executable_path", + executable_path, + MAX_MACOS_EXECUTABLE_PATH_BYTES, + )?; + if !identity.executable_path.is_absolute() { + return Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "executable_path", + detail: "must be absolute", + }); + } + validate_bounded_identity_text( + "designated_requirement_hash", + &identity.designated_requirement_hash, + MAX_MACOS_DESIGNATED_REQUIREMENT_HASH_BYTES, + )?; + if identity.pid == 0 { + return Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field: "pid", + detail: "must be positive", + }); + } + Ok(()) +} + +fn validate_bounded_identity_text( + field: &'static str, + value: &str, + maximum_bytes: usize, +) -> Result<(), MacosOwnerStoreError> { + if value.is_empty() { + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field, + detail: "must not be empty", + }) + } else if value.len() > maximum_bytes { + Err(MacosOwnerStoreError::InvalidOwnerIdentity { + field, + detail: "exceeds its byte limit", + }) + } else { + Ok(()) + } +} + +fn validate_handover_journal(journal: &MacosHandoverJournal) -> Result<(), MacosOwnerStoreError> { + validate_version( + "handover journal", + journal.schema_version, + MACOS_HANDOVER_JOURNAL_SCHEMA_VERSION, + )?; + if !is_valid_transaction_id(journal.transaction_id.as_str()) { + return Err(MacosOwnerStoreError::InvalidTransactionId); + } + if journal.active_epoch == 0 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "active_epoch must be positive", + }); + } + if journal.allowed_forward_operations.len() > MAX_MACOS_HANDOVER_OPERATIONS { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "allowed_forward_operations exceeds its item limit", + }); + } + if journal.allowed_rollback_operations.len() > MAX_MACOS_HANDOVER_OPERATIONS { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "allowed_rollback_operations exceeds its item limit", + }); + } + if journal.pending_standalone_pid == Some(0) + || journal + .allowed_forward_operations + .iter() + .chain(&journal.allowed_rollback_operations) + .any(|operation| { + matches!( + operation, + MacosHandoverOperation::AwaitStandaloneExit { pid: 0 } + ) + }) + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "standalone PID must be positive", + }); + } + Ok(()) +} + +fn validate_version( + artifact: &'static str, + found: u32, + expected: u32, +) -> Result<(), MacosOwnerStoreError> { + if found == expected { + Ok(()) + } else { + Err(MacosOwnerStoreError::UnsupportedVersion { + artifact, + found, + expected, + }) + } +} + +fn is_valid_transaction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn write_json_atomic( + data_dir: &Path, + path: &Path, + artifact: &'static str, + value: &T, +) -> Result<(), MacosOwnerStoreError> +where + T: Serialize + ?Sized, +{ + let mut payload = serde_json::to_vec_pretty(value) + .map_err(|source| MacosOwnerStoreError::Encode { artifact, source })?; + payload.push(b'\n'); + if payload.len() > MAX_MACOS_OWNER_ARTIFACT_BYTES { + return Err(MacosOwnerStoreError::ArtifactTooLarge { + artifact, + maximum_bytes: MAX_MACOS_OWNER_ARTIFACT_BYTES, + }); + } + let (mut temporary, temporary_path) = create_temporary_file(data_dir, path)?; + let result = (|| { + temporary + .write_all(&payload) + .map_err(|source| MacosOwnerStoreError::WriteTemporary { + path: path.to_path_buf(), + source, + })?; + temporary + .sync_all() + .map_err(|source| MacosOwnerStoreError::SyncTemporary { + path: path.to_path_buf(), + source, + })?; + drop(temporary); + hypercolor_platform_fs::replace_file(&temporary_path, path).map_err(|source| { + MacosOwnerStoreError::Replace { + path: path.to_path_buf(), + source, + } + })?; + sync_parent_directory(data_dir) + })(); + if result.is_err() { + drop(fs::remove_file(&temporary_path)); + } + result +} + +fn create_temporary_file( + data_dir: &Path, + path: &Path, +) -> Result<(File, PathBuf), MacosOwnerStoreError> { + for _ in 0..MAX_TEMPORARY_CREATE_ATTEMPTS { + let sequence = TEMPORARY_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary_path = data_dir.join(format!( + ".{}.{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("macos-owner"), + std::process::id(), + sequence + )); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&temporary_path) { + Ok(file) => return Ok((file, temporary_path)), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(source) => { + return Err(MacosOwnerStoreError::CreateTemporary { + path: path.to_path_buf(), + source, + }); + } + } + } + Err(MacosOwnerStoreError::CreateTemporary { + path: path.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "temporary file collision limit reached", + ), + }) +} + +#[cfg(unix)] +fn sync_parent_directory(data_dir: &Path) -> Result<(), MacosOwnerStoreError> { + File::open(data_dir) + .and_then(|directory| directory.sync_all()) + .map_err(|source| MacosOwnerStoreError::SyncDirectory { + path: data_dir.to_path_buf(), + source, + }) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_data_dir: &Path) -> Result<(), MacosOwnerStoreError> { + Ok(()) +} diff --git a/crates/hypercolor-macos-owner/tests/coordinator_tests.rs b/crates/hypercolor-macos-owner/tests/coordinator_tests.rs new file mode 100644 index 000000000..ed803c732 --- /dev/null +++ b/crates/hypercolor-macos-owner/tests/coordinator_tests.rs @@ -0,0 +1,882 @@ +use std::collections::VecDeque; +use std::fs; +use std::time::Duration; + +use hypercolor_macos_owner::{ + MACOS_MANAGED_HANDOVER_TIMEOUT, MACOS_STANDALONE_HANDOVER_TIMEOUT, MacosDaemonOwner, + MacosHandoverJournal, MacosHandoverOperation, MacosHandoverPhase, MacosHandoverTransactionId, + MacosOwnerCoordinatorOutcome, MacosOwnerExecutionError, MacosOwnerExecutor, MacosOwnerIdentity, + MacosOwnerRemedy, MacosOwnerStore, choose_daemon_owner, recover_daemon_owner, + recover_incoming_daemon_owner, +}; + +fn identity(label: &str, pid: u32) -> MacosOwnerIdentity { + MacosOwnerIdentity::new( + format!("audit-{label}"), + format!("/Applications/{label}/hypercolor-daemon"), + format!("requirement-{label}"), + pid, + ) + .expect("fixture identity should be valid") +} + +fn transaction(label: &str) -> MacosHandoverTransactionId { + MacosHandoverTransactionId::new(label).expect("fixture transaction should be valid") +} + +struct FixtureExecutor { + store: MacosOwnerStore, + autostarts: [bool; 3], + operations: Vec, + guard_results: VecDeque, + owner_results: VecDeque, + fail_operation: Option, + next_pid: u32, + incoming_recovers_on_start: bool, +} + +impl FixtureExecutor { + fn new(store: MacosOwnerStore) -> Self { + Self { + store, + autostarts: [true, false, false], + operations: Vec::new(), + guard_results: VecDeque::from([true]), + owner_results: VecDeque::from([true, true]), + fail_operation: None, + next_pid: 1_000, + incoming_recovers_on_start: false, + } + } + + fn index(owner: MacosDaemonOwner) -> Result { + match owner { + MacosDaemonOwner::AppSidecar => Ok(0), + MacosDaemonOwner::DirectLaunchd => Ok(1), + MacosDaemonOwner::Homebrew => Ok(2), + MacosDaemonOwner::Standalone => Err(MacosOwnerExecutionError::new( + "standalone has no autostart state", + )), + } + } + + fn push(&mut self, operation: MacosHandoverOperation) -> Result<(), MacosOwnerExecutionError> { + self.operations.push(operation); + if self.fail_operation == Some(operation) { + Err(MacosOwnerExecutionError::new("injected operation failure")) + } else { + Ok(()) + } + } +} + +impl MacosOwnerExecutor for FixtureExecutor { + fn autostart_enabled( + &mut self, + owner: MacosDaemonOwner, + ) -> Result { + Ok(self.autostarts[Self::index(owner)?]) + } + + fn set_autostart( + &mut self, + owner: MacosDaemonOwner, + enabled: bool, + ) -> Result<(), MacosOwnerExecutionError> { + let operation = match owner { + MacosDaemonOwner::AppSidecar => { + MacosHandoverOperation::SetAppSidecarAutostart { enabled } + } + MacosDaemonOwner::DirectLaunchd => { + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled } + } + MacosDaemonOwner::Homebrew => MacosHandoverOperation::SetHomebrewAutostart { enabled }, + MacosDaemonOwner::Standalone => { + return Err(MacosOwnerExecutionError::new( + "standalone has no autostart state", + )); + } + }; + self.push(operation)?; + self.autostarts[Self::index(owner)?] = enabled; + Ok(()) + } + + fn flush_and_stop( + &mut self, + owner: MacosDaemonOwner, + _pid: Option, + ) -> Result<(), MacosOwnerExecutionError> { + let operation = match owner { + MacosDaemonOwner::AppSidecar => MacosHandoverOperation::FlushAndStopAppSidecar {}, + MacosDaemonOwner::DirectLaunchd => MacosHandoverOperation::FlushAndStopDirectLaunchd {}, + MacosDaemonOwner::Homebrew => MacosHandoverOperation::FlushAndStopHomebrew {}, + MacosDaemonOwner::Standalone => { + return Err(MacosOwnerExecutionError::new( + "standalone cannot be stopped remotely", + )); + } + }; + self.push(operation) + } + + fn start(&mut self, owner: MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError> { + let operation = match owner { + MacosDaemonOwner::AppSidecar => MacosHandoverOperation::StartAppSidecar {}, + MacosDaemonOwner::DirectLaunchd => MacosHandoverOperation::StartDirectLaunchd {}, + MacosDaemonOwner::Homebrew => MacosHandoverOperation::StartHomebrew {}, + MacosDaemonOwner::Standalone => { + return Err(MacosOwnerExecutionError::new( + "standalone cannot be started by a launcher", + )); + } + }; + self.push(operation)?; + if self.incoming_recovers_on_start { + self.next_pid += 1; + self.store + .publish_owner(owner, identity("racing-incoming", self.next_pid)) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + recover_incoming_daemon_owner(&self.store, owner) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + } + Ok(()) + } + + fn wait_for_guard_release( + &mut self, + _pid: u32, + _timeout: Duration, + ) -> Result { + Ok(self.guard_results.pop_front().unwrap_or(true)) + } + + fn wait_for_owner( + &mut self, + owner: MacosDaemonOwner, + _after_epoch: u64, + _timeout: Duration, + ) -> Result { + let result = self.owner_results.pop_front().unwrap_or(true); + if result { + self.next_pid += 1; + self.store + .publish_owner(owner, identity("incoming", self.next_pid)) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + } + Ok(result) + } +} + +#[test] +fn managed_handover_is_synchronous_and_commits_every_forward_phase() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("managed-success"), + ) + .expect("handover should succeed"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + .. + } + )); + assert_eq!(executor.autostarts, [false, true, false]); + let journal = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist"); + assert_eq!(journal.phase, MacosHandoverPhase::Committed); + assert_eq!(journal.journal_revision, 10); + assert_eq!( + store + .load_owner_record() + .expect("owner should load") + .expect("owner should exist") + .selected_external_owner, + Some(hypercolor_macos_owner::MacosExternalOwnerMode::DirectLaunchd) + ); +} + +#[test] +fn same_owner_choice_journals_competing_autostart_reconciliation() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let record = store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("direct", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.autostarts = [true, true, true]; + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("same-owner-reconcile"), + ) + .expect("same-owner reconciliation should commit"); + + assert_eq!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + owner_epoch: record.owner_epoch, + } + ); + assert_eq!(executor.autostarts, [false, true, false]); + assert_eq!( + executor.operations, + [ + MacosHandoverOperation::SetAppSidecarAutostart { enabled: false }, + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled: true }, + MacosHandoverOperation::SetHomebrewAutostart { enabled: false }, + ] + ); + let journal = store + .load_handover_journal() + .expect("journal should load") + .expect("same-owner reconciliation should be journaled"); + assert_eq!(journal.phase, MacosHandoverPhase::Committed); + assert_eq!(journal.journal_revision, 4); + let record = store + .load_owner_record() + .expect("owner record should load") + .expect("owner record should remain present"); + assert_eq!( + record.selected_external_owner, + Some(hypercolor_macos_owner::MacosExternalOwnerMode::DirectLaunchd) + ); +} + +#[test] +fn failed_start_runs_the_complete_rollback_and_restores_prior_mode() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.fail_operation = Some(MacosHandoverOperation::StartDirectLaunchd {}); + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("managed-rollback"), + ) + .expect("rollback should complete"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + .. + } + )); + assert_eq!(executor.autostarts, [true, false, false]); + let journal = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist"); + assert_eq!(journal.phase, MacosHandoverPhase::RolledBack); + assert_eq!(journal.journal_revision, 17); +} + +#[test] +fn rollback_preserves_an_all_disabled_launcher_state() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.autostarts = [false, false, false]; + executor.fail_operation = Some(MacosHandoverOperation::StartDirectLaunchd {}); + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("all-disabled-rollback"), + ) + .expect("rollback should complete"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::RolledBack { .. } + )); + assert_eq!(executor.autostarts, [false, false, false]); +} + +#[test] +fn incoming_daemon_and_surviving_coordinator_converge_without_phase_regression() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.incoming_recovers_on_start = true; + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("racing-incoming"), + ) + .expect("both recovery participants should converge"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + .. + } + )); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::Committed + ); +} + +#[test] +fn standalone_handover_never_mutates_autostart_before_user_exit() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::Standalone, identity("standalone", 4242)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.guard_results = VecDeque::from([false]); + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::Homebrew, + transaction("standalone-pending"), + ) + .expect("pending handover should be typed"); + + assert_eq!(executor.autostarts, [true, false, false]); + assert!(executor.operations.is_empty()); + assert_eq!( + outcome, + MacosOwnerCoordinatorOutcome::PendingStandalone { + requested_owner: MacosDaemonOwner::Homebrew, + remedy: MacosOwnerRemedy::StopStandaloneOwner { pid: 4242 }, + } + ); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::AwaitingGuardRelease + ); +} + +#[test] +fn standalone_pending_resumes_after_native_guard_notification() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::Standalone, identity("standalone", 4242)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.guard_results = VecDeque::from([false]); + choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::Homebrew, + transaction("standalone-resume"), + ) + .expect("first invocation should remain pending"); + + executor.guard_results = VecDeque::from([true]); + let outcome = recover_daemon_owner(&store, &mut executor) + .expect("recovery should succeed") + .expect("pending journal should recover"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::Homebrew, + .. + } + )); + assert_eq!(executor.autostarts, [false, false, true]); +} + +#[test] +fn incoming_daemon_only_commits_its_matching_journal_role() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("incoming-recovery"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + let journal = store.begin_handover(journal).expect("journal should begin"); + store + .advance_handover(&journal.transaction_id, MacosHandoverPhase::StartRequested) + .expect("start request should persist"); + store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("launchd", 202)) + .expect("incoming owner should publish"); + + let outcome = recover_incoming_daemon_owner(&store, MacosDaemonOwner::DirectLaunchd) + .expect("incoming recovery should succeed") + .expect("journal should reconcile"); + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + .. + } + )); + + let unrelated = recover_incoming_daemon_owner(&store, MacosDaemonOwner::Homebrew) + .expect("terminal journal should be inert"); + assert!(unrelated.is_none()); +} + +#[test] +fn requested_incoming_daemon_completes_every_applicable_forward_phase() { + for (index, phase) in [ + MacosHandoverPhase::AutostartsConfigured, + MacosHandoverPhase::StopRequested, + MacosHandoverPhase::OutgoingOwnerStopped, + MacosHandoverPhase::AwaitingGuardRelease, + MacosHandoverPhase::GuardReleased, + MacosHandoverPhase::StartRequested, + MacosHandoverPhase::RequestedOwnerStarted, + MacosHandoverPhase::CommitPending, + ] + .into_iter() + .enumerate() + { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("incoming-forward-{index}")), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + let journal = store.begin_handover(journal).expect("journal should begin"); + store + .advance_handover(&journal.transaction_id, phase) + .expect("fixture phase should persist"); + store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("launchd", 202)) + .expect("requested incoming owner should publish"); + + let outcome = recover_incoming_daemon_owner(&store, MacosDaemonOwner::DirectLaunchd) + .expect("incoming recovery should succeed") + .expect("journal should reconcile"); + assert!( + matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + .. + } + ), + "phase {phase:?} should commit for the active requested owner" + ); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::Committed + ); + } +} + +#[test] +fn incoming_requested_owner_does_not_skip_unconfigured_standalone_autostarts() { + for phase in [ + MacosHandoverPhase::Prepared, + MacosHandoverPhase::AwaitingGuardRelease, + MacosHandoverPhase::GuardReleased, + ] { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::Standalone, identity("standalone", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("incoming-standalone-{phase:?}")), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(false, false, false), + ) + .expect("journal should build"); + let journal = store.begin_handover(journal).expect("journal should begin"); + if phase != MacosHandoverPhase::Prepared { + store + .advance_handover(&journal.transaction_id, phase) + .expect("fixture phase should persist"); + } + store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("launchd", 202)) + .expect("requested incoming owner should publish"); + + let outcome = recover_incoming_daemon_owner(&store, MacosDaemonOwner::DirectLaunchd) + .expect("incoming recovery should inspect the journal") + .expect("journal should require coordinator recovery"); + assert_eq!( + outcome, + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::Standalone, + phase, + } + ); + } +} + +#[test] +fn unrelated_incoming_daemon_returns_path_free_recovery_status() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("unrelated-incoming"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(false, false, false), + ) + .expect("journal should build"); + store.begin_handover(journal).expect("journal should begin"); + store + .publish_owner(MacosDaemonOwner::Homebrew, identity("homebrew", 303)) + .expect("unrelated incoming owner should publish"); + + let outcome = recover_incoming_daemon_owner(&store, MacosDaemonOwner::Homebrew) + .expect("incoming recovery should inspect the journal") + .expect("nonterminal journal should publish recovery status"); + assert_eq!( + outcome, + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: MacosHandoverPhase::Prepared, + } + ); + let encoded = serde_json::to_string(&outcome).expect("recovery status should serialize"); + assert!(!encoded.contains("Applications")); + assert!(!encoded.contains("executable")); +} + +#[test] +fn timeout_contracts_remain_ten_and_sixty_seconds() { + assert_eq!(MACOS_MANAGED_HANDOVER_TIMEOUT, Duration::from_secs(10)); + assert_eq!(MACOS_STANDALONE_HANDOVER_TIMEOUT, Duration::from_mins(1)); +} + +#[test] +fn every_nonterminal_phase_recovers_to_one_viable_owner() { + for (index, phase) in MacosHandoverPhase::ALL.into_iter().enumerate() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("recover-phase-{index}")), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + let journal = store.begin_handover(journal).expect("journal should begin"); + if phase != MacosHandoverPhase::Prepared { + store + .advance_handover(&journal.transaction_id, phase) + .expect("fixture phase should persist"); + } + + if matches!( + phase, + MacosHandoverPhase::RequestedOwnerStarted + | MacosHandoverPhase::CommitPending + | MacosHandoverPhase::RollbackPending + | MacosHandoverPhase::RollbackAutostartsRestored + | MacosHandoverPhase::RollbackStopRequested + | MacosHandoverPhase::RollbackOwnerStopped + | MacosHandoverPhase::RollbackAwaitingGuardRelease + | MacosHandoverPhase::RollbackGuardReleased + | MacosHandoverPhase::RollbackStartRequested + ) { + store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("direct", 202)) + .expect("requested owner fixture should publish"); + } + + let mut executor = FixtureExecutor::new(store.clone()); + executor.autostarts = if phase == MacosHandoverPhase::Prepared { + [true, false, false] + } else if matches!( + phase, + MacosHandoverPhase::AutostartsConfigured + | MacosHandoverPhase::StopRequested + | MacosHandoverPhase::OutgoingOwnerStopped + | MacosHandoverPhase::AwaitingGuardRelease + | MacosHandoverPhase::GuardReleased + | MacosHandoverPhase::StartRequested + | MacosHandoverPhase::RequestedOwnerStarted + | MacosHandoverPhase::CommitPending + | MacosHandoverPhase::RollbackPending + ) { + [false, true, false] + } else { + [true, false, false] + }; + + let recovered = + recover_daemon_owner(&store, &mut executor).expect("phase recovery should not fail"); + if phase.is_terminal() { + assert!( + recovered.is_none(), + "terminal phase {phase:?} must be inert" + ); + continue; + } + let outcome = recovered.expect("nonterminal phase should recover"); + if matches!( + phase, + MacosHandoverPhase::RollbackPending + | MacosHandoverPhase::RollbackAutostartsRestored + | MacosHandoverPhase::RollbackStopRequested + | MacosHandoverPhase::RollbackOwnerStopped + | MacosHandoverPhase::RollbackAwaitingGuardRelease + | MacosHandoverPhase::RollbackGuardReleased + | MacosHandoverPhase::RollbackStartRequested + | MacosHandoverPhase::PriorOwnerStarted + | MacosHandoverPhase::RollbackCommitPending + ) { + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + .. + } + )); + } else { + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::Active { + owner: MacosDaemonOwner::DirectLaunchd, + .. + } + )); + } + } +} + +#[test] +fn stop_and_guard_failures_enter_durable_rollback() { + for (label, failure, guard_results) in [ + ( + "stop-failure", + Some(MacosHandoverOperation::FlushAndStopAppSidecar {}), + VecDeque::from([true]), + ), + ("guard-timeout", None, VecDeque::from([false, true])), + ] { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.fail_operation = failure; + executor.guard_results = guard_results; + + let outcome = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction(label), + ) + .expect("rollback should complete"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + .. + } + )); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::RolledBack + ); + } +} + +#[test] +fn forward_operation_payloads_and_oversized_lists_are_rejected() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("forward-payload"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + store.begin_handover(journal).expect("journal should begin"); + let mut value: serde_json::Value = serde_json::from_slice( + &fs::read(store.handover_journal_path()).expect("journal should read"), + ) + .expect("journal should decode as JSON"); + value["allowed_forward_operations"] = serde_json::json!([{ + "kind": "start_direct_launchd", + "command": "/bin/sh", + "argv": ["-c", "forbidden"], + "executable_path": "/tmp/forbidden" + }]); + fs::write( + store.handover_journal_path(), + serde_json::to_vec(&value).expect("fixture should encode"), + ) + .expect("fixture should write"); + assert!(store.load_handover_journal().is_err()); + + value["allowed_forward_operations"] = serde_json::Value::Array( + (0..=hypercolor_macos_owner::MAX_MACOS_HANDOVER_OPERATIONS) + .map(|_| serde_json::json!({ "kind": "start_direct_launchd" })) + .collect(), + ); + fs::write( + store.handover_journal_path(), + serde_json::to_vec(&value).expect("fixture should encode"), + ) + .expect("fixture should write"); + assert!(store.load_handover_journal().is_err()); +} + +#[test] +fn conditional_phase_advance_cannot_regress_concurrent_recovery() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("phase-cas"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + let journal = store.begin_handover(journal).expect("journal should begin"); + store + .advance_handover_from( + &journal.transaction_id, + MacosHandoverPhase::Prepared, + MacosHandoverPhase::AutostartsConfigured, + ) + .expect("first participant should advance"); + + assert!(matches!( + store.advance_handover_from( + &journal.transaction_id, + MacosHandoverPhase::Prepared, + MacosHandoverPhase::RollbackPending, + ), + Err( + hypercolor_macos_owner::MacosOwnerStoreError::HandoverPhaseChanged { + expected: MacosHandoverPhase::Prepared, + found: MacosHandoverPhase::AutostartsConfigured, + } + ) + )); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::AutostartsConfigured + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_guard_waiter_reacquires_only_after_final_guard_release() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let guard_name = guard_path.to_string_lossy().into_owned(); + let winner = + single_instance::SingleInstance::new(&guard_name).expect("winner guard should open"); + assert!(winner.is_single()); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let waiter_name = guard_name.clone(); + let waiter = std::thread::spawn(move || { + sender + .send(hypercolor_macos_owner::acquire_macos_daemon_guard( + &waiter_name, + )) + .expect("guard result should send"); + }); + + assert!(matches!( + receiver.recv_timeout(Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + drop(winner); + let reacquired = receiver + .recv_timeout(Duration::from_secs(1)) + .expect("waiter should observe guard release") + .expect("waiter should acquire the guard"); + let contender = + single_instance::SingleInstance::new(&guard_name).expect("contender guard should open"); + assert!(!contender.is_single()); + drop(reacquired); + drop(contender); + waiter.join().expect("guard waiter should finish"); + assert!( + single_instance::SingleInstance::new(&guard_name) + .expect("final guard should open") + .is_single() + ); +} diff --git a/crates/hypercolor-types/src/event.rs b/crates/hypercolor-types/src/event.rs index 244a6be19..e95b1bb53 100644 --- a/crates/hypercolor-types/src/event.rs +++ b/crates/hypercolor-types/src/event.rs @@ -414,6 +414,9 @@ pub enum MacosDaemonOwnerEvent { Standalone, } +/// Process exit code for a non-launchd macOS daemon ownership contender. +pub const MACOS_DAEMON_OWNER_CONFLICT_EXIT_CODE: i32 = 73; + /// Losing macOS daemon topology observed beside the active owner. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MacosDaemonOwnerConflictEvent { @@ -422,6 +425,40 @@ pub struct MacosDaemonOwnerConflictEvent { pub observed_at_ms: u64, } +/// Durable phase of a macOS daemon-owner handover. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosDaemonHandoverPhaseEvent { + Prepared, + AutostartsConfigured, + StopRequested, + OutgoingOwnerStopped, + AwaitingGuardRelease, + GuardReleased, + StartRequested, + RequestedOwnerStarted, + CommitPending, + Committed, + RollbackPending, + RollbackAutostartsRestored, + RollbackStopRequested, + RollbackOwnerStopped, + RollbackAwaitingGuardRelease, + RollbackGuardReleased, + RollbackStartRequested, + PriorOwnerStarted, + RollbackCommitPending, + RolledBack, +} + +/// Path-free recovery status for a daemon that cannot complete the journal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MacosDaemonOwnerRecoveryRequiredEvent { + pub requested_owner: MacosDaemonOwnerEvent, + pub prior_owner: MacosDaemonOwnerEvent, + pub phase: MacosDaemonHandoverPhaseEvent, +} + /// Per-stage frame timing in microseconds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrameTiming { @@ -962,6 +999,7 @@ pub enum HypercolorEvent { active_owner: MacosDaemonOwnerEvent, owner_epoch: u64, conflict: Option, + recovery_required: Option, }, /// Global brightness changed. diff --git a/crates/hypercolor-types/tests/event_tests.rs b/crates/hypercolor-types/tests/event_tests.rs index ec0d26074..006892afd 100644 --- a/crates/hypercolor-types/tests/event_tests.rs +++ b/crates/hypercolor-types/tests/event_tests.rs @@ -8,7 +8,8 @@ use hypercolor_types::event::{ AssetChangeKind, ChangeTrigger, ContextType, DisconnectReason, EffectDegradationState, EffectRef, EffectStopReason, EventCategory, EventControlValue, EventPriority, FrameData, FrameTiming, HypercolorEvent, InputButtonState, InputEvent, LayerHealth, LayerStackChangeKind, - MacosDaemonOwnerConflictEvent, MacosDaemonOwnerEvent, PointerScrollPhase, PointerScrollUnit, + MacosDaemonHandoverPhaseEvent, MacosDaemonOwnerConflictEvent, MacosDaemonOwnerEvent, + MacosDaemonOwnerRecoveryRequiredEvent, PointerScrollPhase, PointerScrollUnit, SceneChangeReason, Severity, TimedInputEvent, TransitionRef, ZoneChangeKind, ZoneColors, ZoneRef, }; @@ -311,6 +312,7 @@ fn system_events_have_system_category() { active_owner: MacosDaemonOwnerEvent::AppSidecar, owner_epoch: 7, conflict: None, + recovery_required: None, }, HypercolorEvent::BrightnessChanged { old: 100, @@ -345,6 +347,11 @@ fn macos_daemon_ownership_event_round_trips_bounded_payload() { contender: MacosDaemonOwnerEvent::HomebrewService, observed_at_ms: 1_777, }), + recovery_required: Some(MacosDaemonOwnerRecoveryRequiredEvent { + requested_owner: MacosDaemonOwnerEvent::AppSidecar, + prior_owner: MacosDaemonOwnerEvent::LaunchdService, + phase: MacosDaemonHandoverPhaseEvent::RollbackStartRequested, + }), }; let json = serde_json::to_value(&event).expect("serialize ownership event"); @@ -352,6 +359,10 @@ fn macos_daemon_ownership_event_round_trips_bounded_payload() { assert_eq!(json["data"]["active_owner"], "launchd_service"); assert_eq!(json["data"]["owner_epoch"], 42); assert_eq!(json["data"]["conflict"]["contender"], "homebrew_service"); + assert_eq!( + json["data"]["recovery_required"]["phase"], + "rollback_start_requested" + ); assert_eq!( serde_json::from_value::(json) .expect("deserialize ownership event") diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index 320378aaf..d28f54ffa 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -85,7 +85,7 @@ def caveats end service do - run [opt_bin/"hypercolor-daemon", "--ui-dir", share/"hypercolor/ui"] + run [opt_bin/"hypercolor-daemon", "--macos-owner", "homebrew", "--ui-dir", share/"hypercolor/ui"] keep_alive successful_exit: false log_path var/"log/hypercolor/hypercolor.log" error_log_path var/"log/hypercolor/hypercolor.log" diff --git a/packaging/launchd/tech.hyperbliss.hypercolor.plist b/packaging/launchd/tech.hyperbliss.hypercolor.plist index c9acb6282..da795a84b 100644 --- a/packaging/launchd/tech.hyperbliss.hypercolor.plist +++ b/packaging/launchd/tech.hyperbliss.hypercolor.plist @@ -9,6 +9,8 @@ ProgramArguments @BIN_DIR@/hypercolor-daemon + --macos-owner + direct-launchd --ui-dir @UI_DIR@ diff --git a/protocol/websocket-v1.json b/protocol/websocket-v1.json index 4186179ef..380d8a891 100644 --- a/protocol/websocket-v1.json +++ b/protocol/websocket-v1.json @@ -219,7 +219,8 @@ "owner_epoch" ], "optional_fields": { - "conflict": null + "conflict": null, + "recovery_required": null }, "description": "Authoritative macOS daemon topology snapshot. The event reports ownership state only and cannot request an owner change." } diff --git a/python/src/hypercolor/_generated/models/__init__.py b/python/src/hypercolor/_generated/models/__init__.py index cf8fd5f8b..27f86483b 100644 --- a/python/src/hypercolor/_generated/models/__init__.py +++ b/python/src/hypercolor/_generated/models/__init__.py @@ -244,6 +244,10 @@ from .health_response import HealthResponse from .identify_request import IdentifyRequest from .input_source_issue_status import InputSourceIssueStatus +from .input_source_platform_status_type_0 import InputSourcePlatformStatusType0 +from .input_source_platform_status_type_0_type import InputSourcePlatformStatusType0Type +from .input_source_platform_status_type_1 import InputSourcePlatformStatusType1 +from .input_source_platform_status_type_1_type import InputSourcePlatformStatusType1Type from .input_source_status import InputSourceStatus from .input_status import InputStatus from .invoke_control_action_request import InvokeControlActionRequest @@ -266,6 +270,24 @@ from .led_topology_type_5_type import LedTopologyType5Type from .led_topology_type_6 import LedTopologyType6 from .led_topology_type_6_type import LedTopologyType6Type +from .macos_authorization_state_api import MacosAuthorizationStateApi +from .macos_capability_owner_api import MacosCapabilityOwnerApi +from .macos_daemon_handover_phase_api import MacosDaemonHandoverPhaseApi +from .macos_daemon_owner_conflict_api_status import MacosDaemonOwnerConflictApiStatus +from .macos_daemon_owner_recovery_required_api_status import ( + MacosDaemonOwnerRecoveryRequiredApiStatus, +) +from .macos_daemon_ownership_api_status import MacosDaemonOwnershipApiStatus +from .macos_protected_source_state_api import MacosProtectedSourceStateApi +from .macos_selection_state_api_type_0 import MacosSelectionStateApiType0 +from .macos_selection_state_api_type_0_type import MacosSelectionStateApiType0Type +from .macos_selection_state_api_type_1 import MacosSelectionStateApiType1 +from .macos_selection_state_api_type_1_type import MacosSelectionStateApiType1Type +from .macos_selection_state_api_type_2 import MacosSelectionStateApiType2 +from .macos_selection_state_api_type_2_type import MacosSelectionStateApiType2Type +from .macos_tahoe_selection_capabilities_api_status import ( + MacosTahoeSelectionCapabilitiesApiStatus, +) from .meta import Meta from .normalized_position import NormalizedPosition from .normalized_rect import NormalizedRect @@ -553,6 +575,10 @@ "HealthResponse", "IdentifyRequest", "InputSourceIssueStatus", + "InputSourcePlatformStatusType0", + "InputSourcePlatformStatusType0Type", + "InputSourcePlatformStatusType1", + "InputSourcePlatformStatusType1Type", "InputSourceStatus", "InputStatus", "InvokeControlActionRequest", @@ -575,6 +601,20 @@ "LedTopologyType5Type", "LedTopologyType6", "LedTopologyType6Type", + "MacosAuthorizationStateApi", + "MacosCapabilityOwnerApi", + "MacosDaemonHandoverPhaseApi", + "MacosDaemonOwnerConflictApiStatus", + "MacosDaemonOwnerRecoveryRequiredApiStatus", + "MacosDaemonOwnershipApiStatus", + "MacosProtectedSourceStateApi", + "MacosSelectionStateApiType0", + "MacosSelectionStateApiType0Type", + "MacosSelectionStateApiType1", + "MacosSelectionStateApiType1Type", + "MacosSelectionStateApiType2", + "MacosSelectionStateApiType2Type", + "MacosTahoeSelectionCapabilitiesApiStatus", "Meta", "NormalizedPosition", "NormalizedRect", diff --git a/python/src/hypercolor/_generated/models/api_response_system_status_data.py b/python/src/hypercolor/_generated/models/api_response_system_status_data.py index 1aecc8155..d1eeed64f 100644 --- a/python/src/hypercolor/_generated/models/api_response_system_status_data.py +++ b/python/src/hypercolor/_generated/models/api_response_system_status_data.py @@ -12,6 +12,7 @@ from ..models.effect_health_status import EffectHealthStatus from ..models.input_status import InputStatus from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import MacosDaemonOwnershipApiStatus from ..models.preview_runtime_status import PreviewRuntimeStatus from ..models.render_acceleration_status import RenderAccelerationStatus from ..models.render_loop_status import RenderLoopStatus @@ -62,6 +63,7 @@ class ApiResponseSystemStatusData: active_effect (None | str | Unset): active_scene (None | str | Unset): latest_frame (LatestFrameStatus | None | Unset): + macos_daemon_ownership (MacosDaemonOwnershipApiStatus | None | Unset): """ active_scene_snapshot_locked: bool @@ -89,10 +91,14 @@ class ApiResponseSystemStatusData: active_effect: None | str | Unset = UNSET active_scene: None | str | Unset = UNSET latest_frame: LatestFrameStatus | None | Unset = UNSET + macos_daemon_ownership: MacosDaemonOwnershipApiStatus | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import ( + MacosDaemonOwnershipApiStatus, + ) active_scene_snapshot_locked = self.active_scene_snapshot_locked @@ -158,6 +164,14 @@ def to_dict(self) -> dict[str, Any]: else: latest_frame = self.latest_frame + macos_daemon_ownership: dict[str, Any] | None | Unset + if isinstance(self.macos_daemon_ownership, Unset): + macos_daemon_ownership = UNSET + elif isinstance(self.macos_daemon_ownership, MacosDaemonOwnershipApiStatus): + macos_daemon_ownership = self.macos_daemon_ownership.to_dict() + else: + macos_daemon_ownership = self.macos_daemon_ownership + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -192,6 +206,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["active_scene"] = active_scene if latest_frame is not UNSET: field_dict["latest_frame"] = latest_frame + if macos_daemon_ownership is not UNSET: + field_dict["macos_daemon_ownership"] = macos_daemon_ownership return field_dict @@ -200,6 +216,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.effect_health_status import EffectHealthStatus from ..models.input_status import InputStatus from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import ( + MacosDaemonOwnershipApiStatus, + ) from ..models.preview_runtime_status import PreviewRuntimeStatus from ..models.render_acceleration_status import RenderAccelerationStatus from ..models.render_loop_status import RenderLoopStatus @@ -290,6 +309,29 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: latest_frame = _parse_latest_frame(d.pop("latest_frame", UNSET)) + def _parse_macos_daemon_ownership( + data: object, + ) -> MacosDaemonOwnershipApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + macos_daemon_ownership_type_1 = MacosDaemonOwnershipApiStatus.from_dict( + data + ) + + return macos_daemon_ownership_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnershipApiStatus | None | Unset, data) + + macos_daemon_ownership = _parse_macos_daemon_ownership( + d.pop("macos_daemon_ownership", UNSET) + ) + api_response_system_status_data = cls( active_scene_snapshot_locked=active_scene_snapshot_locked, audio_available=audio_available, @@ -316,6 +358,7 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: active_effect=active_effect, active_scene=active_scene, latest_frame=latest_frame, + macos_daemon_ownership=macos_daemon_ownership, ) api_response_system_status_data.additional_properties = d diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py new file mode 100644 index 000000000..9d9428be6 --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.input_source_platform_status_type_0_type import ( + InputSourcePlatformStatusType0Type, +) +from ..models.macos_authorization_state_api import MacosAuthorizationStateApi +from ..models.macos_capability_owner_api import MacosCapabilityOwnerApi +from ..models.macos_protected_source_state_api import MacosProtectedSourceStateApi +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + + +T = TypeVar("T", bound="InputSourcePlatformStatusType0") + + +@_attrs_define +class InputSourcePlatformStatusType0: + """ + Attributes: + keyboard (MacosProtectedSourceStateApi): + keyboard_owner (MacosCapabilityOwnerApi): + keyboard_tcc (MacosAuthorizationStateApi): + pointer (MacosProtectedSourceStateApi): + pointer_owner (MacosCapabilityOwnerApi): + type_ (InputSourcePlatformStatusType0Type): + owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): + """ + + keyboard: MacosProtectedSourceStateApi + keyboard_owner: MacosCapabilityOwnerApi + keyboard_tcc: MacosAuthorizationStateApi + pointer: MacosProtectedSourceStateApi + pointer_owner: MacosCapabilityOwnerApi + type_: InputSourcePlatformStatusType0Type + owner_conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + + keyboard = self.keyboard.value + + keyboard_owner = self.keyboard_owner.value + + keyboard_tcc = self.keyboard_tcc.value + + pointer = self.pointer.value + + pointer_owner = self.pointer_owner.value + + type_ = self.type_.value + + owner_conflict: dict[str, Any] | None | Unset + if isinstance(self.owner_conflict, Unset): + owner_conflict = UNSET + elif isinstance(self.owner_conflict, MacosDaemonOwnerConflictApiStatus): + owner_conflict = self.owner_conflict.to_dict() + else: + owner_conflict = self.owner_conflict + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "keyboard": keyboard, + "keyboard_owner": keyboard_owner, + "keyboard_tcc": keyboard_tcc, + "pointer": pointer, + "pointer_owner": pointer_owner, + "type": type_, + } + ) + if owner_conflict is not UNSET: + field_dict["owner_conflict"] = owner_conflict + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + + d = dict(src_dict) + keyboard = MacosProtectedSourceStateApi(d.pop("keyboard")) + + keyboard_owner = MacosCapabilityOwnerApi(d.pop("keyboard_owner")) + + keyboard_tcc = MacosAuthorizationStateApi(d.pop("keyboard_tcc")) + + pointer = MacosProtectedSourceStateApi(d.pop("pointer")) + + pointer_owner = MacosCapabilityOwnerApi(d.pop("pointer_owner")) + + type_ = InputSourcePlatformStatusType0Type(d.pop("type")) + + def _parse_owner_conflict( + data: object, + ) -> MacosDaemonOwnerConflictApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + owner_conflict_type_1 = MacosDaemonOwnerConflictApiStatus.from_dict( + data + ) + + return owner_conflict_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnerConflictApiStatus | None | Unset, data) + + owner_conflict = _parse_owner_conflict(d.pop("owner_conflict", UNSET)) + + input_source_platform_status_type_0 = cls( + keyboard=keyboard, + keyboard_owner=keyboard_owner, + keyboard_tcc=keyboard_tcc, + pointer=pointer, + pointer_owner=pointer_owner, + type_=type_, + owner_conflict=owner_conflict, + ) + + input_source_platform_status_type_0.additional_properties = d + return input_source_platform_status_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_0_type.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0_type.py new file mode 100644 index 000000000..ca63dea48 --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class InputSourcePlatformStatusType0Type(str, Enum): + MACOS_INPUT = "macos_input" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py new file mode 100644 index 000000000..2ef96af2f --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.input_source_platform_status_type_1_type import ( + InputSourcePlatformStatusType1Type, +) +from ..models.macos_authorization_state_api import MacosAuthorizationStateApi +from ..models.macos_capability_owner_api import MacosCapabilityOwnerApi +from ..models.macos_protected_source_state_api import MacosProtectedSourceStateApi +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_selection_state_api_type_0 import MacosSelectionStateApiType0 + from ..models.macos_selection_state_api_type_1 import MacosSelectionStateApiType1 + from ..models.macos_selection_state_api_type_2 import MacosSelectionStateApiType2 + from ..models.macos_tahoe_selection_capabilities_api_status import ( + MacosTahoeSelectionCapabilitiesApiStatus, + ) + + +T = TypeVar("T", bound="InputSourcePlatformStatusType1") + + +@_attrs_define +class InputSourcePlatformStatusType1: + """ + Attributes: + owner (MacosCapabilityOwnerApi): + selection (MacosSelectionStateApiType0 | MacosSelectionStateApiType1 | MacosSelectionStateApiType2): + state (MacosProtectedSourceStateApi): + tcc (MacosAuthorizationStateApi): + type_ (InputSourcePlatformStatusType1Type): + owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): + tahoe_selection (MacosTahoeSelectionCapabilitiesApiStatus | None | Unset): + """ + + owner: MacosCapabilityOwnerApi + selection: ( + MacosSelectionStateApiType0 + | MacosSelectionStateApiType1 + | MacosSelectionStateApiType2 + ) + state: MacosProtectedSourceStateApi + tcc: MacosAuthorizationStateApi + type_: InputSourcePlatformStatusType1Type + owner_conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET + tahoe_selection: MacosTahoeSelectionCapabilitiesApiStatus | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_selection_state_api_type_0 import ( + MacosSelectionStateApiType0, + ) + from ..models.macos_selection_state_api_type_1 import ( + MacosSelectionStateApiType1, + ) + from ..models.macos_tahoe_selection_capabilities_api_status import ( + MacosTahoeSelectionCapabilitiesApiStatus, + ) + + owner = self.owner.value + + selection: dict[str, Any] + if isinstance(self.selection, MacosSelectionStateApiType0): + selection = self.selection.to_dict() + elif isinstance(self.selection, MacosSelectionStateApiType1): + selection = self.selection.to_dict() + else: + selection = self.selection.to_dict() + + state = self.state.value + + tcc = self.tcc.value + + type_ = self.type_.value + + owner_conflict: dict[str, Any] | None | Unset + if isinstance(self.owner_conflict, Unset): + owner_conflict = UNSET + elif isinstance(self.owner_conflict, MacosDaemonOwnerConflictApiStatus): + owner_conflict = self.owner_conflict.to_dict() + else: + owner_conflict = self.owner_conflict + + tahoe_selection: dict[str, Any] | None | Unset + if isinstance(self.tahoe_selection, Unset): + tahoe_selection = UNSET + elif isinstance(self.tahoe_selection, MacosTahoeSelectionCapabilitiesApiStatus): + tahoe_selection = self.tahoe_selection.to_dict() + else: + tahoe_selection = self.tahoe_selection + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "owner": owner, + "selection": selection, + "state": state, + "tcc": tcc, + "type": type_, + } + ) + if owner_conflict is not UNSET: + field_dict["owner_conflict"] = owner_conflict + if tahoe_selection is not UNSET: + field_dict["tahoe_selection"] = tahoe_selection + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_selection_state_api_type_0 import ( + MacosSelectionStateApiType0, + ) + from ..models.macos_selection_state_api_type_1 import ( + MacosSelectionStateApiType1, + ) + from ..models.macos_selection_state_api_type_2 import ( + MacosSelectionStateApiType2, + ) + from ..models.macos_tahoe_selection_capabilities_api_status import ( + MacosTahoeSelectionCapabilitiesApiStatus, + ) + + d = dict(src_dict) + owner = MacosCapabilityOwnerApi(d.pop("owner")) + + def _parse_selection( + data: object, + ) -> ( + MacosSelectionStateApiType0 + | MacosSelectionStateApiType1 + | MacosSelectionStateApiType2 + ): + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_macos_selection_state_api_type_0 = ( + MacosSelectionStateApiType0.from_dict(data) + ) + + return componentsschemas_macos_selection_state_api_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_macos_selection_state_api_type_1 = ( + MacosSelectionStateApiType1.from_dict(data) + ) + + return componentsschemas_macos_selection_state_api_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + componentsschemas_macos_selection_state_api_type_2 = ( + MacosSelectionStateApiType2.from_dict(data) + ) + + return componentsschemas_macos_selection_state_api_type_2 + + selection = _parse_selection(d.pop("selection")) + + state = MacosProtectedSourceStateApi(d.pop("state")) + + tcc = MacosAuthorizationStateApi(d.pop("tcc")) + + type_ = InputSourcePlatformStatusType1Type(d.pop("type")) + + def _parse_owner_conflict( + data: object, + ) -> MacosDaemonOwnerConflictApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + owner_conflict_type_1 = MacosDaemonOwnerConflictApiStatus.from_dict( + data + ) + + return owner_conflict_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnerConflictApiStatus | None | Unset, data) + + owner_conflict = _parse_owner_conflict(d.pop("owner_conflict", UNSET)) + + def _parse_tahoe_selection( + data: object, + ) -> MacosTahoeSelectionCapabilitiesApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + tahoe_selection_type_1 = ( + MacosTahoeSelectionCapabilitiesApiStatus.from_dict(data) + ) + + return tahoe_selection_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosTahoeSelectionCapabilitiesApiStatus | None | Unset, data) + + tahoe_selection = _parse_tahoe_selection(d.pop("tahoe_selection", UNSET)) + + input_source_platform_status_type_1 = cls( + owner=owner, + selection=selection, + state=state, + tcc=tcc, + type_=type_, + owner_conflict=owner_conflict, + tahoe_selection=tahoe_selection, + ) + + input_source_platform_status_type_1.additional_properties = d + return input_source_platform_status_type_1 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1_type.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1_type.py new file mode 100644 index 000000000..fb6fe9752 --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class InputSourcePlatformStatusType1Type(str, Enum): + MACOS_SCREEN = "macos_screen" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/input_source_status.py b/python/src/hypercolor/_generated/models/input_source_status.py index 3267c704e..150015fc4 100644 --- a/python/src/hypercolor/_generated/models/input_source_status.py +++ b/python/src/hypercolor/_generated/models/input_source_status.py @@ -10,6 +10,12 @@ if TYPE_CHECKING: from ..models.input_source_issue_status import InputSourceIssueStatus + from ..models.input_source_platform_status_type_0 import ( + InputSourcePlatformStatusType0, + ) + from ..models.input_source_platform_status_type_1 import ( + InputSourcePlatformStatusType1, + ) T = TypeVar("T", bound="InputSourceStatus") @@ -38,6 +44,7 @@ class InputSourceStatus: issue (InputSourceIssueStatus | None | Unset): last_sample_age_ms (int | None | Unset): lifecycle_issue (InputSourceIssueStatus | None | Unset): + platform (InputSourcePlatformStatusType0 | InputSourcePlatformStatusType1 | None | Unset): """ backend: str @@ -58,10 +65,19 @@ class InputSourceStatus: issue: InputSourceIssueStatus | None | Unset = UNSET last_sample_age_ms: int | None | Unset = UNSET lifecycle_issue: InputSourceIssueStatus | None | Unset = UNSET + platform: ( + InputSourcePlatformStatusType0 | InputSourcePlatformStatusType1 | None | Unset + ) = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: from ..models.input_source_issue_status import InputSourceIssueStatus + from ..models.input_source_platform_status_type_0 import ( + InputSourcePlatformStatusType0, + ) + from ..models.input_source_platform_status_type_1 import ( + InputSourcePlatformStatusType1, + ) backend = self.backend @@ -125,6 +141,16 @@ def to_dict(self) -> dict[str, Any]: else: lifecycle_issue = self.lifecycle_issue + platform: dict[str, Any] | None | Unset + if isinstance(self.platform, Unset): + platform = UNSET + elif isinstance(self.platform, InputSourcePlatformStatusType0): + platform = self.platform.to_dict() + elif isinstance(self.platform, InputSourcePlatformStatusType1): + platform = self.platform.to_dict() + else: + platform = self.platform + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -154,12 +180,20 @@ def to_dict(self) -> dict[str, Any]: field_dict["last_sample_age_ms"] = last_sample_age_ms if lifecycle_issue is not UNSET: field_dict["lifecycle_issue"] = lifecycle_issue + if platform is not UNSET: + field_dict["platform"] = platform return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.input_source_issue_status import InputSourceIssueStatus + from ..models.input_source_platform_status_type_0 import ( + InputSourcePlatformStatusType0, + ) + from ..models.input_source_platform_status_type_1 import ( + InputSourcePlatformStatusType1, + ) d = dict(src_dict) backend = d.pop("backend") @@ -265,6 +299,48 @@ def _parse_lifecycle_issue( lifecycle_issue = _parse_lifecycle_issue(d.pop("lifecycle_issue", UNSET)) + def _parse_platform( + data: object, + ) -> ( + InputSourcePlatformStatusType0 + | InputSourcePlatformStatusType1 + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_input_source_platform_status_type_0 = ( + InputSourcePlatformStatusType0.from_dict(data) + ) + + return componentsschemas_input_source_platform_status_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_input_source_platform_status_type_1 = ( + InputSourcePlatformStatusType1.from_dict(data) + ) + + return componentsschemas_input_source_platform_status_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + InputSourcePlatformStatusType0 + | InputSourcePlatformStatusType1 + | None + | Unset, + data, + ) + + platform = _parse_platform(d.pop("platform", UNSET)) + input_source_status = cls( backend=backend, configured=configured, @@ -284,6 +360,7 @@ def _parse_lifecycle_issue( issue=issue, last_sample_age_ms=last_sample_age_ms, lifecycle_issue=lifecycle_issue, + platform=platform, ) input_source_status.additional_properties = d diff --git a/python/src/hypercolor/_generated/models/macos_authorization_state_api.py b/python/src/hypercolor/_generated/models/macos_authorization_state_api.py new file mode 100644 index 000000000..ab6ec8528 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_authorization_state_api.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class MacosAuthorizationStateApi(str, Enum): + AUTHORIZED = "authorized" + DENIED = "denied" + NOT_DETERMINED = "not_determined" + UNKNOWN = "unknown" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_capability_owner_api.py b/python/src/hypercolor/_generated/models/macos_capability_owner_api.py new file mode 100644 index 000000000..60b9a0464 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_capability_owner_api.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class MacosCapabilityOwnerApi(str, Enum): + APP = "app" + APP_SIDECAR = "app_sidecar" + BROKER = "broker" + HOMEBREW_SERVICE = "homebrew_service" + LAUNCHD_SERVICE = "launchd_service" + STANDALONE = "standalone" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_daemon_handover_phase_api.py b/python/src/hypercolor/_generated/models/macos_daemon_handover_phase_api.py new file mode 100644 index 000000000..f331925cb --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_daemon_handover_phase_api.py @@ -0,0 +1,27 @@ +from enum import Enum + + +class MacosDaemonHandoverPhaseApi(str, Enum): + AUTOSTARTS_CONFIGURED = "autostarts_configured" + AWAITING_GUARD_RELEASE = "awaiting_guard_release" + COMMITTED = "committed" + COMMIT_PENDING = "commit_pending" + GUARD_RELEASED = "guard_released" + OUTGOING_OWNER_STOPPED = "outgoing_owner_stopped" + PREPARED = "prepared" + PRIOR_OWNER_STARTED = "prior_owner_started" + REQUESTED_OWNER_STARTED = "requested_owner_started" + ROLLBACK_AUTOSTARTS_RESTORED = "rollback_autostarts_restored" + ROLLBACK_AWAITING_GUARD_RELEASE = "rollback_awaiting_guard_release" + ROLLBACK_COMMIT_PENDING = "rollback_commit_pending" + ROLLBACK_GUARD_RELEASED = "rollback_guard_released" + ROLLBACK_OWNER_STOPPED = "rollback_owner_stopped" + ROLLBACK_PENDING = "rollback_pending" + ROLLBACK_START_REQUESTED = "rollback_start_requested" + ROLLBACK_STOP_REQUESTED = "rollback_stop_requested" + ROLLED_BACK = "rolled_back" + START_REQUESTED = "start_requested" + STOP_REQUESTED = "stop_requested" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_daemon_owner_conflict_api_status.py b/python/src/hypercolor/_generated/models/macos_daemon_owner_conflict_api_status.py new file mode 100644 index 000000000..9f4d63d3d --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_daemon_owner_conflict_api_status.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_capability_owner_api import MacosCapabilityOwnerApi + +T = TypeVar("T", bound="MacosDaemonOwnerConflictApiStatus") + + +@_attrs_define +class MacosDaemonOwnerConflictApiStatus: + """ + Attributes: + active (MacosCapabilityOwnerApi): + contender (MacosCapabilityOwnerApi): + observed_at_ms (int): + """ + + active: MacosCapabilityOwnerApi + contender: MacosCapabilityOwnerApi + observed_at_ms: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + active = self.active.value + + contender = self.contender.value + + observed_at_ms = self.observed_at_ms + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "active": active, + "contender": contender, + "observed_at_ms": observed_at_ms, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + active = MacosCapabilityOwnerApi(d.pop("active")) + + contender = MacosCapabilityOwnerApi(d.pop("contender")) + + observed_at_ms = d.pop("observed_at_ms") + + macos_daemon_owner_conflict_api_status = cls( + active=active, + contender=contender, + observed_at_ms=observed_at_ms, + ) + + macos_daemon_owner_conflict_api_status.additional_properties = d + return macos_daemon_owner_conflict_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_daemon_owner_recovery_required_api_status.py b/python/src/hypercolor/_generated/models/macos_daemon_owner_recovery_required_api_status.py new file mode 100644 index 000000000..e8727376c --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_daemon_owner_recovery_required_api_status.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_capability_owner_api import MacosCapabilityOwnerApi +from ..models.macos_daemon_handover_phase_api import MacosDaemonHandoverPhaseApi + +T = TypeVar("T", bound="MacosDaemonOwnerRecoveryRequiredApiStatus") + + +@_attrs_define +class MacosDaemonOwnerRecoveryRequiredApiStatus: + """ + Attributes: + phase (MacosDaemonHandoverPhaseApi): + prior_owner (MacosCapabilityOwnerApi): + requested_owner (MacosCapabilityOwnerApi): + """ + + phase: MacosDaemonHandoverPhaseApi + prior_owner: MacosCapabilityOwnerApi + requested_owner: MacosCapabilityOwnerApi + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + phase = self.phase.value + + prior_owner = self.prior_owner.value + + requested_owner = self.requested_owner.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "phase": phase, + "prior_owner": prior_owner, + "requested_owner": requested_owner, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + phase = MacosDaemonHandoverPhaseApi(d.pop("phase")) + + prior_owner = MacosCapabilityOwnerApi(d.pop("prior_owner")) + + requested_owner = MacosCapabilityOwnerApi(d.pop("requested_owner")) + + macos_daemon_owner_recovery_required_api_status = cls( + phase=phase, + prior_owner=prior_owner, + requested_owner=requested_owner, + ) + + macos_daemon_owner_recovery_required_api_status.additional_properties = d + return macos_daemon_owner_recovery_required_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_daemon_ownership_api_status.py b/python/src/hypercolor/_generated/models/macos_daemon_ownership_api_status.py new file mode 100644 index 000000000..4484b4ad2 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_daemon_ownership_api_status.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_capability_owner_api import MacosCapabilityOwnerApi +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_daemon_owner_recovery_required_api_status import ( + MacosDaemonOwnerRecoveryRequiredApiStatus, + ) + + +T = TypeVar("T", bound="MacosDaemonOwnershipApiStatus") + + +@_attrs_define +class MacosDaemonOwnershipApiStatus: + """ + Attributes: + active_owner (MacosCapabilityOwnerApi): + owner_epoch (int): + conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): + recovery_required (MacosDaemonOwnerRecoveryRequiredApiStatus | None | Unset): + """ + + active_owner: MacosCapabilityOwnerApi + owner_epoch: int + conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET + recovery_required: MacosDaemonOwnerRecoveryRequiredApiStatus | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_daemon_owner_recovery_required_api_status import ( + MacosDaemonOwnerRecoveryRequiredApiStatus, + ) + + active_owner = self.active_owner.value + + owner_epoch = self.owner_epoch + + conflict: dict[str, Any] | None | Unset + if isinstance(self.conflict, Unset): + conflict = UNSET + elif isinstance(self.conflict, MacosDaemonOwnerConflictApiStatus): + conflict = self.conflict.to_dict() + else: + conflict = self.conflict + + recovery_required: dict[str, Any] | None | Unset + if isinstance(self.recovery_required, Unset): + recovery_required = UNSET + elif isinstance( + self.recovery_required, MacosDaemonOwnerRecoveryRequiredApiStatus + ): + recovery_required = self.recovery_required.to_dict() + else: + recovery_required = self.recovery_required + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "active_owner": active_owner, + "owner_epoch": owner_epoch, + } + ) + if conflict is not UNSET: + field_dict["conflict"] = conflict + if recovery_required is not UNSET: + field_dict["recovery_required"] = recovery_required + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.macos_daemon_owner_conflict_api_status import ( + MacosDaemonOwnerConflictApiStatus, + ) + from ..models.macos_daemon_owner_recovery_required_api_status import ( + MacosDaemonOwnerRecoveryRequiredApiStatus, + ) + + d = dict(src_dict) + active_owner = MacosCapabilityOwnerApi(d.pop("active_owner")) + + owner_epoch = d.pop("owner_epoch") + + def _parse_conflict( + data: object, + ) -> MacosDaemonOwnerConflictApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + conflict_type_1 = MacosDaemonOwnerConflictApiStatus.from_dict(data) + + return conflict_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnerConflictApiStatus | None | Unset, data) + + conflict = _parse_conflict(d.pop("conflict", UNSET)) + + def _parse_recovery_required( + data: object, + ) -> MacosDaemonOwnerRecoveryRequiredApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + recovery_required_type_1 = ( + MacosDaemonOwnerRecoveryRequiredApiStatus.from_dict(data) + ) + + return recovery_required_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnerRecoveryRequiredApiStatus | None | Unset, data) + + recovery_required = _parse_recovery_required(d.pop("recovery_required", UNSET)) + + macos_daemon_ownership_api_status = cls( + active_owner=active_owner, + owner_epoch=owner_epoch, + conflict=conflict, + recovery_required=recovery_required, + ) + + macos_daemon_ownership_api_status.additional_properties = d + return macos_daemon_ownership_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_protected_source_state_api.py b/python/src/hypercolor/_generated/models/macos_protected_source_state_api.py new file mode 100644 index 000000000..099824142 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_protected_source_state_api.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class MacosProtectedSourceStateApi(str, Enum): + DISABLED = "disabled" + FAILED = "failed" + INTERRUPTED = "interrupted" + LIVE = "live" + NEEDS_PROCESS_RESTART = "needs_process_restart" + NEEDS_SELECTION = "needs_selection" + NEEDS_USER_ACTION = "needs_user_action" + PERMISSION_DENIED = "permission_denied" + READY_IDLE = "ready_idle" + REVOKED = "revoked" + STARTING = "starting" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0.py new file mode 100644 index 000000000..52803ecfd --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_selection_state_api_type_0_type import ( + MacosSelectionStateApiType0Type, +) + +T = TypeVar("T", bound="MacosSelectionStateApiType0") + + +@_attrs_define +class MacosSelectionStateApiType0: + """ + Attributes: + type_ (MacosSelectionStateApiType0Type): + """ + + type_: MacosSelectionStateApiType0Type + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = MacosSelectionStateApiType0Type(d.pop("type")) + + macos_selection_state_api_type_0 = cls( + type_=type_, + ) + + macos_selection_state_api_type_0.additional_properties = d + return macos_selection_state_api_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0_type.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0_type.py new file mode 100644 index 000000000..50ed7e3d7 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_0_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class MacosSelectionStateApiType0Type(str, Enum): + NONE = "none" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1.py new file mode 100644 index 000000000..a745f8af6 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_selection_state_api_type_1_type import ( + MacosSelectionStateApiType1Type, +) + +T = TypeVar("T", bound="MacosSelectionStateApiType1") + + +@_attrs_define +class MacosSelectionStateApiType1: + """ + Attributes: + source_id (str): + type_ (MacosSelectionStateApiType1Type): + """ + + source_id: str + type_: MacosSelectionStateApiType1Type + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_id = self.source_id + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_id": source_id, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source_id = d.pop("source_id") + + type_ = MacosSelectionStateApiType1Type(d.pop("type")) + + macos_selection_state_api_type_1 = cls( + source_id=source_id, + type_=type_, + ) + + macos_selection_state_api_type_1.additional_properties = d + return macos_selection_state_api_type_1 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1_type.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1_type.py new file mode 100644 index 000000000..508d74529 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_1_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class MacosSelectionStateApiType1Type(str, Enum): + DISPLAY = "display" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2.py new file mode 100644 index 000000000..921d6870a --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_selection_state_api_type_2_type import ( + MacosSelectionStateApiType2Type, +) + +T = TypeVar("T", bound="MacosSelectionStateApiType2") + + +@_attrs_define +class MacosSelectionStateApiType2: + """ + Attributes: + content_style (str): + type_ (MacosSelectionStateApiType2Type): + """ + + content_style: str + type_: MacosSelectionStateApiType2Type + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + content_style = self.content_style + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "content_style": content_style, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + content_style = d.pop("content_style") + + type_ = MacosSelectionStateApiType2Type(d.pop("type")) + + macos_selection_state_api_type_2 = cls( + content_style=content_style, + type_=type_, + ) + + macos_selection_state_api_type_2.additional_properties = d + return macos_selection_state_api_type_2 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2_type.py b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2_type.py new file mode 100644 index 000000000..51b7f3ca9 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_selection_state_api_type_2_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class MacosSelectionStateApiType2Type(str, Enum): + SESSION_SCOPED = "session_scoped" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_tahoe_selection_capabilities_api_status.py b/python/src/hypercolor/_generated/models/macos_tahoe_selection_capabilities_api_status.py new file mode 100644 index 000000000..59dccdda5 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_tahoe_selection_capabilities_api_status.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="MacosTahoeSelectionCapabilitiesApiStatus") + + +@_attrs_define +class MacosTahoeSelectionCapabilitiesApiStatus: + """ + Attributes: + capture_session_generation (int): + dual_range_screenshots (bool): + hdr_capture (bool): + source_id (str): + """ + + capture_session_generation: int + dual_range_screenshots: bool + hdr_capture: bool + source_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + capture_session_generation = self.capture_session_generation + + dual_range_screenshots = self.dual_range_screenshots + + hdr_capture = self.hdr_capture + + source_id = self.source_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "capture_session_generation": capture_session_generation, + "dual_range_screenshots": dual_range_screenshots, + "hdr_capture": hdr_capture, + "source_id": source_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + capture_session_generation = d.pop("capture_session_generation") + + dual_range_screenshots = d.pop("dual_range_screenshots") + + hdr_capture = d.pop("hdr_capture") + + source_id = d.pop("source_id") + + macos_tahoe_selection_capabilities_api_status = cls( + capture_session_generation=capture_session_generation, + dual_range_screenshots=dual_range_screenshots, + hdr_capture=hdr_capture, + source_id=source_id, + ) + + macos_tahoe_selection_capabilities_api_status.additional_properties = d + return macos_tahoe_selection_capabilities_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/system_status.py b/python/src/hypercolor/_generated/models/system_status.py index 40d7ff094..d3c742d5e 100644 --- a/python/src/hypercolor/_generated/models/system_status.py +++ b/python/src/hypercolor/_generated/models/system_status.py @@ -12,6 +12,7 @@ from ..models.effect_health_status import EffectHealthStatus from ..models.input_status import InputStatus from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import MacosDaemonOwnershipApiStatus from ..models.preview_runtime_status import PreviewRuntimeStatus from ..models.render_acceleration_status import RenderAccelerationStatus from ..models.render_loop_status import RenderLoopStatus @@ -62,6 +63,7 @@ class SystemStatus: active_effect (None | str | Unset): active_scene (None | str | Unset): latest_frame (LatestFrameStatus | None | Unset): + macos_daemon_ownership (MacosDaemonOwnershipApiStatus | None | Unset): """ active_scene_snapshot_locked: bool @@ -89,10 +91,14 @@ class SystemStatus: active_effect: None | str | Unset = UNSET active_scene: None | str | Unset = UNSET latest_frame: LatestFrameStatus | None | Unset = UNSET + macos_daemon_ownership: MacosDaemonOwnershipApiStatus | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import ( + MacosDaemonOwnershipApiStatus, + ) active_scene_snapshot_locked = self.active_scene_snapshot_locked @@ -158,6 +164,14 @@ def to_dict(self) -> dict[str, Any]: else: latest_frame = self.latest_frame + macos_daemon_ownership: dict[str, Any] | None | Unset + if isinstance(self.macos_daemon_ownership, Unset): + macos_daemon_ownership = UNSET + elif isinstance(self.macos_daemon_ownership, MacosDaemonOwnershipApiStatus): + macos_daemon_ownership = self.macos_daemon_ownership.to_dict() + else: + macos_daemon_ownership = self.macos_daemon_ownership + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -192,6 +206,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["active_scene"] = active_scene if latest_frame is not UNSET: field_dict["latest_frame"] = latest_frame + if macos_daemon_ownership is not UNSET: + field_dict["macos_daemon_ownership"] = macos_daemon_ownership return field_dict @@ -200,6 +216,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.effect_health_status import EffectHealthStatus from ..models.input_status import InputStatus from ..models.latest_frame_status import LatestFrameStatus + from ..models.macos_daemon_ownership_api_status import ( + MacosDaemonOwnershipApiStatus, + ) from ..models.preview_runtime_status import PreviewRuntimeStatus from ..models.render_acceleration_status import RenderAccelerationStatus from ..models.render_loop_status import RenderLoopStatus @@ -290,6 +309,29 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: latest_frame = _parse_latest_frame(d.pop("latest_frame", UNSET)) + def _parse_macos_daemon_ownership( + data: object, + ) -> MacosDaemonOwnershipApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + macos_daemon_ownership_type_1 = MacosDaemonOwnershipApiStatus.from_dict( + data + ) + + return macos_daemon_ownership_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosDaemonOwnershipApiStatus | None | Unset, data) + + macos_daemon_ownership = _parse_macos_daemon_ownership( + d.pop("macos_daemon_ownership", UNSET) + ) + system_status = cls( active_scene_snapshot_locked=active_scene_snapshot_locked, audio_available=audio_available, @@ -316,6 +358,7 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: active_effect=active_effect, active_scene=active_scene, latest_frame=latest_frame, + macos_daemon_ownership=macos_daemon_ownership, ) system_status.additional_properties = d From e707cf8512738df9e620a14195cc945a41e8d220 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:16:32 -0700 Subject: [PATCH 076/144] docs(screen): mark native macOS capture implemented Replace the stale generic ScreenCaptureKit placeholder with the implemented Spec 76 authority. Keep macOS lifecycle, publication, HDR, diagnostics, and release acceptance anchored in the platform-specific contract. Co-Authored-By: Nova (GPT-5.6) --- docs/specs/14-screen-capture.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specs/14-screen-capture.md b/docs/specs/14-screen-capture.md index 5ee21b231..715e45141 100644 --- a/docs/specs/14-screen-capture.md +++ b/docs/specs/14-screen-capture.md @@ -1641,9 +1641,10 @@ Three properties drove the design: ### macOS -Unimplemented. ScreenCaptureKit is the intended backend, and unlike Windows it -does have a consent surface (the TCC Screen Recording prompt), so it will need -a demand-driven request flow rather than a default-on config. +Implemented by the native ScreenCaptureKit source defined in +[Spec 76](76-macos-screen-capture-and-host-input.md). That authority owns the +demand-driven TCC consent flow, system picker, exact CPU and Metal publication, +HDR color processing, diagnostics, and release acceptance for macOS. ### Conditional Compilation From dcb510819f7a755bc3ee5ced9cb5137041c4bebb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:19:44 -0700 Subject: [PATCH 077/144] feat(screen): reduce native macOS capture on Metal Transform every imported macOS source format on the registered Metal device and publish owner-backed reduced targets without full-frame CPU copies. Share equal physical work, account exclusive and shared retention exactly, and keep capture admissions alive through command completion. Co-Authored-By: Nova (OpenAI Codex) --- Cargo.lock | 2 + .../src/render_thread/producer_queue.rs | 6 + .../src/render_thread/sparkleflinger/gpu.rs | 517 +++++++++- .../render_thread/sparkleflinger/gpu/tests.rs | 968 +++++++++++++++++- .../hypercolor-macos-gpu-interop/Cargo.toml | 17 +- .../hypercolor-macos-gpu-interop/src/lib.rs | 4 + .../src/native_reduction.metal | 389 +++++++ .../src/native_reduction.rs | 714 +++++++++++++ .../src/screen_capture.rs | 79 +- .../tests/screen_capture_bridge_tests.rs | 394 ++++++- 10 files changed, 3018 insertions(+), 72 deletions(-) create mode 100644 crates/hypercolor-macos-gpu-interop/src/native_reduction.metal create mode 100644 crates/hypercolor-macos-gpu-interop/src/native_reduction.rs diff --git a/Cargo.lock b/Cargo.lock index f62ae002a..19bebfae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5359,6 +5359,7 @@ dependencies = [ "objc2 0.6.4", "objc2-core-foundation", "objc2-core-video", + "objc2-foundation 0.3.2", "objc2-io-surface", "objc2-metal 0.3.2", "pollster", @@ -8320,6 +8321,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] diff --git a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs index 7849c2c79..b42ee2133 100644 --- a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs +++ b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs @@ -79,6 +79,8 @@ pub(crate) struct MacosScreenTextureLease { crate::render_thread::sparkleflinger::gpu::PreparedMacosScreenTarget, >, _target_lifetime: ScreenResourceLifetime, + _shared_target_lifetime: Option, + _capture_lifetime: ScreenResourceLifetime, } #[cfg(all(feature = "wgpu", target_os = "macos", feature = "screen-capture"))] @@ -90,12 +92,16 @@ impl MacosScreenTextureLease { crate::render_thread::sparkleflinger::gpu::PreparedMacosScreenTarget, >, target_lifetime: ScreenResourceLifetime, + shared_target_lifetime: Option, + capture_lifetime: ScreenResourceLifetime, ) -> Self { Self { _imported: imported, _capture_owner: capture_owner, _target_owner: target_owner, _target_lifetime: target_lifetime, + _shared_target_lifetime: shared_target_lifetime, + _capture_lifetime: capture_lifetime, } } } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index 377f945bc..f2d718ee5 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -41,11 +41,14 @@ use hypercolor_core::input::screen::{ }; #[cfg(all(target_os = "macos", feature = "screen-capture"))] use hypercolor_core::input::screen::{ - MacosNativeTargetManifest, PlatformGpuApi, ResolvedScreenPublicationDescriptor, - ScreenBranchPayload, ScreenBranchPublication, ScreenCaptureBackend, + CapturePixelFormat, CaptureRotation, CaptureTransferFunction, LED_TONE_MAP_ALGORITHM_REVISION, + MacosNativeTargetManifest, PlatformGpuApi, PreparedLedToneMap, ResolvedScreenColorTransform, + ResolvedScreenPublicationDescriptor, ScreenBranchPayload, ScreenBranchPublication, + ScreenCaptureBackend, ScreenColorTransformCapabilities, ScreenLetterboxFill, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativePreparationPayload, - ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenPhysicalGpuDeviceIdentity, - ScreenPublicationKind, ScreenResourceApi, + ScreenNativeRetentionQuote, ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, + ScreenPhysicalGpuDeviceIdentity, ScreenPhysicalReductionDescriptor, ScreenPlanGeneration, + ScreenPublicationKind, ScreenReductionFilter, ScreenResourceApi, ScreenSourceReflection, }; use hypercolor_core::spatial::PreparedZonePlan; use hypercolor_core::types::canvas::{ @@ -55,8 +58,10 @@ use hypercolor_core::types::canvas::{ use hypercolor_macos_capture::MacosCaptureFrame; #[cfg(all(target_os = "macos", feature = "screen-capture"))] use hypercolor_macos_gpu_interop::{ - ImportedMacosScreenFrame, MacosScreenBridge as MacosInteropScreenBridge, - MacosScreenStorageIdentity, + ImportedMacosScreenFrame, MacosNativeColorTransform, MacosNativeLetterboxFill, + MacosNativeOutputTransfer, MacosNativeReducer, MacosNativeReductionDescriptor, + MacosNativeReductionFilter, MacosNativeReductionTarget, MacosNativeTargetFormat, + MacosScreenBridge as MacosInteropScreenBridge, MacosScreenStorageIdentity, }; use hypercolor_types::scene::ZoneId; #[cfg(target_os = "windows")] @@ -376,20 +381,60 @@ struct PreparedWindowsScreenTarget { #[cfg(all(target_os = "macos", feature = "screen-capture"))] struct MacosScreenBridge { + device: wgpu::Device, interop: MacosInteropScreenBridge, + reducer: MacosNativeReducer, storage_ids: Mutex>, - lifetime: Arc<()>, + physical_targets: Mutex< + Vec<( + ScreenPlanGeneration, + ScreenPhysicalReductionDescriptor, + Weak, + )>, + >, } #[cfg(all(target_os = "macos", feature = "screen-capture"))] struct MacosScreenTargetPreparer { - bridge_lifetime: Weak<()>, + bridge: Weak, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +#[derive(Debug)] +struct PreparedMacosPhysicalTarget { + target: MacosNativeReductionTarget, + storage_id: u64, + content_sequence: Mutex>, } #[cfg(all(target_os = "macos", feature = "screen-capture"))] -#[derive(Clone)] +#[derive(Debug)] pub(crate) struct PreparedMacosScreenTarget { resource_generation: u64, + descriptor: Arc, + physical: Option>, + logical_target: Option, + logical_storage_id: Option, + logical_content_sequence: Mutex>, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +impl Clone for PreparedMacosScreenTarget { + fn clone(&self) -> Self { + Self { + resource_generation: self.resource_generation, + descriptor: Arc::clone(&self.descriptor), + physical: self.physical.clone(), + logical_target: self.logical_target.clone(), + logical_storage_id: self.logical_storage_id, + logical_content_sequence: Mutex::new( + *self + .logical_content_sequence + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ), + } + } } #[cfg(all(target_os = "macos", feature = "screen-capture"))] @@ -402,7 +447,7 @@ impl MacosScreenBridge { ) -> Result<(ImportedMacosScreenFrame, u64)> { let imported = self .interop - .import_bgra_frame(device, resource_generation, frame) + .import_frame(device, resource_generation, frame) .context("failed to import the native macOS screen publication")?; let identity = imported.storage_identity(); let mut storage_ids = self @@ -420,6 +465,82 @@ impl MacosScreenBridge { Ok((imported, storage_id)) } + fn prepare_target( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + plan_generation: ScreenPlanGeneration, + ) -> Result { + macos_native_color_transform(descriptor)?; + let physical = if macos_descriptor_requires_native_work(descriptor) { + let mut targets = self + .physical_targets + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + targets.retain(|(_, _, target)| target.strong_count() > 0); + if let Some(target) = targets.iter().find_map(|(plan, candidate, target)| { + (*plan == plan_generation && candidate == descriptor.physical()) + .then(|| target.upgrade()) + .flatten() + }) { + Some(target) + } else { + let extent = descriptor.physical().reduction_extent(); + let format = + macos_native_target_format(descriptor.physical().target_pixel_format())?; + let target = Arc::new(PreparedMacosPhysicalTarget { + target: self.reducer.create_target( + self.interop_device(), + extent.width(), + extent.height(), + format, + )?, + storage_id: next_gpu_texture_storage_id()?, + content_sequence: Mutex::new(None), + }); + targets.push(( + plan_generation, + descriptor.physical().clone(), + Arc::downgrade(&target), + )); + Some(target) + } + } else { + None + }; + let geometry = descriptor.geometry(); + let needs_materialization = physical.is_some() && !geometry.content_fills_output(); + if needs_materialization { + macos_native_letterbox_fill(descriptor)?; + } + let logical_target = if needs_materialization { + let extent = geometry.output_extent(); + Some(self.reducer.create_target( + self.interop_device(), + extent.width(), + extent.height(), + macos_native_target_format(descriptor.physical().target_pixel_format())?, + )?) + } else { + None + }; + let logical_storage_id = logical_target + .as_ref() + .map(|_| next_gpu_texture_storage_id()) + .transpose()?; + Ok(PreparedMacosScreenTarget { + resource_generation: descriptor.source().resources().resource_generation(), + descriptor: Arc::new(descriptor.clone()), + physical, + logical_target, + logical_storage_id, + logical_content_sequence: Mutex::new(None), + }) + } + + fn interop_device(&self) -> &wgpu::Device { + &self.device + } + fn clear_storage_ids(&self) { self.storage_ids .lock() @@ -446,6 +567,184 @@ fn prepared_macos_screen_target_metadata_bytes() -> Result { .context("macOS prepared target metadata accounting overflow") } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn prepared_macos_screen_target_exclusive_bytes( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result { + let mut bytes = prepared_macos_screen_target_metadata_bytes()?; + if !macos_descriptor_requires_native_work(descriptor) { + return Ok(bytes); + } + if !descriptor.geometry().content_fills_output() { + let logical_texture_bytes = + macos_target_texture_bytes(descriptor.geometry().output_extent()) + .context("macOS logical target texture accounting overflow")?; + bytes = bytes + .checked_add(logical_texture_bytes) + .context("macOS logical target accounting overflow")?; + } + Ok(bytes) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn prepared_macos_screen_target_shared_bytes( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result { + if !macos_descriptor_requires_native_work(descriptor) { + return Ok(0); + } + let physical_texture_bytes = + macos_target_texture_bytes(descriptor.physical().reduction_extent()) + .context("macOS physical target texture accounting overflow")?; + checked_macos_arc_allocation_bytes::()? + .checked_add(physical_texture_bytes) + .context("macOS physical target accounting overflow") +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn prepared_macos_screen_target_retention( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result { + Ok(ScreenNativeRetentionQuote::split( + prepared_macos_screen_target_exclusive_bytes(descriptor)?, + prepared_macos_screen_target_shared_bytes(descriptor)?, + )) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_target_texture_bytes(extent: hypercolor_core::input::screen::PixelExtent) -> Option { + u64::from(extent.width()) + .checked_mul(u64::from(extent.height())) + .and_then(|pixels| pixels.checked_mul(4)) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_descriptor_requires_native_work(descriptor: &ResolvedScreenPublicationDescriptor) -> bool { + let source = descriptor.source(); + descriptor.source_pixel_format() != CapturePixelFormat::Bgra8 + || source.geometry().crop().is_some() + || descriptor.geometry().output_extent() != source.geometry().storage_extent() + || descriptor.physical().reduction_extent() != source.geometry().storage_extent() + || descriptor.physical().target_pixel_format() != descriptor.source_pixel_format() + || !matches!( + descriptor.physical().color_pipeline().transform(), + ResolvedScreenColorTransform::PreserveEncodedSamples + ) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +#[error("unsupported macOS native reduction target format: {0:?}")] +struct UnsupportedMacosNativeTargetFormat(CapturePixelFormat); + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_native_target_format( + format: CapturePixelFormat, +) -> std::result::Result { + match format { + CapturePixelFormat::Rgba8 => Ok(MacosNativeTargetFormat::Rgba8), + CapturePixelFormat::Bgra8 => Ok(MacosNativeTargetFormat::Bgra8), + unsupported => Err(UnsupportedMacosNativeTargetFormat(unsupported)), + } +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_reduction_descriptor( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result { + let source = descriptor.source(); + let geometry = source.geometry(); + anyhow::ensure!( + geometry.rotation() == CaptureRotation::Identity + && source.reflection() == ScreenSourceReflection::None + && geometry.native_extent() == geometry.storage_extent() + && geometry.source_scale().numerator() == geometry.source_scale().denominator(), + "macOS native reduction received unsupported pending source geometry" + ); + let crop = geometry.crop(); + let crop_x = crop.map_or(0, hypercolor_core::input::screen::PixelRect::x); + let crop_y = crop.map_or(0, hypercolor_core::input::screen::PixelRect::y); + let region = descriptor.physical().source_region(); + let rational = |value: hypercolor_core::input::screen::ScreenRational| { + value.numerator() as f32 / value.denominator().get() as f32 + }; + let source_rect = [ + crop_x as f32 + rational(region.x()), + crop_y as f32 + rational(region.y()), + rational(region.width()), + rational(region.height()), + ]; + let output = descriptor.physical().reduction_extent(); + let filter = match descriptor.physical().reduction_filter() { + ScreenReductionFilter::Nearest => MacosNativeReductionFilter::Nearest, + ScreenReductionFilter::Bilinear => MacosNativeReductionFilter::Bilinear, + ScreenReductionFilter::Area => MacosNativeReductionFilter::Area, + }; + MacosNativeReductionDescriptor::new( + [output.width(), output.height()], + [0, 0, output.width(), output.height()], + source_rect, + filter, + macos_native_color_transform(descriptor)?, + ) + .map_err(anyhow::Error::from) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_native_color_transform( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result> { + let pipeline = descriptor.physical().color_pipeline(); + if pipeline.transform() == ResolvedScreenColorTransform::PreserveEncodedSamples { + return Ok(None); + } + let source = pipeline + .effective_source() + .context("managed macOS native reduction has no effective source colorimetry")?; + let output = pipeline + .output() + .try_known() + .context("managed macOS native reduction has no known output colorimetry")?; + let calibration = pipeline + .calibration() + .context("managed macOS native reduction has no calibration")?; + let prepared = PreparedLedToneMap::prepare(source, output, calibration) + .context("failed to prepare shared macOS native color constants")?; + let output_transfer = match output.transfer_function() { + CaptureTransferFunction::Srgb => MacosNativeOutputTransfer::Srgb, + CaptureTransferFunction::Linear => MacosNativeOutputTransfer::Linear, + CaptureTransferFunction::Rec709 => MacosNativeOutputTransfer::Rec709, + CaptureTransferFunction::Rec2020 => MacosNativeOutputTransfer::Rec2020, + unsupported => { + anyhow::bail!("unsupported macOS native output transfer function: {unsupported:?}") + } + }; + let constants = prepared.constants(); + Ok(Some(( + output_transfer, + MacosNativeColorTransform::new( + constants.source_to_target, + constants.source_luminance_and_exposure, + constants.curve, + ), + ))) +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +fn macos_native_letterbox_fill( + descriptor: &ResolvedScreenPublicationDescriptor, +) -> Result { + match descriptor.processing_profile().letterbox_fill() { + ScreenLetterboxFill::Transparent => Ok(MacosNativeLetterboxFill::Transparent), + ScreenLetterboxFill::Solid(color) => Ok(MacosNativeLetterboxFill::Solid( + color.map(|channel| f32::from(channel) / f32::from(u8::MAX)), + )), + ScreenLetterboxFill::EdgeExtend => { + anyhow::bail!("macOS native reduction does not support edge-extended letterbox fill") + } + } +} + #[cfg(all(target_os = "macos", feature = "screen-capture"))] fn checked_macos_arc_allocation_bytes() -> Result { let (layout, _) = Layout::new::<[AtomicUsize; 2]>() @@ -465,10 +764,19 @@ impl ScreenNativeTargetPreparer for MacosScreenTargetPreparer { .downcast_ref::() .context("macOS screen target received an unknown preparation manifest")?; validate_macos_target_manifest(descriptor, manifest)?; - self.bridge_lifetime + self.bridge .upgrade() .context("macOS screen renderer was retired during target admission")?; - prepared_macos_screen_target_metadata_bytes() + prepared_macos_screen_target_exclusive_bytes(descriptor) + } + + fn quote_retention( + &self, + descriptor: &ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> Result { + self.quote_retained_bytes(descriptor, platform)?; + prepared_macos_screen_target_retention(descriptor) } fn prepare( @@ -480,18 +788,18 @@ impl ScreenNativeTargetPreparer for MacosScreenTargetPreparer { .downcast_ref::() .context("macOS screen target received an unknown preparation manifest")?; validate_macos_target_manifest(descriptor, manifest)?; - self.bridge_lifetime + let bridge = self + .bridge .upgrade() .context("macOS screen renderer was retired during target preparation")?; - Ok(ScreenNativeTargetPreparation::new( + let prepared = bridge.prepare_target(descriptor, platform.plan_generation())?; + Ok(ScreenNativeTargetPreparation::with_retention( ScreenNativePreparationPayload::new( descriptor, platform.plan_generation(), - Arc::new(PreparedMacosScreenTarget { - resource_generation: manifest.resource_generation(), - }), + Arc::new(prepared), ), - prepared_macos_screen_target_metadata_bytes()?, + prepared_macos_screen_target_retention(descriptor)?, )) } } @@ -872,10 +1180,19 @@ fn create_screen_bridge( return (None, None); } }; + let reducer = match MacosNativeReducer::new(device) { + Ok(reducer) => reducer, + Err(error) => { + tracing::debug!(%error, "renderer does not expose a native Metal screen reducer"); + return (None, None); + } + }; let bridge = Arc::new(MacosScreenBridge { + device: device.clone(), interop, + reducer, storage_ids: Mutex::new(HashMap::new()), - lifetime: Arc::new(()), + physical_targets: Mutex::new(Vec::new()), }); let target = create_screen_target(&bridge, max_texture_dimension); (Some(bridge), target) @@ -894,18 +1211,26 @@ fn create_screen_target( tracing::warn!("screen target identity space is exhausted"); return None; }; - Some(ScreenNativeExecutionTarget::new( - ScreenNativeExecutionTargetId::new( - NonZeroU64::new(target_id).expect("screen target identities start at one"), - ), - PlatformGpuApi::Metal, - ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(bridge.interop.metal_registry_id()), - NonZeroU32::new(max_texture_dimension) - .expect("wgpu devices expose a non-zero texture dimension limit"), - Arc::new(MacosScreenTargetPreparer { - bridge_lifetime: Arc::downgrade(&bridge.lifetime), - }), - )) + Some( + ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(target_id).expect("screen target identities start at one"), + ), + PlatformGpuApi::Metal, + ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(bridge.interop.metal_registry_id()), + NonZeroU32::new(max_texture_dimension) + .expect("wgpu devices expose a non-zero texture dimension limit"), + Arc::new(MacosScreenTargetPreparer { + bridge: Arc::downgrade(bridge), + }), + ) + .with_color_capabilities(ScreenColorTransformCapabilities::new( + true, + true, + true, + LED_TONE_MAP_ALGORITHM_REVISION, + )), + ) } pub(crate) struct GpuSparkleFlinger { @@ -1766,10 +2091,11 @@ impl GpuSparkleFlinger { let Some(bridge) = self.screen_bridge.clone() else { return Ok(None); }; - let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { - return Ok(None); + let (surface, requires_work) = match publication.payload() { + ScreenBranchPayload::GpuSurface(payload) => (payload.surface(), false), + ScreenBranchPayload::NativeWork(payload) => (payload.source(), true), + ScreenBranchPayload::Surface(_) | ScreenBranchPayload::Zones(_) => return Ok(None), }; - let surface = payload.surface(); let capture_owner = surface .owner::() .context("native macOS screen publication has an unknown capture owner")?; @@ -1780,35 +2106,118 @@ impl GpuSparkleFlinger { .resource_lifetime() .cloned() .context("native macOS screen publication has no renderer allocation lifetime")?; - anyhow::ensure!( - surface.capture_resource_lifetime().is_some(), - "native macOS screen publication has no capture allocation lifetime" - ); + let shared_target_lifetime = surface.shared_resource_lifetime().cloned(); + let capture_lifetime = surface + .capture_resource_lifetime() + .cloned() + .context("native macOS screen publication has no capture allocation lifetime")?; let capture = capture_owner .downgrade() .upgrade() .context("native macOS capture owner retired before import")?; let (imported, storage_id) = bridge.import_frame(&self.device, target_owner.resource_generation, capture)?; - let extent = surface.extent(); anyhow::ensure!( - imported.capture().storage_extent.width == extent.width() - && imported.capture().storage_extent.height == extent.height(), + imported.capture().storage_extent.width == surface.extent().width() + && imported.capture().storage_extent.height == surface.extent().height(), "native macOS imported extent does not match the published surface" ); - let width = extent.width(); - let height = extent.height(); let content_generation = imported.content_sequence(); - let texture = imported - .texture() - .context("native macOS publication has no wgpu texture")? - .as_ref() - .clone(); - let view = imported - .view() - .context("native macOS publication has no wgpu texture view")? - .as_ref() - .clone(); + let descriptor = &target_owner.descriptor; + let (width, height, storage_id, texture, view) = if requires_work { + self.flush_pending_output_submission()?; + let physical = target_owner + .physical + .as_ref() + .context("native macOS work has no prepared physical target")?; + let mut physical_sequence = physical + .content_sequence + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if *physical_sequence != Some(content_generation) { + let mut encoder = + self.device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger macOS native screen reduction"), + }); + bridge.reducer.encode( + &imported, + &physical.target, + macos_reduction_descriptor(descriptor)?, + &mut encoder, + )?; + let _ = self.queue.submit(Some(encoder.finish())); + *physical_sequence = Some(content_generation); + } + drop(physical_sequence); + + if let Some(logical_target) = target_owner.logical_target.as_ref() { + let mut logical_sequence = target_owner + .logical_content_sequence + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if *logical_sequence != Some(content_generation) { + let geometry = descriptor.geometry(); + let mut encoder = + self.device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger macOS native screen materialization"), + }); + bridge.reducer.encode_materialization( + &physical.target, + logical_target, + [ + geometry.content_x(), + geometry.content_y(), + geometry.content_extent().width(), + geometry.content_extent().height(), + ], + macos_native_letterbox_fill(descriptor)?, + &mut encoder, + )?; + let _ = self.queue.submit(Some(encoder.finish())); + *logical_sequence = Some(content_generation); + } + ( + logical_target.width(), + logical_target.height(), + target_owner + .logical_storage_id + .context("logical macOS target has no storage identity")?, + logical_target.texture().clone(), + logical_target.view().clone(), + ) + } else { + ( + physical.target.width(), + physical.target.height(), + physical.storage_id, + physical.target.texture().clone(), + physical.target.view().clone(), + ) + } + } else { + let extent = descriptor.geometry().output_extent(); + anyhow::ensure!( + surface.extent() == extent, + "native macOS identity surface extent does not match its target" + ); + ( + extent.width(), + extent.height(), + storage_id, + imported + .texture() + .context("native macOS identity publication has no wgpu texture")? + .as_ref() + .clone(), + imported + .view() + .context("native macOS identity publication has no wgpu texture view")? + .as_ref() + .clone(), + ) + }; Ok(Some(GpuTextureFrame { width, height, @@ -1823,6 +2232,8 @@ impl GpuSparkleFlinger { capture_owner, target_owner, target_lifetime, + shared_target_lifetime, + capture_lifetime, )), })) } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs index 6a881abf8..3829d3560 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs @@ -1,3 +1,5 @@ +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use std::num::{NonZeroU32, NonZeroU64}; #[cfg(any( all(feature = "servo-gpu-import", target_os = "linux"), all(feature = "servo-gpu-import", target_os = "macos"), @@ -8,7 +10,23 @@ use std::sync::mpsc; use hypercolor_core::blend_math::encode_srgb_channel; #[cfg(all(feature = "screen-capture", target_os = "macos"))] -use hypercolor_core::input::screen::{PlatformGpuApi, ScreenPhysicalGpuDeviceIdentity}; +use hypercolor_core::input::screen::{ + CaptureColorSpace, CaptureColorimetry, CaptureDynamicRange, CaptureEpoch, CaptureGeometry, + CaptureLuminanceContext, CapturePixelFormat, CapturePositiveScalar, CaptureRotation, + CaptureSourceId, CaptureTransferFunction, InputPublicationDemandRevision, + KnownCaptureColorimetry, LedToneMapCalibration, PhysicalOrigin, PixelExtent, PlatformGpuApi, + PlatformGpuSurface, PreparedLedToneMap, ResolvedScreenSource, ResolvedScreenSourceConfig, + ScreenAdmissionCapacity, ScreenAspectPolicy, ScreenBackendResourceIdentity, + ScreenByteAdmissionCoordinator, ScreenCaptureBackend, ScreenColorTransformCapabilities, + ScreenExecutorColorCapabilities, ScreenExtentRequest, ScreenInputGraphGeneration, + ScreenLetterboxFill, ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, + ScreenNativePreparationPayload, ScreenNativeRetentionQuote, ScreenNativeTargetPreparation, + ScreenNativeTargetPreparer, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, + ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenPublicationExecutor, + ScreenPublicationExecutorRequest, ScreenPublicationKind, ScreenPublicationRequest, + ScreenPublicationSlotPolicy, ScreenResourceApi, ScreenSourceReflection, ScreenSourceSelector, + ScreenUpscalePolicy, ScreenWorkerExactLedgerBuilder, SourceScale, +}; use hypercolor_core::spatial::SpatialEngine; use hypercolor_core::types::canvas::{ Canvas, PublishedSurface, RenderSurfacePool, Rgba, SurfaceDescriptor, @@ -16,8 +34,9 @@ use hypercolor_core::types::canvas::{ #[cfg(all(feature = "screen-capture", target_os = "macos"))] use hypercolor_macos_capture::{ MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, - MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, MacosPixelRect, - MacosPointRect, MacosScale, MacosTransferFunction, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + MacosYuvMatrix, }; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::device::{DeviceId, DisplayFrameFormat}; @@ -46,12 +65,20 @@ use super::{ PendingPreviewReadback, ensure_readback_buffer_capacity, ensure_storage_buffer_capacity, gpu_canvas_admission, }; +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use super::{ + MacosNativeColorTransform, MacosNativeOutputTransfer, MacosNativeReductionDescriptor, + MacosNativeReductionFilter, MacosNativeTargetFormat, PreparedMacosScreenTarget, + UnsupportedMacosNativeTargetFormat, macos_native_target_format, +}; #[cfg(target_os = "windows")] use super::{ NativeScreenCopyFailurePolicy, native_screen_copy_failure_policy, screen_storage_requires_cache_turnover, validate_windows_plan_generation, }; use crate::performance::CompositorBackendKind; +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use crate::render_thread::producer_queue::MacosScreenTextureLease; use crate::render_thread::producer_queue::{GpuTextureFrame, GpuTextureFrameOrigin, ProducerFrame}; use crate::render_thread::sparkleflinger::gpu_sampling::GpuSamplingPlan; use crate::render_thread::sparkleflinger::{ @@ -1646,8 +1673,16 @@ fn metal_compositor_registers_and_composes_native_capture() { storage_id, content_generation: imported.content_sequence(), origin: GpuTextureFrameOrigin::ProducerTexture, - texture: imported.texture().as_ref().clone(), - view: imported.view().as_ref().clone(), + texture: imported + .texture() + .expect("BGRA imports expose a wgpu texture") + .as_ref() + .clone(), + view: imported + .view() + .expect("BGRA imports expose a wgpu texture view") + .as_ref() + .clone(), immutable_lease: None, macos_screen_lease: None, })), @@ -1664,6 +1699,929 @@ fn metal_compositor_registers_and_composes_native_capture() { ); } +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn native_metal_target_formats_reject_disguised_source_storage() { + assert_eq!( + macos_native_target_format(CapturePixelFormat::Rgba8) + .expect("RGBA8 is a truthful compositor target"), + MacosNativeTargetFormat::Rgba8, + ); + assert_eq!( + macos_native_target_format(CapturePixelFormat::Bgra8) + .expect("BGRA8 is a truthful compositor target"), + MacosNativeTargetFormat::Bgra8, + ); + assert_eq!( + macos_native_target_format(CapturePixelFormat::Argb2101010) + .expect_err("packed source storage cannot masquerade as a compositor target"), + UnsupportedMacosNativeTargetFormat(CapturePixelFormat::Argb2101010), + ); +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +struct MacosLeaseTargetPreparer { + bridge: Arc, +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +impl ScreenNativeTargetPreparer for MacosLeaseTargetPreparer { + fn quote_retained_bytes( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + super::prepared_macos_screen_target_exclusive_bytes(descriptor) + } + + fn quote_retention( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + _platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + super::prepared_macos_screen_target_retention(descriptor) + } + + fn prepare( + &self, + descriptor: &hypercolor_core::input::screen::ResolvedScreenPublicationDescriptor, + platform: &ScreenNativePreparationPayload, + ) -> anyhow::Result { + let prepared = self + .bridge + .prepare_target(descriptor, platform.plan_generation())?; + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(prepared), + ), + super::prepared_macos_screen_target_retention(descriptor)?, + )) + } +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn equal_native_physical_descriptors_share_the_reduction_target() { + let Some(compositor) = gpu_test_compositor() else { + return; + }; + let target = compositor + .screen_native_execution_target() + .expect("Metal compositor exposes a native screen target") + .clone(); + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor retains its screen bridge"), + ); + let target_color_capabilities = target.color_capabilities(); + let extent = PixelExtent::new(4, 3).expect("fixture extent is valid"); + let source = ResolvedScreenSource::new( + ScreenSourceSelector::Configured, + CaptureEpoch { + source_id: CaptureSourceId::new("macos:fixture:shared-physical") + .expect("fixture source id is valid"), + topology_generation: 3, + session_generation: 5, + }, + ResolvedScreenSourceConfig::new( + CaptureGeometry::new( + PhysicalOrigin::default(), + extent, + extent, + CaptureRotation::Identity, + None, + SourceScale::ONE, + ) + .expect("fixture geometry is valid"), + extent, + ScreenSourceReflection::None, + CapturePixelFormat::Bgra8, + CaptureColorimetry::SRGB, + ScreenBackendResourceIdentity::new_with_physical_gpu_device( + ScreenCaptureBackend::MacosScreenCaptureKit, + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Metal), + target.physical_gpu_device().clone(), + 5, + 7, + ), + ), + ); + let descriptor = ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::new( + ScreenProcessingProfileConfig::exact_encoded_identity(CapturePixelFormat::Bgra8), + )), + ) + .resolve_with_executor_capabilities( + &source, + ScreenExecutorColorCapabilities::new( + ScreenColorTransformCapabilities::NONE, + target_color_capabilities, + ), + ) + .expect("native fixture descriptor resolves"); + + let plan_generation = hypercolor_core::input::screen::ScreenPlanGeneration::default(); + let first = bridge + .prepare_target(&descriptor, plan_generation) + .expect("first native target prepares"); + let second = bridge + .prepare_target(&descriptor, plan_generation) + .expect("equal native target prepares"); + let first_physical = first + .physical + .as_ref() + .expect("bounded native descriptor has physical work"); + let second_physical = second + .physical + .as_ref() + .expect("equal bounded descriptor has physical work"); + + assert!(Arc::ptr_eq(first_physical, second_physical)); + assert_eq!(first_physical.storage_id, second_physical.storage_id); + + let edge_extended = ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target), + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::new( + ScreenProcessingProfileConfig { + letterbox_fill: ScreenLetterboxFill::EdgeExtend, + ..ScreenProcessingProfileConfig::exact_encoded_identity(CapturePixelFormat::Bgra8) + }, + )), + ) + .resolve_with_executor_capabilities( + &source, + ScreenExecutorColorCapabilities::new( + ScreenColorTransformCapabilities::NONE, + target_color_capabilities, + ), + ) + .expect("edge-extended native descriptor resolves"); + let error = bridge + .prepare_target(&edge_extended, plan_generation) + .expect_err("edge extension must fail native preparation"); + assert!(error.to_string().contains("edge-extended letterbox fill")); +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn macos_texture_lease_retains_exclusive_shared_and_capture_admissions() { + let Some(compositor) = gpu_test_compositor() else { + return; + }; + let registered_target = compositor + .screen_native_execution_target() + .expect("Metal compositor exposes a native screen target") + .clone(); + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor retains its screen bridge"), + ); + let target = ScreenNativeExecutionTarget::new( + ScreenNativeExecutionTargetId::new( + NonZeroU64::new(991).expect("fixture target id is non-zero"), + ), + PlatformGpuApi::Metal, + registered_target.physical_gpu_device().clone(), + NonZeroU32::new(compositor.probe.max_texture_dimension_2d) + .expect("fixture texture limit is non-zero"), + Arc::new(MacosLeaseTargetPreparer { + bridge: Arc::clone(&bridge), + }), + ) + .with_color_capabilities(registered_target.color_capabilities()); + let extent = PixelExtent::new(4, 3).expect("fixture extent is valid"); + let source_id = + CaptureSourceId::new("macos:fixture:lease").expect("fixture source id is valid"); + let source = ResolvedScreenSource::new( + ScreenSourceSelector::Configured, + CaptureEpoch { + source_id: source_id.clone(), + topology_generation: 3, + session_generation: 5, + }, + ResolvedScreenSourceConfig::new( + CaptureGeometry::new( + PhysicalOrigin::default(), + extent, + extent, + CaptureRotation::Identity, + None, + SourceScale::ONE, + ) + .expect("fixture geometry is valid"), + extent, + ScreenSourceReflection::None, + CapturePixelFormat::Bgra8, + CaptureColorimetry::SRGB, + ScreenBackendResourceIdentity::new_with_physical_gpu_device( + ScreenCaptureBackend::MacosScreenCaptureKit, + ScreenResourceApi::PlatformGpu(PlatformGpuApi::Metal), + registered_target.physical_gpu_device().clone(), + 5, + 7, + ), + ), + ); + let demand = hypercolor_core::input::screen::RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target), + ScreenExtentRequest::bounded( + NonZeroU32::new(2), + NonZeroU32::new(1), + ScreenUpscalePolicy::Never, + ), + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::default()), + ), + NonZeroU32::new(60).expect("fixture cadence is non-zero"), + ) + .resolve_with_executor_capabilities( + &source, + ScreenExecutorColorCapabilities::new( + ScreenColorTransformCapabilities::NONE, + registered_target.color_capabilities(), + ), + ) + .expect("native lease demand resolves"); + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + let mut builder = ScreenPlanBuilder::with_publication_slots_and_admission( + ScreenPublicationSlotPolicy::default(), + coordinator.clone(), + ); + let revision = InputPublicationDemandRevision::new(1); + let graph = ScreenInputGraphGeneration::new(1); + let mut preparing = builder + .prepare( + [demand], + None, + revision, + graph, + ScreenAdmissionCapacity::new(u64::MAX, u64::MAX), + ) + .expect("native lease plan prepares"); + let ticket = preparing + .worker_ticket(&source_id) + .expect("native lease source owns a worker ticket"); + let mut ledger = + ScreenWorkerExactLedgerBuilder::new(ticket).expect("native lease ledger begins"); + let descriptor = ledger.ticket().candidate_plan().branches()[0] + .descriptor() + .clone(); + let ScreenPublicationExecutor::SourceNative(target) = descriptor.executor() else { + panic!("native lease descriptor keeps its native executor"); + }; + let prepared = ledger + .prepare_native_target( + target, + &descriptor, + &hypercolor_core::input::screen::ScreenNativePreparationPayload::new( + &descriptor, + ledger.ticket().plan_generation(), + Arc::new(()), + ), + "native-target-test", + "worker-runtime-total", + ) + .expect("native renderer target is admitted"); + let shared_resource_name = prepared + .shared_resource_name() + .cloned() + .expect("native reduction has a shared physical resource"); + ledger + .preflight_additional_bytes(1) + .expect("capture admission byte fits"); + ledger + .report_scoped("capture-plan-test", "worker-runtime-total", 1) + .expect("capture admission is exact"); + let required = ledger + .ticket() + .required_minimums() + .iter() + .map(|minimum| (Arc::clone(minimum.name()), minimum.minimum_bytes())) + .collect::>(); + for (name, bytes) in required { + ledger + .report(&name, bytes) + .expect("required native lease resource is exact"); + } + let (token, lifetimes) = ledger + .finish() + .expect("native lease ledger finishes") + .into_parts(); + preparing + .acknowledge(token) + .expect("native lease worker acknowledges"); + let target_lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name().as_ref() == "native-target-test") + .cloned() + .expect("target lifetime is present"); + let capture_lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name().as_ref() == "capture-plan-test") + .cloned() + .expect("capture lifetime is present"); + let shared_target_lifetime = lifetimes + .iter() + .find(|lifetime| lifetime.resource().name() == &shared_resource_name) + .cloned() + .expect("shared physical lifetime is present"); + let bound = prepared + .bind_with_shared( + target_lifetime.clone(), + Some(shared_target_lifetime.clone()), + ) + .expect("prepared target binds its exclusive and shared lifetimes"); + let capture = Arc::new(macos_capture_frame(&[17, 43, 91, 255].repeat(12))); + let imported = bridge + .interop + .import_frame(&compositor.device, 7, Arc::clone(&capture)) + .expect("lease fixture imports"); + let surface = bound + .retain_on_surface_with_capture_allocation( + PlatformGpuSurface::new( + PlatformGpuApi::Metal, + u64::from(capture.surface.iosurface_id), + extent, + CapturePixelFormat::Bgra8, + capture, + ) + .expect("lease fixture surface is valid"), + capture_lifetime.clone(), + ) + .expect("surface retains both exact allocations"); + let capture_owner = surface + .owner::() + .expect("surface retains the capture owner"); + let target_owner = surface + .retained_owner::() + .expect("surface retains the renderer owner"); + assert_eq!( + surface + .shared_resource_lifetime() + .expect("surface retains the shared physical lifetime") + .resource() + .name(), + shared_target_lifetime.resource().name() + ); + let lease = MacosScreenTextureLease::new( + imported, + capture_owner, + target_owner, + target_lifetime, + Some(shared_target_lifetime), + capture_lifetime, + ); + drop(surface); + drop(bound); + drop(lifetimes); + drop(preparing); + drop(builder); + let retained_bytes = coordinator.snapshot().reserved_bytes(); + assert!(retained_bytes > 0); + drop(lease); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn native_metal_reduction_feeds_gpu_zone_sampling_without_readback() { + let Some(mut compositor) = gpu_test_compositor() else { + return; + }; + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor retains its screen bridge"), + ); + let capture = Arc::new(macos_capture_frame(&[17, 43, 91, 255].repeat(12))); + let imported = bridge + .interop + .import_frame(&compositor.device, 23, capture) + .expect("native zone fixture imports"); + let target = bridge + .reducer + .create_target(&compositor.device, 4, 4, MacosNativeTargetFormat::Rgba8) + .expect("native zone target allocates"); + let descriptor = MacosNativeReductionDescriptor::new( + [4, 4], + [0, 0, 4, 4], + [0.0, 0.0, 4.0, 3.0], + MacosNativeReductionFilter::Area, + None, + ) + .expect("native zone reduction geometry is valid"); + let mut encoder = compositor + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger native zone reduction"), + }); + bridge + .reducer + .encode(&imported, &target, descriptor, &mut encoder) + .expect("native zone reduction encodes"); + let _ = compositor.queue.submit(Some(encoder.finish())); + + let plan = CompositionPlan::single( + 4, + 4, + CompositionLayer::replace(ProducerFrame::GpuTexture(GpuTextureFrame { + width: 4, + height: 4, + storage_id: 29, + content_generation: imported.content_sequence(), + origin: GpuTextureFrameOrigin::ProducerTexture, + texture: target.texture().clone(), + view: target.view().clone(), + immutable_lease: None, + macos_screen_lease: None, + })), + ); + compositor + .compose(&plan, false, None) + .expect("native reduced texture composes without readback"); + let engine = SpatialEngine::new(sampling_layout(SamplingMode::Bilinear)); + let mut expected = Canvas::new(4, 4); + expected.fill(Rgba::new(91, 43, 17, 255)); + let mut sampled = Vec::new(); + assert!( + compositor + .sample_zone_plan_into(engine.sampling_plan().as_ref(), &mut sampled) + .expect("native reduced texture samples into zones") + ); + assert_eq!(sampled, engine.sample(&expected)); +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn native_metal_color_pipeline_matches_shared_sdr_p3_pq_hlg_extended_linear_and_yuv_vectors() { + let Some(compositor) = gpu_test_compositor() else { + return; + }; + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor should retain its screen bridge"), + ); + for (format, source, color, planes) in managed_native_vectors() { + let capture = Arc::new(macos_native_capture_frame(format, color, &planes)); + let imported = bridge + .interop + .import_frame(&compositor.device, 19, Arc::clone(&capture)) + .expect("managed native vector imports"); + let prepared = PreparedLedToneMap::prepare( + source, + KnownCaptureColorimetry::SRGB, + LedToneMapCalibration::DEFAULT, + ) + .expect("managed native vector prepares"); + let constants = prepared.constants(); + let target = bridge + .reducer + .create_target(&compositor.device, 1, 1, MacosNativeTargetFormat::Rgba8) + .expect("managed native target allocates"); + let descriptor = MacosNativeReductionDescriptor::new( + [1, 1], + [0, 0, 1, 1], + [0.0, 0.0, 1.0, 1.0], + MacosNativeReductionFilter::Nearest, + Some(( + MacosNativeOutputTransfer::Srgb, + MacosNativeColorTransform::new( + constants.source_to_target, + constants.source_luminance_and_exposure, + constants.curve, + ), + )), + ) + .expect("managed native descriptor is valid"); + let mut encoder = + compositor + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger managed native color parity"), + }); + bridge + .reducer + .encode(&imported, &target, descriptor, &mut encoder) + .expect("managed native vector encodes"); + let _ = compositor.queue.submit(Some(encoder.finish())); + let actual = read_texture_rgba8( + &compositor.device, + &compositor.queue, + target.texture(), + 1, + 1, + ); + let encoded = capture + .with_cpu_source(|source| source.sample_rgba32f(0, 0)) + .expect("scalar source maps") + .expect("scalar source decodes"); + let mapped = prepared.decode_and_map_source(encoded); + let expected = prepared.encode(mapped); + assert_eq!(actual.as_slice(), expected, "{format:?} managed parity"); + } +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +#[test] +fn native_metal_sdr_output_transfers_match_the_shared_encoder() { + let Some(compositor) = gpu_test_compositor() else { + return; + }; + let bridge = Arc::clone( + compositor + .screen_bridge + .as_ref() + .expect("Metal compositor should retain its screen bridge"), + ); + let source = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Srgb, + CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard, + None, + ) + .expect("linear source contract is valid"); + let color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let planes = vec![ + [0x3400_u16, 0x3800, 0x3a00, 0x3c00] + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + ]; + let capture = Arc::new(macos_native_capture_frame( + MacosCapturePixelFormat::Rgba16Float, + color, + &planes, + )); + let imported = bridge + .interop + .import_frame(&compositor.device, 29, Arc::clone(&capture)) + .expect("SDR transfer fixture imports"); + let encoded_source = capture + .with_cpu_source(|source| source.sample_rgba32f(0, 0)) + .expect("scalar source maps") + .expect("scalar source decodes"); + + for (transfer, native_transfer, color_space) in [ + ( + CaptureTransferFunction::Srgb, + MacosNativeOutputTransfer::Srgb, + CaptureColorSpace::Srgb, + ), + ( + CaptureTransferFunction::Linear, + MacosNativeOutputTransfer::Linear, + CaptureColorSpace::Srgb, + ), + ( + CaptureTransferFunction::Rec709, + MacosNativeOutputTransfer::Rec709, + CaptureColorSpace::Srgb, + ), + ( + CaptureTransferFunction::Rec2020, + MacosNativeOutputTransfer::Rec2020, + CaptureColorSpace::Rec2020, + ), + ] { + let output = KnownCaptureColorimetry::try_new( + color_space, + transfer, + CaptureDynamicRange::Standard, + None, + ) + .expect("SDR output contract is valid"); + let prepared = PreparedLedToneMap::prepare(source, output, LedToneMapCalibration::DEFAULT) + .expect("SDR output fixture prepares"); + let constants = prepared.constants(); + let target = bridge + .reducer + .create_target(&compositor.device, 1, 1, MacosNativeTargetFormat::Rgba8) + .expect("SDR output target allocates"); + let descriptor = MacosNativeReductionDescriptor::new( + [1, 1], + [0, 0, 1, 1], + [0.0, 0.0, 1.0, 1.0], + MacosNativeReductionFilter::Nearest, + Some(( + native_transfer, + MacosNativeColorTransform::new( + constants.source_to_target, + constants.source_luminance_and_exposure, + constants.curve, + ), + )), + ) + .expect("SDR output descriptor is valid"); + let mut encoder = + compositor + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger native SDR output parity"), + }); + bridge + .reducer + .encode(&imported, &target, descriptor, &mut encoder) + .expect("SDR output vector encodes"); + let _ = compositor.queue.submit(Some(encoder.finish())); + let actual = read_texture_rgba8( + &compositor.device, + &compositor.queue, + target.texture(), + 1, + 1, + ); + let expected = prepared.encode(prepared.decode_and_map_source(encoded_source)); + assert_eq!(actual.as_slice(), expected, "{transfer:?} output parity"); + } +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +fn managed_native_vectors() -> Vec<( + MacosCapturePixelFormat, + KnownCaptureColorimetry, + MacosCaptureColorimetry, + Vec>, +)> { + let p3 = KnownCaptureColorimetry::try_new( + CaptureColorSpace::DisplayP3, + CaptureTransferFunction::Srgb, + CaptureDynamicRange::Standard, + None, + ) + .expect("P3 source contract is valid"); + let hdr_luminance = CaptureLuminanceContext::new( + CapturePositiveScalar::try_new(203.0).expect("reference white is valid"), + CapturePositiveScalar::try_new(1_000.0).expect("peak is valid"), + ) + .expect("HDR luminance is ordered"); + let rec2020_pq = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Pq, + CaptureDynamicRange::High, + Some(hdr_luminance), + ) + .expect("PQ source contract is valid"); + let rec2020_linear = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Linear, + CaptureDynamicRange::High, + Some(hdr_luminance), + ) + .expect("extended-linear source contract is valid"); + let rec2020_hlg = KnownCaptureColorimetry::try_new( + CaptureColorSpace::Rec2020, + CaptureTransferFunction::Hlg, + CaptureDynamicRange::High, + Some(hdr_luminance), + ) + .expect("HLG source contract is valid"); + vec![ + ( + MacosCapturePixelFormat::Bgra8, + KnownCaptureColorimetry::SRGB, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + vec![vec![208, 72, 24, 255]], + ), + ( + MacosCapturePixelFormat::Bgra8, + p3, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + vec![vec![32, 96, 224, 255]], + ), + ( + MacosCapturePixelFormat::Argb2101010, + rec2020_pq, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + vec![ + ((3_u32 << 30) | (600_u32 << 20) | (450_u32 << 10) | 0x012c_u32) + .to_le_bytes() + .to_vec(), + ], + ), + ( + MacosCapturePixelFormat::Rgba16Float, + rec2020_linear, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + vec![ + [0x4000_u16, 0x3c00, 0x3800, 0x3c00] + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + ], + ), + ( + MacosCapturePixelFormat::Yuv420VideoRange, + rec2020_pq, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: Some(MacosChromaLocation::Center), + }, + vec![vec![128], vec![64, 192]], + ), + ( + MacosCapturePixelFormat::Yuv420FullRange, + rec2020_pq, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Full, + chroma_location: Some(MacosChromaLocation::Left), + }, + vec![vec![144], vec![80, 176]], + ), + ( + MacosCapturePixelFormat::Yuv44410BiPlanar, + rec2020_pq, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: Some(MacosChromaLocation::TopLeft), + }, + vec![ + (600_u16 << 6).to_le_bytes().to_vec(), + [(320_u16 << 6), (700_u16 << 6)] + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + ], + ), + ( + MacosCapturePixelFormat::Bgra8, + rec2020_hlg, + MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Hlg, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + vec![vec![64, 128, 192, 255]], + ), + ] +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +fn macos_native_capture_frame( + format: MacosCapturePixelFormat, + color: MacosCaptureColorimetry, + planes: &[Vec], +) -> MacosCaptureFrame { + let extent = MacosPixelExtent::new(1, 1).expect("fixture extent is valid"); + let borrowed = planes.iter().map(Vec::as_slice).collect::>(); + let (surface, planes) = + MacosCaptureSurface::new_native_fixture(extent, format, color, &borrowed) + .expect("native managed fixture is valid"); + MacosCaptureFrame { + epoch: 5, + sequence: 1, + display_time: 13, + storage_extent: extent, + planes: Arc::from(planes), + pixel_format: format, + color, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0).expect("fixture display scale is valid"), + content_scale: MacosScale::new(1.0).expect("fixture content scale is valid"), + content_rect_points: MacosPointRect::new(0.0, 0.0, 1.0, 1.0) + .expect("fixture content points are valid"), + content_rect_pixels: MacosPixelRect::new(0, 0, 1, 1) + .expect("fixture content pixels are valid"), + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([]), + cursor_composed: false, + surface, + } +} + +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +fn read_texture_rgba8( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Vec { + let row_bytes = width * 4; + let padded = + row_bytes.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("SparkleFlinger managed native color readback"), + size: u64::from(padded) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("SparkleFlinger managed native color readback"), + }); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let submission = queue.submit(Some(encoder.finish())); + let slice = buffer.slice(..); + let (sender, receiver) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .expect("managed native color readback poll succeeds"); + receiver + .recv() + .expect("managed native color callback arrives") + .expect("managed native color buffer maps"); + let mapped = slice.get_mapped_range(); + let mut result = Vec::with_capacity((row_bytes * height) as usize); + for row in mapped.chunks_exact(padded as usize) { + result.extend_from_slice(&row[..row_bytes as usize]); + } + result +} + #[cfg(all(feature = "screen-capture", target_os = "macos"))] fn macos_capture_frame(pixels: &[u8]) -> MacosCaptureFrame { let extent = MacosPixelExtent::new(4, 3).expect("fixture extent should be valid"); diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index 6a2e46f9a..24d37f4fb 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -52,7 +52,22 @@ objc2-core-video = { workspace = true, optional = true, features = [ "objc2-metal", ] } objc2-io-surface = { workspace = true, features = ["std", "IOSurfaceRef", "IOSurfaceTypes", "objc2-core-foundation", "libc", "bitflags"] } -objc2-metal = { workspace = true, features = ["std", "MTLAllocation", "MTLDevice", "MTLPixelFormat", "MTLResource", "MTLTexture", "objc2-io-surface"] } +objc2-metal = { workspace = true, features = [ + "std", + "MTLAllocation", + "MTLCommandBuffer", + "MTLCommandEncoder", + "MTLComputeCommandEncoder", + "MTLComputePipeline", + "MTLDevice", + "MTLLibrary", + "MTLPixelFormat", + "MTLResource", + "MTLTexture", + "MTLTypes", + "objc2-io-surface", +] } +objc2-foundation = { workspace = true, features = ["std", "NSError", "NSString"] } paint_api = { workspace = true, optional = true } surfman = { workspace = true, optional = true } tracing = { workspace = true, optional = true } diff --git a/crates/hypercolor-macos-gpu-interop/src/lib.rs b/crates/hypercolor-macos-gpu-interop/src/lib.rs index f7a624500..e829647b0 100644 --- a/crates/hypercolor-macos-gpu-interop/src/lib.rs +++ b/crates/hypercolor-macos-gpu-interop/src/lib.rs @@ -5,6 +5,8 @@ #[cfg(target_os = "macos")] mod macos; #[cfg(all(target_os = "macos", feature = "screen-capture"))] +mod native_reduction; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] mod screen_capture; #[cfg(all(target_os = "macos", feature = "servo-context"))] mod servo_context; @@ -14,6 +16,8 @@ mod stubs; #[cfg(target_os = "macos")] pub use macos::*; #[cfg(all(target_os = "macos", feature = "screen-capture"))] +pub use native_reduction::*; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] pub use screen_capture::*; #[cfg(all(target_os = "macos", feature = "servo-context"))] pub use servo_context::*; diff --git a/crates/hypercolor-macos-gpu-interop/src/native_reduction.metal b/crates/hypercolor-macos-gpu-interop/src/native_reduction.metal new file mode 100644 index 000000000..8b89b1a33 --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/src/native_reduction.metal @@ -0,0 +1,389 @@ +#include +using namespace metal; + +struct ReductionParameters { + uint4 content_rect; + uint4 output_and_format; + float4 source_rect; + uint4 source_and_chroma_extent; + uint4 color; + uint4 operation; + float4 source_to_target[3]; + float4 source_luminance_and_exposure; + float4 curve; +}; + +struct MaterializationParameters { + uint4 content_rect; + uint4 output_extent; + float4 fill; +}; + +constant float PQ_M1 = 2610.0 / 16384.0; +constant float PQ_M2 = 2523.0 / 32.0; +constant float PQ_C1 = 3424.0 / 4096.0; +constant float PQ_C2 = 2413.0 / 128.0; +constant float PQ_C3 = 2392.0 / 128.0; + +float pq_to_nits(float encoded) { + float power = pow(clamp(encoded, 0.0, 1.0), 1.0 / PQ_M2); + float numerator = max(power - PQ_C1, 0.0); + float denominator = PQ_C2 - PQ_C3 * power; + return 10000.0 * pow(numerator / denominator, 1.0 / PQ_M1); +} + +float nits_to_pq(float nits) { + float power = pow(max(nits, 0.0) / 10000.0, PQ_M1); + return pow((PQ_C1 + PQ_C2 * power) / (1.0 + PQ_C3 * power), PQ_M2); +} + +float hlg_inverse_oetf(float encoded) { + constexpr float hlg_a = 0.17883277; + constexpr float hlg_b = 0.28466892; + constexpr float hlg_c = 0.55991073; + return encoded <= 0.5 + ? encoded * encoded / 3.0 + : (exp((encoded - hlg_c) / hlg_a) + hlg_b) / 12.0; +} + +float decode_channel( + float encoded, + uint transfer, + float source_reference_nits +) { + if (transfer == 0) { + return encoded <= 0.04045 + ? encoded / 12.92 + : pow((encoded + 0.055) / 1.055, 2.4); + } + if (transfer == 1) { + return encoded < 0.081 + ? encoded / 4.5 + : pow((encoded + 0.099) / 1.099, 1.0 / 0.45); + } + if (transfer == 2) { + constexpr float alpha = 1.09929682680944; + constexpr float beta = 0.018053968510807; + return encoded < 4.5 * beta + ? encoded / 4.5 + : pow((encoded + alpha - 1.0) / alpha, 1.0 / 0.45); + } + if (transfer == 3) { + return encoded; + } + if (transfer == 4) { + return pq_to_nits(encoded) / source_reference_nits; + } + return hlg_inverse_oetf(max(encoded, 0.0)); +} + +float map_luminance(float value, constant ReductionParameters& p) { + float reference_ratio = p.curve.x; + float source_headroom = p.curve.y; + float target_peak_nits = p.curve.w; + if (source_headroom <= 1.0) { + return min(value, 1.0) * reference_ratio; + } + float target_reference_nits = reference_ratio * target_peak_nits; + float source_peak_nits = target_reference_nits * source_headroom; + if (source_peak_nits <= target_peak_nits) { + return clamp(value * reference_ratio, 0.0, 1.0); + } + float source_peak_pq = nits_to_pq(source_peak_nits); + float maximum_luminance = nits_to_pq(target_peak_nits) / source_peak_pq; + float input_pq = nits_to_pq(value * target_reference_nits) / source_peak_pq; + float knee_start = 1.5 * maximum_luminance - 0.5; + if (input_pq < knee_start) { + return clamp(value * reference_ratio, 0.0, 1.0); + } + float t = clamp((input_pq - knee_start) / (1.0 - knee_start), 0.0, 1.0); + float t2 = t * t; + float t3 = t2 * t; + float output_pq = (2.0 * t3 - 3.0 * t2 + 1.0) * knee_start + + (t3 - 2.0 * t2 + t) * (1.0 - knee_start) + + (-2.0 * t3 + 3.0 * t2) * maximum_luminance; + return clamp(pq_to_nits(output_pq * source_peak_pq) / target_peak_nits, 0.0, 1.0); +} + +float3 compress_gamut(float3 rgb, float luminance) { + float neutral = clamp(luminance, 0.0, 1.0); + float scale = 1.0; + for (uint index = 0; index < 3; index++) { + float channel = rgb[index]; + float chroma = channel - neutral; + if (channel < 0.0) { + scale = min(scale, neutral / -chroma); + } else if (channel > 1.0) { + scale = min(scale, (1.0 - neutral) / chroma); + } + } + return clamp(neutral + (rgb - neutral) * scale, 0.0, 1.0); +} + +float3 map_color(float3 encoded, constant ReductionParameters& p) { + float3 linear; + for (uint index = 0; index < 3; index++) { + linear[index] = decode_channel(encoded[index], p.color.w, p.curve.z); + } + if (p.color.w == 5) { + float scene_luminance = max( + dot(p.source_luminance_and_exposure.xyz, linear), + 0.0 + ); + if (scene_luminance <= FLT_EPSILON) { + linear = float3(0.0); + } else { + float peak_nits = p.curve.z * p.curve.y; + float system_gamma = 1.2 + 0.42 * log10(peak_nits / 1000.0); + float ootf_scale = p.curve.y * pow(scene_luminance, system_gamma - 1.0); + linear *= ootf_scale; + } + } + float exposure = p.source_luminance_and_exposure.w; + float3 exposed = linear * exposure; + float source_luminance = max( + dot(p.source_luminance_and_exposure.xyz, exposed), + 0.0 + ); + float mapped_luminance = map_luminance(source_luminance, p); + float3 target = float3( + dot(p.source_to_target[0].xyz, exposed), + dot(p.source_to_target[1].xyz, exposed), + dot(p.source_to_target[2].xyz, exposed) + ); + target = source_luminance > FLT_EPSILON + ? target * (mapped_luminance / source_luminance) + : float3(0.0); + float minimum = min(target.x, min(target.y, target.z)); + float maximum = max(target.x, max(target.y, target.z)); + if (maximum - minimum <= 1.0e-5) { + target = float3(mapped_luminance); + } + return compress_gamut(target, mapped_luminance); +} + +float2 read_chroma_bilinear( + texture2d chroma, + float2 coordinate, + uint2 extent +) { + float2 bounded = clamp(coordinate, float2(0.0), float2(extent - 1)); + uint2 lower = uint2(floor(bounded)); + uint2 upper = min(lower + 1, extent - 1); + float2 fraction = fract(bounded); + float2 top = mix(chroma.read(lower).rg, chroma.read(uint2(upper.x, lower.y)).rg, fraction.x); + float2 bottom = mix(chroma.read(uint2(lower.x, upper.y)).rg, chroma.read(upper).rg, fraction.x); + return mix(top, bottom, fraction.y); +} + +float3 yuv_to_rgb(float y, float cb, float cr, uint matrix) { + float kr = matrix == 1 ? 0.299 : (matrix == 2 ? 0.2126 : 0.2627); + float kb = matrix == 1 ? 0.114 : (matrix == 2 ? 0.0722 : 0.0593); + float kg = 1.0 - kr - kb; + return float3( + y + 2.0 * (1.0 - kr) * cr, + y - 2.0 * kb * (1.0 - kb) / kg * cb + - 2.0 * kr * (1.0 - kr) / kg * cr, + y + 2.0 * (1.0 - kb) * cb + ); +} + +float4 load_encoded( + texture2d plane0, + texture2d plane1, + uint2 position, + constant ReductionParameters& p +) { + uint format = p.output_and_format.z; + if (format <= 2) { + return plane0.read(position); + } + float y; + float cb; + float cr; + if (format == 3) { + float2 offset = p.color.z == 1 + ? float2(1.0, 1.0) + : (p.color.z == 2 ? float2(0.5, 1.0) : float2(0.5, 0.5)); + float2 luma_center = float2(position) + 0.5; + float2 chroma_coordinate = (luma_center - offset) * 0.5; + float2 chroma = read_chroma_bilinear( + plane1, + chroma_coordinate, + p.source_and_chroma_extent.zw + ); + y = plane0.read(position).r; + if (p.color.x == 0) { + cb = chroma.r - 128.0 / 255.0; + cr = chroma.g - 128.0 / 255.0; + } else { + y = (y * 255.0 - 16.0) / 219.0; + cb = (chroma.r * 255.0 - 128.0) / 224.0; + cr = (chroma.g * 255.0 - 128.0) / 224.0; + } + } else { + float luma_code = round(plane0.read(position).r * 65535.0) / 64.0; + float2 chroma_code = round(plane1.read(position).rg * 65535.0) / 64.0; + if (p.color.x == 0) { + y = luma_code / 1023.0; + cb = (chroma_code.r - 512.0) / 1023.0; + cr = (chroma_code.g - 512.0) / 1023.0; + } else { + y = (luma_code - 64.0) / 876.0; + cb = (chroma_code.r - 512.0) / 896.0; + cr = (chroma_code.g - 512.0) / 896.0; + } + } + return float4(yuv_to_rgb(y, cb, cr, p.color.y), 1.0); +} + +float4 load_sample( + texture2d plane0, + texture2d plane1, + int2 position, + constant ReductionParameters& p +) { + int2 maximum = int2(p.source_and_chroma_extent.xy) - 1; + uint2 bounded = uint2(clamp(position, int2(0), maximum)); + float4 encoded = load_encoded(plane0, plane1, bounded, p); + if (p.operation.x == 0) { + return encoded; + } + return float4(map_color(encoded.rgb, p), encoded.a); +} + +float4 sample_nearest( + texture2d plane0, + texture2d plane1, + float2 coordinate, + constant ReductionParameters& p +) { + return load_sample(plane0, plane1, int2(floor(coordinate)), p); +} + +float4 sample_bilinear( + texture2d plane0, + texture2d plane1, + float2 coordinate, + constant ReductionParameters& p +) { + float2 centered = coordinate - 0.5; + int2 lower = int2(floor(centered)); + float2 fraction = fract(centered); + float4 top = mix( + load_sample(plane0, plane1, lower, p), + load_sample(plane0, plane1, lower + int2(1, 0), p), + fraction.x + ); + float4 bottom = mix( + load_sample(plane0, plane1, lower + int2(0, 1), p), + load_sample(plane0, plane1, lower + int2(1, 1), p), + fraction.x + ); + return mix(top, bottom, fraction.y); +} + +float4 sample_area( + texture2d plane0, + texture2d plane1, + float2 start, + float2 end, + constant ReductionParameters& p +) { + int2 first = int2(floor(start)); + int2 last = int2(ceil(end)); + float4 total = float4(0.0); + float total_weight = 0.0; + for (int y = first.y; y < last.y; y++) { + float height = max(0.0, min(end.y, float(y + 1)) - max(start.y, float(y))); + for (int x = first.x; x < last.x; x++) { + float width = max(0.0, min(end.x, float(x + 1)) - max(start.x, float(x))); + float weight = width * height; + total += load_sample(plane0, plane1, int2(x, y), p) * weight; + total_weight += weight; + } + } + return total / max(total_weight, FLT_EPSILON); +} + +float encode_srgb(float linear) { + float bounded = round(clamp(linear, 0.0, 1.0) * 4095.0) / 4095.0; + return bounded <= 0.0031308 + ? 12.92 * bounded + : 1.055 * pow(bounded, 1.0 / 2.4) - 0.055; +} + +float encode_output(float linear, uint transfer) { + if (transfer == 0) { + return encode_srgb(linear); + } + if (transfer == 1) { + return linear; + } + if (transfer == 2) { + return linear < 0.018 + ? 4.5 * linear + : 1.099 * pow(linear, 0.45) - 0.099; + } + constexpr float alpha = 1.0992968; + constexpr float beta = 0.01805397; + return linear < beta + ? 4.5 * linear + : alpha * pow(linear, 0.45) - (alpha - 1.0); +} + +kernel void hypercolor_reduce( + texture2d plane0 [[texture(0)]], + texture2d plane1 [[texture(1)]], + texture2d output [[texture(2)]], + constant ReductionParameters& p [[buffer(0)]], + uint2 gid [[thread_position_in_grid]] +) { + if (any(gid >= p.output_and_format.xy)) { + return; + } + if (gid.x < p.content_rect.x || gid.y < p.content_rect.y + || gid.x >= p.content_rect.x + p.content_rect.z + || gid.y >= p.content_rect.y + p.content_rect.w) { + output.write(float4(0.0, 0.0, 0.0, 1.0), gid); + return; + } + float2 local = float2(gid - p.content_rect.xy); + float2 scale = p.source_rect.zw / float2(p.content_rect.zw); + float2 start = p.source_rect.xy + local * scale; + float2 end = start + scale; + float4 sample; + if (p.output_and_format.w == 0) { + sample = sample_nearest(plane0, plane1, (start + end) * 0.5, p); + } else if (p.output_and_format.w == 1) { + sample = sample_bilinear(plane0, plane1, (start + end) * 0.5, p); + } else { + sample = sample_area(plane0, plane1, start, end, p); + } + if (p.operation.x != 0) { + sample.rgb = float3( + encode_output(sample.r, p.operation.y), + encode_output(sample.g, p.operation.y), + encode_output(sample.b, p.operation.y) + ); + } + output.write(float4(clamp(sample.rgb, 0.0, 1.0), clamp(sample.a, 0.0, 1.0)), gid); +} + +kernel void hypercolor_materialize( + texture2d source [[texture(0)]], + texture2d output [[texture(1)]], + constant MaterializationParameters& p [[buffer(0)]], + uint2 gid [[thread_position_in_grid]] +) { + if (any(gid >= p.output_extent.xy)) { + return; + } + if (gid.x < p.content_rect.x || gid.y < p.content_rect.y + || gid.x >= p.content_rect.x + p.content_rect.z + || gid.y >= p.content_rect.y + p.content_rect.w) { + output.write(p.fill, gid); + return; + } + output.write(source.read(gid - p.content_rect.xy), gid); +} diff --git a/crates/hypercolor-macos-gpu-interop/src/native_reduction.rs b/crates/hypercolor-macos-gpu-interop/src/native_reduction.rs new file mode 100644 index 000000000..85a3d442f --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/src/native_reduction.rs @@ -0,0 +1,714 @@ +use std::ptr::NonNull; + +use hypercolor_macos_capture::{ + MacosCapturePixelFormat, MacosChromaLocation, MacosColorRange, MacosTransferFunction, + MacosYuvMatrix, +}; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2_foundation::NSString; +use objc2_metal::{ + MTLCommandBuffer, MTLCommandEncoder, MTLComputeCommandEncoder, MTLComputePipelineState, + MTLDevice, MTLLibrary, MTLPixelFormat, MTLSize, MTLStorageMode, MTLTexture, + MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, +}; +use thiserror::Error; + +use crate::{ + ImportedMacosScreenFrame, MacosGpuInteropError, MacosScreenBridgeError, + metal_device_import_contract, +}; + +const NATIVE_REDUCTION_SHADER: &str = include_str!("native_reduction.metal"); + +/// Spatial filter executed by the native Metal reducer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MacosNativeReductionFilter { + /// Select the nearest complete source sample. + Nearest, + /// Interpolate four complete source samples. + Bilinear, + /// Integrate every covered complete source sample by area. + #[default] + Area, +} + +/// Output transfer function applied after linear-light spatial reduction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacosNativeOutputTransfer { + /// IEC 61966-2-1 sRGB encoding. + Srgb, + /// Linear normalized encoding. + Linear, + /// ITU-R BT.709 encoding. + Rec709, + /// ITU-R BT.2020 encoding. + Rec2020, +} + +/// Bars surrounding a materialized native reduction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MacosNativeLetterboxFill { + /// Fully transparent black. + Transparent, + /// RGBA color expressed as normalized channels. + Solid([f32; 4]), +} + +/// Eight-bit target storage format requested by the resolved descriptor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacosNativeTargetFormat { + /// Red, green, blue, alpha byte storage. + Rgba8, + /// Blue, green, red, alpha byte storage. + Bgra8, +} + +/// Canonical color-transform constants prepared by the shared color pipeline. +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MacosNativeColorTransform { + source_to_target: [[f32; 4]; 3], + source_luminance_and_exposure: [f32; 4], + curve: [f32; 4], +} + +impl MacosNativeColorTransform { + /// Copy the canonical 80-byte transform shared with the CPU reducer. + #[must_use] + pub const fn new( + source_to_target: [[f32; 4]; 3], + source_luminance_and_exposure: [f32; 4], + curve: [f32; 4], + ) -> Self { + Self { + source_to_target, + source_luminance_and_exposure, + curve, + } + } +} + +/// Complete geometry and color operation for one native reduction dispatch. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MacosNativeReductionDescriptor { + output_extent: [u32; 2], + content_rect: [u32; 4], + source_rect: [f32; 4], + filter: MacosNativeReductionFilter, + color: Option<(MacosNativeOutputTransfer, MacosNativeColorTransform)>, +} + +impl MacosNativeReductionDescriptor { + /// Construct and validate one reduction into a logical RGBA8 output. + /// + /// The source rectangle is expressed as pixel-edge coordinates in the + /// retained luma or packed-RGB storage plane. + pub fn new( + output_extent: [u32; 2], + content_rect: [u32; 4], + source_rect: [f32; 4], + filter: MacosNativeReductionFilter, + color: Option<(MacosNativeOutputTransfer, MacosNativeColorTransform)>, + ) -> Result { + if output_extent.contains(&0) || content_rect[2] == 0 || content_rect[3] == 0 { + return Err(MacosNativeReductionError::InvalidGeometry); + } + let content_right = content_rect[0] + .checked_add(content_rect[2]) + .ok_or(MacosNativeReductionError::InvalidGeometry)?; + let content_bottom = content_rect[1] + .checked_add(content_rect[3]) + .ok_or(MacosNativeReductionError::InvalidGeometry)?; + if content_right > output_extent[0] + || content_bottom > output_extent[1] + || !source_rect.into_iter().all(f32::is_finite) + || source_rect[0] < 0.0 + || source_rect[1] < 0.0 + || source_rect[2] <= 0.0 + || source_rect[3] <= 0.0 + { + return Err(MacosNativeReductionError::InvalidGeometry); + } + Ok(Self { + output_extent, + content_rect, + source_rect, + filter, + color, + }) + } + + /// Logical output extent produced by this dispatch. + #[must_use] + pub const fn output_extent(self) -> [u32; 2] { + self.output_extent + } +} + +/// Owner-backed RGBA8 texture written by native reduction and sampled by wgpu. +#[derive(Debug, Clone)] +pub struct MacosNativeReductionTarget { + width: u32, + height: u32, + format: MacosNativeTargetFormat, + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +impl MacosNativeReductionTarget { + /// Output width. + #[must_use] + pub const fn width(&self) -> u32 { + self.width + } + + /// Output height. + #[must_use] + pub const fn height(&self) -> u32 { + self.height + } + + /// Exact target byte storage format. + #[must_use] + pub const fn format(&self) -> MacosNativeTargetFormat { + self.format + } + + /// RGBA8 texture on the registered Metal device. + #[must_use] + pub const fn texture(&self) -> &wgpu::Texture { + &self.texture + } + + /// Default RGBA8 texture view. + #[must_use] + pub const fn view(&self) -> &wgpu::TextureView { + &self.view + } +} + +struct RetainedComputePipeline(Retained>); + +// SAFETY: Metal compute pipeline states are immutable and documented for +// concurrent command encoding after construction. +unsafe impl Send for RetainedComputePipeline {} + +// SAFETY: shared access invokes only immutable pipeline-state methods. +unsafe impl Sync for RetainedComputePipeline {} + +/// Metal compute pipeline converting retained capture planes into RGBA8. +pub struct MacosNativeReducer { + metal_registry_id: u64, + reduction_pipeline: RetainedComputePipeline, + materialization_pipeline: RetainedComputePipeline, +} + +/// Errors raised while preparing or executing native capture reduction. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum MacosNativeReductionError { + /// The requested source or output geometry is invalid. + #[error("invalid macOS native reduction geometry")] + InvalidGeometry, + /// The imported source planes contradict the capture format. + #[error("invalid macOS native reduction planes: {0}")] + InvalidPlanes(&'static str), + /// The target belongs to a different physical Metal device. + #[error("macOS native reduction target belongs to a different Metal device")] + DeviceMismatch, + /// The MSL library could not compile. + #[error("failed to compile macOS native reduction shader: {0}")] + ShaderCompilation(String), + /// One required MSL entry point was missing. + #[error("macOS native reduction shader has no {0} entry point")] + MissingEntryPoint(&'static str), + /// The Metal compute pipeline could not be created. + #[error("failed to create macOS native reduction pipeline: {0}")] + PipelineCreation(String), + /// The Metal device could not allocate the target texture. + #[error("failed to allocate macOS native reduction target")] + TargetAllocation, + /// The wgpu command encoder did not expose its Metal command buffer. + #[error("wgpu command encoder did not expose a Metal command buffer")] + MissingCommandBuffer, + /// IOSurface or wgpu Metal interop failed. + #[error(transparent)] + Interop(#[from] MacosGpuInteropError), + /// Capture-plane import failed. + #[error(transparent)] + ScreenBridge(#[from] MacosScreenBridgeError), +} + +impl MacosNativeReducer { + /// Compile the reducer on the exact Metal device backing `device`. + pub fn new(device: &wgpu::Device) -> Result { + let (metal_registry_id, _) = metal_device_import_contract(device)?; + let hal_device = require_metal_device(device)?; + let raw_device = hal_device.raw_device(); + let library = raw_device + .newLibraryWithSource_options_error(&NSString::from_str(NATIVE_REDUCTION_SHADER), None) + .map_err(|error| { + MacosNativeReductionError::ShaderCompilation( + error.localizedDescription().to_string(), + ) + })?; + let reduction_pipeline = create_pipeline(raw_device, &library, "hypercolor_reduce")?; + let materialization_pipeline = + create_pipeline(raw_device, &library, "hypercolor_materialize")?; + Ok(Self { + metal_registry_id, + reduction_pipeline, + materialization_pipeline, + }) + } + + /// Allocate one owner-backed RGBA8 target on the registered Metal device. + pub fn create_target( + &self, + device: &wgpu::Device, + width: u32, + height: u32, + format: MacosNativeTargetFormat, + ) -> Result { + if width == 0 || height == 0 { + return Err(MacosNativeReductionError::InvalidGeometry); + } + let (registry_id, _) = metal_device_import_contract(device)?; + if registry_id != self.metal_registry_id { + return Err(MacosNativeReductionError::DeviceMismatch); + } + let hal_device = require_metal_device(device)?; + let descriptor = MTLTextureDescriptor::new(); + descriptor.setTextureType(MTLTextureType::Type2D); + let (metal_format, wgpu_format) = match format { + MacosNativeTargetFormat::Rgba8 => { + (MTLPixelFormat::RGBA8Unorm, wgpu::TextureFormat::Rgba8Unorm) + } + MacosNativeTargetFormat::Bgra8 => { + (MTLPixelFormat::BGRA8Unorm, wgpu::TextureFormat::Bgra8Unorm) + } + }; + descriptor.setPixelFormat(metal_format); + // SAFETY: dimensions are validated as non-zero, the fixed counts are + // valid for a non-arrayed 2D texture, and no multiplication occurs. + unsafe { + descriptor.setWidth(width as usize); + descriptor.setHeight(height as usize); + descriptor.setMipmapLevelCount(1); + descriptor.setArrayLength(1); + descriptor.setSampleCount(1); + } + descriptor.setStorageMode(MTLStorageMode::Private); + descriptor.setUsage(MTLTextureUsage::ShaderRead | MTLTextureUsage::ShaderWrite); + let metal_texture = hal_device + .raw_device() + .newTextureWithDescriptor(&descriptor) + .ok_or(MacosNativeReductionError::TargetAllocation)?; + let wgpu_descriptor = wgpu::TextureDescriptor { + label: Some("macOS native screen reduction target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu_format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }; + let copy_size = wgpu_hal::CopyExtent { + width, + height, + depth: 1, + }; + // SAFETY: the texture was allocated by this wgpu device's raw Metal + // device and exactly matches the descriptor and copy extent. + let hal_texture = unsafe { + wgpu_hal::metal::Device::texture_from_raw( + metal_texture, + wgpu_format, + MTLTextureType::Type2D, + 1, + 1, + copy_size, + ) + }; + // SAFETY: the HAL texture belongs to this device and exactly matches + // the supplied wgpu descriptor. + let texture = unsafe { + device.create_texture_from_hal::(hal_texture, &wgpu_descriptor) + }; + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Ok(MacosNativeReductionTarget { + width, + height, + format, + texture, + view, + }) + } + + /// Encode one zero-copy source conversion and spatial reduction. + pub fn encode( + &self, + imported: &ImportedMacosScreenFrame, + target: &MacosNativeReductionTarget, + descriptor: MacosNativeReductionDescriptor, + encoder: &mut wgpu::CommandEncoder, + ) -> Result<(), MacosNativeReductionError> { + if descriptor.output_extent != [target.width, target.height] { + return Err(MacosNativeReductionError::InvalidGeometry); + } + let source = imported.capture(); + let storage = source.storage_extent; + if descriptor.source_rect[0] + descriptor.source_rect[2] > storage.width as f32 + || descriptor.source_rect[1] + descriptor.source_rect[3] > storage.height as f32 + { + return Err(MacosNativeReductionError::InvalidGeometry); + } + validate_plane_count(source.pixel_format, imported.planes().len())?; + let parameters = ReductionParameters::new(imported, descriptor)?; + let output = raw_metal_texture(&target.texture)?; + let first = &imported.planes()[0]; + first.with_metal_texture(|plane0| { + if let Some(second) = imported.planes().get(1) { + second.with_metal_texture(|plane1| { + self.encode_raw(encoder, plane0, plane1, &output, ¶meters) + })? + } else { + self.encode_raw(encoder, plane0, plane0, &output, ¶meters) + } + })??; + Ok(()) + } + + /// Encode bars and one physical target into an independently resolved output. + pub fn encode_materialization( + &self, + source: &MacosNativeReductionTarget, + target: &MacosNativeReductionTarget, + content_rect: [u32; 4], + fill: MacosNativeLetterboxFill, + encoder: &mut wgpu::CommandEncoder, + ) -> Result<(), MacosNativeReductionError> { + if content_rect[2] != source.width || content_rect[3] != source.height { + return Err(MacosNativeReductionError::InvalidGeometry); + } + if source.format != target.format { + return Err(MacosNativeReductionError::InvalidPlanes( + "physical and logical target formats differ", + )); + } + let right = content_rect[0] + .checked_add(content_rect[2]) + .ok_or(MacosNativeReductionError::InvalidGeometry)?; + let bottom = content_rect[1] + .checked_add(content_rect[3]) + .ok_or(MacosNativeReductionError::InvalidGeometry)?; + if right > target.width || bottom > target.height { + return Err(MacosNativeReductionError::InvalidGeometry); + } + let parameters = MaterializationParameters { + content_rect, + output_extent: [target.width, target.height, 0, 0], + fill: match fill { + MacosNativeLetterboxFill::Transparent => [0.0; 4], + MacosNativeLetterboxFill::Solid(color) => color, + }, + }; + let source = raw_metal_texture(&source.texture)?; + let target = raw_metal_texture(&target.texture)?; + // SAFETY: the callback borrows the raw encoder only for this encoding + // operation. The Metal command buffer remains owned and ended by wgpu. + unsafe { + encoder.as_hal_mut::(|hal_encoder| { + let hal_encoder = + hal_encoder.ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + let command_buffer = hal_encoder + .raw_command_buffer() + .ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + let compute = command_buffer + .computeCommandEncoder() + .ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + compute.setComputePipelineState(&self.materialization_pipeline.0); + compute.setTexture_atIndex(Some(source.raw_handle()), 0); + compute.setTexture_atIndex(Some(target.raw_handle()), 1); + compute.setBytes_length_atIndex( + NonNull::from(¶meters).cast(), + size_of::(), + 0, + ); + compute.dispatchThreads_threadsPerThreadgroup( + MTLSize { + width: parameters.output_extent[0] as usize, + height: parameters.output_extent[1] as usize, + depth: 1, + }, + MTLSize { + width: 8, + height: 8, + depth: 1, + }, + ); + compute.endEncoding(); + Ok(()) + }) + } + } + + fn encode_raw( + &self, + encoder: &mut wgpu::CommandEncoder, + plane0: &ProtocolObject, + plane1: &ProtocolObject, + output: &impl std::ops::Deref, + parameters: &ReductionParameters, + ) -> Result<(), MacosNativeReductionError> { + // SAFETY: the callback borrows the raw encoder only for this encoding + // operation. The Metal command buffer remains owned and ended by wgpu. + unsafe { + encoder.as_hal_mut::(|hal_encoder| { + let hal_encoder = + hal_encoder.ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + let command_buffer = hal_encoder + .raw_command_buffer() + .ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + let compute = command_buffer + .computeCommandEncoder() + .ok_or(MacosNativeReductionError::MissingCommandBuffer)?; + compute.setComputePipelineState(&self.reduction_pipeline.0); + compute.setTexture_atIndex(Some(plane0), 0); + compute.setTexture_atIndex(Some(plane1), 1); + compute.setTexture_atIndex(Some(output.raw_handle()), 2); + compute.setBytes_length_atIndex( + NonNull::from(parameters).cast(), + size_of::(), + 0, + ); + compute.dispatchThreads_threadsPerThreadgroup( + MTLSize { + width: parameters.output_and_format[0] as usize, + height: parameters.output_and_format[1] as usize, + depth: 1, + }, + MTLSize { + width: 8, + height: 8, + depth: 1, + }, + ); + compute.endEncoding(); + Ok(()) + }) + } + } +} + +#[repr(C, align(16))] +struct MaterializationParameters { + content_rect: [u32; 4], + output_extent: [u32; 4], + fill: [f32; 4], +} + +#[repr(C, align(16))] +struct ReductionParameters { + content_rect: [u32; 4], + output_and_format: [u32; 4], + source_rect: [f32; 4], + source_and_chroma_extent: [u32; 4], + color: [u32; 4], + operation: [u32; 4], + transform: MacosNativeColorTransform, +} + +impl ReductionParameters { + fn new( + imported: &ImportedMacosScreenFrame, + descriptor: MacosNativeReductionDescriptor, + ) -> Result { + let capture = imported.capture(); + capture + .color + .validate_for(capture.pixel_format) + .map_err(|_| { + MacosNativeReductionError::InvalidPlanes( + "capture color metadata does not match the source format", + ) + })?; + let format = source_format_code(capture.pixel_format); + let range = u32::from(capture.color.range == MacosColorRange::Video); + let matrix = match capture.color.matrix { + None => 0, + Some(MacosYuvMatrix::Bt601) => 1, + Some(MacosYuvMatrix::Bt709) => 2, + Some(MacosYuvMatrix::Bt2020) => 3, + }; + let chroma_location = match capture.color.chroma_location { + None => 0, + Some(MacosChromaLocation::Center) => 1, + Some(MacosChromaLocation::Left) => 2, + Some(MacosChromaLocation::TopLeft) => 3, + }; + let source_transfer = source_transfer_code(capture.color.transfer); + let (managed, output_transfer, transform) = descriptor.color.map_or_else( + || (0, 0, identity_color_transform()), + |(output, transform)| { + let output_transfer = match output { + MacosNativeOutputTransfer::Srgb => 0, + MacosNativeOutputTransfer::Linear => 1, + MacosNativeOutputTransfer::Rec709 => 2, + MacosNativeOutputTransfer::Rec2020 => 3, + }; + (1, output_transfer, transform) + }, + ); + let chroma = imported + .planes() + .get(1) + .map_or(capture.storage_extent, |plane| { + plane.storage_identity().extent + }); + Ok(Self { + content_rect: descriptor.content_rect, + output_and_format: [ + descriptor.output_extent[0], + descriptor.output_extent[1], + format, + descriptor.filter as u32, + ], + source_rect: descriptor.source_rect, + source_and_chroma_extent: [ + capture.storage_extent.width, + capture.storage_extent.height, + chroma.width, + chroma.height, + ], + color: [range, matrix, chroma_location, source_transfer], + operation: [managed, output_transfer, 0, 0], + transform, + }) + } +} + +fn identity_color_transform() -> MacosNativeColorTransform { + MacosNativeColorTransform::new( + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ], + [0.212_639, 0.715_168_65, 0.072_192_32, 1.0], + [1.0, 1.0, 1.0, 1.0], + ) +} + +const fn source_format_code(format: MacosCapturePixelFormat) -> u32 { + match format { + MacosCapturePixelFormat::Bgra8 => 0, + MacosCapturePixelFormat::Argb2101010 => 1, + MacosCapturePixelFormat::Rgba16Float => 2, + MacosCapturePixelFormat::Yuv420VideoRange | MacosCapturePixelFormat::Yuv420FullRange => 3, + MacosCapturePixelFormat::Yuv44410BiPlanar => 4, + } +} + +const fn source_transfer_code(transfer: MacosTransferFunction) -> u32 { + match transfer { + MacosTransferFunction::Srgb => 0, + MacosTransferFunction::Rec709 => 1, + MacosTransferFunction::Rec2020 => 2, + MacosTransferFunction::Linear => 3, + MacosTransferFunction::Pq => 4, + MacosTransferFunction::Hlg => 5, + } +} + +fn validate_plane_count( + format: MacosCapturePixelFormat, + plane_count: usize, +) -> Result<(), MacosNativeReductionError> { + let expected = if matches!( + format, + MacosCapturePixelFormat::Yuv420VideoRange + | MacosCapturePixelFormat::Yuv420FullRange + | MacosCapturePixelFormat::Yuv44410BiPlanar + ) { + 2 + } else { + 1 + }; + if plane_count == expected { + Ok(()) + } else { + Err(MacosNativeReductionError::InvalidPlanes( + "plane count does not match the capture format", + )) + } +} + +fn require_metal_device( + device: &wgpu::Device, +) -> Result + '_, MacosGpuInteropError> { + // SAFETY: the HAL device is borrowed only for immediate Metal allocation + // or pipeline construction and never outlives the wgpu device. + unsafe { device.as_hal::() } + .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice) +} + +fn raw_metal_texture( + texture: &wgpu::Texture, +) -> Result + '_, MacosGpuInteropError> { + // SAFETY: the guard is borrowed only for immediate command encoding and + // the target was constructed on the Metal backend by this module. + unsafe { texture.as_hal::() } + .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice) +} + +fn create_pipeline( + device: &ProtocolObject, + library: &ProtocolObject, + entry_point: &'static str, +) -> Result { + let function = library + .newFunctionWithName(&NSString::from_str(entry_point)) + .ok_or(MacosNativeReductionError::MissingEntryPoint(entry_point))?; + device + .newComputePipelineStateWithFunction_error(&function) + .map(RetainedComputePipeline) + .map_err(|error| { + MacosNativeReductionError::PipelineCreation(error.localizedDescription().to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn descriptor_rejects_content_outside_output() { + assert!(matches!( + MacosNativeReductionDescriptor::new( + [8, 8], + [4, 4, 5, 4], + [0.0, 0.0, 8.0, 8.0], + MacosNativeReductionFilter::Area, + None, + ), + Err(MacosNativeReductionError::InvalidGeometry) + )); + } + + #[test] + fn canonical_transform_stays_gpu_abi_compatible() { + assert_eq!(size_of::(), 80); + assert_eq!(align_of::(), 16); + assert_eq!(size_of::(), 176); + assert_eq!(align_of::(), 16); + assert_eq!(size_of::(), 48); + assert_eq!(align_of::(), 16); + } +} diff --git a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs index c7b631eef..da57e0eef 100644 --- a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -115,12 +115,18 @@ pub enum ImportedMacosScreenPlaneFormat { Wgpu(ImportedFrameFormat), /// ScreenCaptureKit `l10r` represented by Metal BGR10A2 semantics. Bgr10A2Unorm, + /// ScreenCaptureKit `xf44` luma represented by native Metal R16 semantics. + R16Unorm, + /// ScreenCaptureKit `xf44` chroma represented by native Metal RG16 semantics. + Rg16Unorm, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum CapturePlaneImportDescriptor { Wgpu(MacosIosurfaceImportDescriptor), Bgr10A2Unorm { width: u32, height: u32 }, + R16Unorm { width: u32, height: u32 }, + Rg16Unorm { width: u32, height: u32 }, } impl CapturePlaneImportDescriptor { @@ -143,6 +149,14 @@ impl CapturePlaneImportDescriptor { } Ok(Self::Bgr10A2Unorm { width, height }) } + ImportedMacosScreenPlaneFormat::R16Unorm => { + validate_native_dimensions(width, height, 2)?; + Ok(Self::R16Unorm { width, height }) + } + ImportedMacosScreenPlaneFormat::Rg16Unorm => { + validate_native_dimensions(width, height, 4)?; + Ok(Self::Rg16Unorm { width, height }) + } } } @@ -150,10 +164,27 @@ impl CapturePlaneImportDescriptor { match self { Self::Wgpu(descriptor) => descriptor.width * descriptor.format.bytes_per_texel(), Self::Bgr10A2Unorm { width, .. } => width * 4, + Self::R16Unorm { width, .. } => width * 2, + Self::Rg16Unorm { width, .. } => width * 4, } } } +fn validate_native_dimensions( + width: u32, + height: u32, + bytes_per_texel: u32, +) -> Result<(), MacosScreenBridgeError> { + if width == 0 + || height == 0 + || width > i32::MAX as u32 / bytes_per_texel + || height > i32::MAX as u32 + { + return Err(MacosGpuInteropError::InvalidDimensions { width, height }.into()); + } + Ok(()) +} + impl ImportedMacosScreenFrame { /// Complete physical storage identity of the first imported plane. /// @@ -498,8 +529,11 @@ impl MacosScreenBridge { core_video_wrapper: None, } } - CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } => self + CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } + | CapturePlaneImportDescriptor::R16Unorm { width, height } + | CapturePlaneImportDescriptor::Rg16Unorm { width, height } => self .native_wrapper_or_insert(storage_identity, || { + let (format, metal_format) = native_plane_format(*descriptor); let texture = import_iosurface_metal_texture_plane( device, iosurface, @@ -507,12 +541,12 @@ impl MacosScreenBridge { *height, plane_index, source_pixel_format, - MTLPixelFormat::BGR10A2Unorm, + metal_format, self.storage_mode, )?; Ok(ImportedMacosScreenPlane { storage_identity, - format: ImportedMacosScreenPlaneFormat::Bgr10A2Unorm, + format, storage: ImportedMacosScreenPlaneStorage::NativeMetal( RetainedMetalTexture(texture), ), @@ -578,20 +612,23 @@ impl MacosScreenBridge { core_video_wrapper: Some(RetainedCoreVideoTexture { _wrapper: wrapper }), } } - CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } => { + CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } + | CapturePlaneImportDescriptor::R16Unorm { width, height } + | CapturePlaneImportDescriptor::Rg16Unorm { width, height } => { + let (format, metal_format) = native_plane_format(*descriptor); let (texture, wrapper, _) = import_core_video_metal_texture_plane( cache.cache(), pixel_buffer, *width, *height, plane_index, - MTLPixelFormat::BGR10A2Unorm, + metal_format, frame.surface.iosurface_id, self.storage_mode, )?; ImportedMacosScreenPlane { storage_identity, - format: ImportedMacosScreenPlaneFormat::Bgr10A2Unorm, + format, storage: ImportedMacosScreenPlaneStorage::NativeMetal( RetainedMetalTexture(texture), ), @@ -832,8 +869,8 @@ fn capture_plane_formats( ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg8Unorm), ]; const YUV44410: &[ImportedMacosScreenPlaneFormat] = &[ - ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R16Unorm), - ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg16Unorm), + ImportedMacosScreenPlaneFormat::R16Unorm, + ImportedMacosScreenPlaneFormat::Rg16Unorm, ]; match pixel_format { @@ -847,6 +884,28 @@ fn capture_plane_formats( } } +fn native_plane_format( + descriptor: CapturePlaneImportDescriptor, +) -> (ImportedMacosScreenPlaneFormat, MTLPixelFormat) { + match descriptor { + CapturePlaneImportDescriptor::Bgr10A2Unorm { .. } => ( + ImportedMacosScreenPlaneFormat::Bgr10A2Unorm, + MTLPixelFormat::BGR10A2Unorm, + ), + CapturePlaneImportDescriptor::R16Unorm { .. } => ( + ImportedMacosScreenPlaneFormat::R16Unorm, + MTLPixelFormat::R16Unorm, + ), + CapturePlaneImportDescriptor::Rg16Unorm { .. } => ( + ImportedMacosScreenPlaneFormat::Rg16Unorm, + MTLPixelFormat::RG16Unorm, + ), + CapturePlaneImportDescriptor::Wgpu(_) => { + unreachable!("wgpu planes never enter native format selection") + } + } +} + const fn capture_plane_extent( pixel_format: MacosCapturePixelFormat, storage_extent: MacosPixelExtent, @@ -951,8 +1010,8 @@ mod tests { capture_plane_formats(MacosCapturePixelFormat::Yuv44410BiPlanar) .expect("10-bit YUV should import directly"), &[ - ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::R16Unorm), - ImportedMacosScreenPlaneFormat::Wgpu(ImportedFrameFormat::Rg16Unorm) + ImportedMacosScreenPlaneFormat::R16Unorm, + ImportedMacosScreenPlaneFormat::Rg16Unorm ] ); assert_eq!( diff --git a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs index f934ece0f..84cf19295 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -4,10 +4,15 @@ use std::sync::{Arc, mpsc}; use hypercolor_macos_capture::{ MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, - MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, MacosPixelRect, - MacosPointRect, MacosScale, MacosTransferFunction, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + MacosYuvMatrix, +}; +use hypercolor_macos_gpu_interop::{ + MacosMetalStorageMode, MacosNativeLetterboxFill, MacosNativeReducer, + MacosNativeReductionDescriptor, MacosNativeReductionError, MacosNativeReductionFilter, + MacosNativeTargetFormat, MacosScreenBridge, }; -use hypercolor_macos_gpu_interop::{MacosMetalStorageMode, MacosScreenBridge}; const WIDTH: u32 = 4; const HEIGHT: u32 = 3; @@ -91,6 +96,216 @@ fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), S Ok(()) } +#[test] +fn native_reducer_compiles_and_reads_back_spatially_reduced_rgba() -> Result<(), String> { + let wgpu = WgpuFixture::new()?; + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let reducer = MacosNativeReducer::new(&wgpu.device).map_err(|error| error.to_string())?; + let gradient_row = [ + 0_u8, 0, 0, 255, 20, 40, 60, 255, 40, 80, 120, 255, 60, 120, 180, 255, + ]; + let gradient = gradient_row.repeat(HEIGHT as usize); + let extent = MacosPixelExtent::new(WIDTH, HEIGHT).map_err(|error| error.to_string())?; + let color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let imported = bridge + .import_frame( + &wgpu.device, + 11, + Arc::new(native_capture_frame( + extent, + MacosCapturePixelFormat::Bgra8, + color, + &[gradient], + 1, + )?), + ) + .map_err(|error| error.to_string())?; + let target = reducer + .create_target(&wgpu.device, 2, 1, MacosNativeTargetFormat::Rgba8) + .map_err(|error| error.to_string())?; + let descriptor = MacosNativeReductionDescriptor::new( + [2, 1], + [0, 0, 2, 1], + [0.0, 0.0, WIDTH as f32, HEIGHT as f32], + MacosNativeReductionFilter::Area, + None, + ) + .map_err(|error| error.to_string())?; + let mut encoder = wgpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("hypercolor macOS native reduction fixture"), + }); + reducer + .encode(&imported, &target, descriptor, &mut encoder) + .map_err(|error| error.to_string())?; + let _ = wgpu.queue.submit(Some(encoder.finish())); + + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, target.texture(), 2, 1)?, + [30, 20, 10, 255, 150, 100, 50, 255] + ); + + let transparent = reducer + .create_target(&wgpu.device, 4, 3, MacosNativeTargetFormat::Rgba8) + .map_err(|error| error.to_string())?; + let solid = reducer + .create_target(&wgpu.device, 4, 3, MacosNativeTargetFormat::Rgba8) + .map_err(|error| error.to_string())?; + let mut encoder = wgpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("hypercolor macOS native materialization fixture"), + }); + reducer + .encode_materialization( + &target, + &transparent, + [1, 1, 2, 1], + MacosNativeLetterboxFill::Transparent, + &mut encoder, + ) + .map_err(|error| error.to_string())?; + reducer + .encode_materialization( + &target, + &solid, + [1, 1, 2, 1], + MacosNativeLetterboxFill::Solid([ + 7.0 / 255.0, + 11.0 / 255.0, + 13.0 / 255.0, + 17.0 / 255.0, + ]), + &mut encoder, + ) + .map_err(|error| error.to_string())?; + let _ = wgpu.queue.submit(Some(encoder.finish())); + let mut transparent_expected = vec![0; 4 * 3 * 4]; + transparent_expected[20..28].copy_from_slice(&[30, 20, 10, 255, 150, 100, 50, 255]); + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, transparent.texture(), 4, 3,)?, + transparent_expected + ); + let mut solid_expected = [7, 11, 13, 17].repeat(12); + solid_expected[20..28].copy_from_slice(&[30, 20, 10, 255, 150, 100, 50, 255]); + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, solid.texture(), 4, 3)?, + solid_expected + ); + Ok(()) +} + +#[test] +fn every_native_format_matches_the_scalar_source_oracle() -> Result<(), String> { + let wgpu = WgpuFixture::new()?; + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let reducer = MacosNativeReducer::new(&wgpu.device).map_err(|error| error.to_string())?; + let extent = MacosPixelExtent::new(3, 3).map_err(|error| error.to_string())?; + let fixtures = native_format_vectors(); + + for (index, (format, color, planes)) in fixtures.into_iter().enumerate() { + let frame = Arc::new(native_capture_frame( + extent, + format, + color, + &planes, + u64::try_from(index + 1).map_err(|error| error.to_string())?, + )?); + let expected = scalar_rgba8(&frame)?; + let imported = bridge + .import_frame(&wgpu.device, 17, frame) + .map_err(|error| error.to_string())?; + let target = reducer + .create_target( + &wgpu.device, + extent.width, + extent.height, + MacosNativeTargetFormat::Rgba8, + ) + .map_err(|error| error.to_string())?; + let descriptor = MacosNativeReductionDescriptor::new( + [extent.width, extent.height], + [0, 0, extent.width, extent.height], + [0.0, 0.0, extent.width as f32, extent.height as f32], + MacosNativeReductionFilter::Nearest, + None, + ) + .map_err(|error| error.to_string())?; + let mut encoder = wgpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("hypercolor macOS native format parity fixture"), + }); + reducer + .encode(&imported, &target, descriptor, &mut encoder) + .map_err(|error| error.to_string())?; + let _ = wgpu.queue.submit(Some(encoder.finish())); + let actual = read_texture_pixels( + &wgpu.device, + &wgpu.queue, + target.texture(), + extent.width, + extent.height, + )?; + assert_eq!(actual, expected, "{format:?} scalar parity"); + } + Ok(()) +} + +#[test] +fn native_reducer_rejects_missing_yuv_color_metadata() -> Result<(), String> { + let wgpu = WgpuFixture::new()?; + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let reducer = MacosNativeReducer::new(&wgpu.device).map_err(|error| error.to_string())?; + let extent = MacosPixelExtent::new(1, 1).map_err(|error| error.to_string())?; + let valid_color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(MacosYuvMatrix::Bt2020), + range: MacosColorRange::Video, + chroma_location: Some(MacosChromaLocation::Center), + }; + let mut frame = native_capture_frame( + extent, + MacosCapturePixelFormat::Yuv420VideoRange, + valid_color, + &[vec![128], vec![64, 192]], + 1, + )?; + frame.color.matrix = None; + let imported = bridge + .import_frame(&wgpu.device, 31, Arc::new(frame)) + .map_err(|error| error.to_string())?; + let target = reducer + .create_target(&wgpu.device, 1, 1, MacosNativeTargetFormat::Rgba8) + .map_err(|error| error.to_string())?; + let descriptor = MacosNativeReductionDescriptor::new( + [1, 1], + [0, 0, 1, 1], + [0.0, 0.0, 1.0, 1.0], + MacosNativeReductionFilter::Nearest, + None, + ) + .map_err(|error| error.to_string())?; + let mut encoder = wgpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("hypercolor invalid YUV metadata fixture"), + }); + assert!(matches!( + reducer.encode(&imported, &target, descriptor, &mut encoder), + Err(MacosNativeReductionError::InvalidPlanes(_)) + )); + Ok(()) +} + fn capture_frame() -> Result { let extent = MacosPixelExtent::new(WIDTH, HEIGHT).map_err(|error| error.to_string())?; let pixels = fixture_pixels(); @@ -127,6 +342,179 @@ fn capture_frame() -> Result { }) } +fn native_capture_frame( + extent: MacosPixelExtent, + format: MacosCapturePixelFormat, + color: MacosCaptureColorimetry, + planes: &[Vec], + sequence: u64, +) -> Result { + let borrowed = planes.iter().map(Vec::as_slice).collect::>(); + let (surface, planes) = + MacosCaptureSurface::new_native_fixture(extent, format, color, &borrowed) + .map_err(|error| error.to_string())?; + Ok(MacosCaptureFrame { + epoch: 5, + sequence, + display_time: 13 + sequence, + storage_extent: extent, + planes: Arc::from(planes), + pixel_format: format, + color, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0).map_err(|error| error.to_string())?, + content_scale: MacosScale::new(1.0).map_err(|error| error.to_string())?, + content_rect_points: MacosPointRect::new( + 0.0, + 0.0, + extent.width.into(), + extent.height.into(), + ) + .map_err(|error| error.to_string())?, + content_rect_pixels: MacosPixelRect::new(0, 0, extent.width, extent.height) + .map_err(|error| error.to_string())?, + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([]), + cursor_composed: false, + surface, + }) +} + +fn native_format_vectors() -> Vec<( + MacosCapturePixelFormat, + MacosCaptureColorimetry, + Vec>, +)> { + let rgb = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let linear = MacosCaptureColorimetry { + transfer: MacosTransferFunction::Linear, + ..rgb + }; + let yuv = |range, matrix, chroma_location| MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Rec2020, + transfer: MacosTransferFunction::Pq, + matrix: Some(matrix), + range, + chroma_location: Some(chroma_location), + }; + let bgra = (0..9_u8) + .flat_map(|value| [value * 17, 255 - value * 13, value * 23, 255]) + .collect(); + let l10r: Vec = [ + (0_u32, 0_u32, 0_u32, 3_u32), + (1_023, 0, 0, 3), + (0, 1_023, 0, 3), + (0, 0, 1_023, 3), + (512, 256, 768, 2), + (128, 900, 64, 1), + (900, 128, 512, 3), + (1023, 1023, 1023, 3), + (64, 32, 16, 0), + ] + .into_iter() + .flat_map(|(r, g, b, a): (u32, u32, u32, u32)| { + ((a << 30) | (r << 20) | (g << 10) | b).to_le_bytes() + }) + .collect(); + let rgha = [ + [0x0000, 0x0000, 0x0000, 0x3c00], + [0x3c00, 0x0000, 0x0000, 0x3c00], + [0x0000, 0x3c00, 0x0000, 0x3c00], + [0x0000, 0x0000, 0x3c00, 0x3c00], + [0x4000, 0x3800, 0xbc00, 0x3800], + [0x3400, 0x3a00, 0x3e00, 0x3c00], + [0x3b00, 0x3900, 0x3700, 0x3c00], + [0x3c00, 0x3c00, 0x3c00, 0x3c00], + [0x2c00, 0x3000, 0x3400, 0x0000], + ] + .into_iter() + .flatten() + .flat_map(u16::to_le_bytes) + .collect(); + let luma_video = vec![16, 64, 128, 192, 235, 96, 32, 160, 224]; + let luma_full = vec![0, 31, 63, 95, 127, 159, 191, 223, 255]; + let chroma = vec![16, 240, 128, 128, 240, 16, 64, 192]; + let xf_luma = [0_u16, 64, 256, 512, 768, 876, 940, 1023, 128] + .into_iter() + .flat_map(|code| (code << 6).to_le_bytes()) + .collect(); + let xf_chroma: Vec = [ + (512_u16, 512_u16), + (64, 960), + (960, 64), + (256, 768), + (768, 256), + (512, 960), + (960, 512), + (128, 128), + (896, 896), + ] + .into_iter() + .flat_map(|(cb, cr): (u16, u16)| [(cb << 6).to_le_bytes(), (cr << 6).to_le_bytes()]) + .flatten() + .collect(); + vec![ + (MacosCapturePixelFormat::Bgra8, rgb, vec![bgra]), + (MacosCapturePixelFormat::Argb2101010, linear, vec![l10r]), + (MacosCapturePixelFormat::Rgba16Float, linear, vec![rgha]), + ( + MacosCapturePixelFormat::Yuv420VideoRange, + yuv( + MacosColorRange::Video, + MacosYuvMatrix::Bt709, + MacosChromaLocation::Left, + ), + vec![luma_video, chroma.clone()], + ), + ( + MacosCapturePixelFormat::Yuv420FullRange, + yuv( + MacosColorRange::Full, + MacosYuvMatrix::Bt2020, + MacosChromaLocation::TopLeft, + ), + vec![luma_full, chroma], + ), + ( + MacosCapturePixelFormat::Yuv44410BiPlanar, + yuv( + MacosColorRange::Video, + MacosYuvMatrix::Bt601, + MacosChromaLocation::Center, + ), + vec![xf_luma, xf_chroma], + ), + ] +} + +fn scalar_rgba8(frame: &MacosCaptureFrame) -> Result, String> { + frame + .with_cpu_source(|source| { + let mut output = Vec::new(); + for y in 0..source.extent().height { + for x in 0..source.extent().width { + let pixel = source + .sample_rgba32f(x, y) + .map_err(|error| error.to_string())?; + output.extend( + pixel.map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8), + ); + } + } + Ok(output) + }) + .map_err(|error| error.to_string())? +} + struct WgpuFixture { _instance: wgpu::Instance, device: wgpu::Device, From 71fe1c30c830e2dc6289c1476bb90abcf5cefaa5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:32:58 -0700 Subject: [PATCH 078/144] docs(macos): align installer signing status Record the manifest-driven Developer ID actor as the current release path. The same actor signs, notarizes, and verifies release artifacts. Preserve local unsigned builds as an intentional development workflow. Co-Authored-By: Nova (GPT-5.6) --- docs/specs/67-macos-installer.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/67-macos-installer.md b/docs/specs/67-macos-installer.md index f8a0fb56d..5d4b49015 100644 --- a/docs/specs/67-macos-installer.md +++ b/docs/specs/67-macos-installer.md @@ -4,7 +4,8 @@ > hardware-key infrastructure required to ship a Developer ID notarized DMG, and > the exact patches to apply once the Apple credentials are provisioned. -**Status:** Active — local + CI scaffolding wired, signing/notarization deferred until creds exist +**Status:** Implemented. Release CI uses the manifest-driven Developer ID +signing and notarization actor; local unsigned builds remain available. **Scope:** `scripts/build-mac-installer.sh`, `scripts/generate-mac-icons.sh`, `crates/hypercolor-app/icons/`, `crates/hypercolor-app/tauri.conf.json`, `.github/workflows/ci.yml` (mac branches of `build-native-app`) @@ -28,7 +29,7 @@ Local and CI builds both produce per-arch DMG + `.app` artifacts via Tauri 2's b | `Info.plist` with microphone and screen-capture purpose strings; no Apple Events string | `crates/hypercolor-app/Info.plist` | Live | | Exact six-key daemon hardened-runtime entitlement profile | `packaging/macos/daemon.entitlements.plist` | Live | | Sidecar staging (daemon + CLI under `target/bundle-stage/binaries/`) | `scripts/stage-app-bundle-assets.sh` | Live | -| Per-arch CI build matrix (`macos-arm64`, `macos-x64`) | `.github/workflows/ci.yml` § `build-native-app` | Live, currently `--no-sign` | +| Per-arch CI build matrix (`macos-arm64`, `macos-x64`) | `.github/workflows/ci.yml` § `build-native-app` | Live; release artifacts are signed and notarized by `scripts/sign-macos-artifacts.sh` | | DMG artifact name normalization to `Hypercolor--.dmg` | `.github/workflows/ci.yml` § Normalize macOS DMG | Live | | Homebrew Cask template with per-arch SHA placeholders | `packaging/homebrew/hypercolor-app.rb` | Live | | Cask publish step (commits to `hyperb1iss/homebrew-tap`) | `.github/workflows/ci.yml` § `update-homebrew` | Live | From 0a923ba984997d822b523d14268dfb6227258e5c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:53:07 -0700 Subject: [PATCH 079/144] feat(input): expose exact screen consumer counts Publish the count from the committed screen publication plan through source status, REST, safe WebSocket events, and generated clients. The settings UI now distinguishes zero, one, and multiple active consumers without inferring activity from the broader demand bit. Keep tolerant UI decoding for older daemons while pinning the canonical server and protocol payloads to the required field. Co-Authored-By: Nova (GPT-5.6 Codex) --- crates/hypercolor-core/src/bus/mod.rs | 2 ++ crates/hypercolor-core/tests/bus_tests.rs | 1 + crates/hypercolor-daemon/src/api/system.rs | 4 +++ crates/hypercolor-daemon/src/api/ws/tests.rs | 2 ++ crates/hypercolor-ui/src/api/system.rs | 4 +++ .../src/components/settings_sections.rs | 27 ++++++++++++++----- crates/hypercolor-ui/src/ws/messages.rs | 24 +++++++++++++++++ protocol/websocket-v1.json | 1 + .../_generated/models/input_source_status.py | 8 ++++++ 9 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/hypercolor-core/src/bus/mod.rs b/crates/hypercolor-core/src/bus/mod.rs index aabb84f84..c492271f9 100644 --- a/crates/hypercolor-core/src/bus/mod.rs +++ b/crates/hypercolor-core/src/bus/mod.rs @@ -59,6 +59,7 @@ struct InputSourceStatusEvent { configured: bool, consented: bool, demanded: bool, + active_consumer_count: usize, state: &'static str, freshness: &'static str, source_graph_generation: u64, @@ -81,6 +82,7 @@ impl From<&SourceStatus> for InputSourceStatusEvent { configured: status.configured, consented: status.consented, demanded: status.demanded, + active_consumer_count: status.active_consumer_count, state: source_state_name(status.state), freshness: source_freshness_name(status.freshness), source_graph_generation: status.source_graph_generation, diff --git a/crates/hypercolor-core/tests/bus_tests.rs b/crates/hypercolor-core/tests/bus_tests.rs index 1dc7abb86..12627b29f 100644 --- a/crates/hypercolor-core/tests/bus_tests.rs +++ b/crates/hypercolor-core/tests/bus_tests.rs @@ -1093,6 +1093,7 @@ async fn input_status_events_are_content_safe_coalesced_and_generation_aware() { assert_eq!(payload["source_id"], "host-interaction"); assert_eq!(payload["kind"], "interaction"); assert_eq!(payload["backend"], "raw_input"); + assert_eq!(payload["active_consumer_count"], 0); assert_eq!( payload["session_generation"], first_session.session_generation() diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index 81aacd625..58683abed 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -318,6 +318,7 @@ pub struct InputSourceStatus { pub configured: bool, pub consented: bool, pub demanded: bool, + pub active_consumer_count: usize, pub state: String, pub freshness: String, pub source_graph_generation: u64, @@ -715,6 +716,7 @@ fn input_source_status(source: &SourceStatus, now: Instant) -> InputSourceStatus configured: source.configured, consented: source.consented, demanded: source.demanded, + active_consumer_count: source.active_consumer_count, state: source_state_name(source.state).to_owned(), freshness: source_freshness_name(source.freshness).to_owned(), source_graph_generation: source.source_graph_generation, @@ -1949,6 +1951,7 @@ mod tests { configured: true, consented: true, demanded: true, + active_consumer_count: 2, state: SourceState::Live, freshness: SourceFreshness::NotApplicable, source_graph_generation: 7, @@ -2061,6 +2064,7 @@ mod tests { let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); let value = serde_json::to_value(status).expect("screen status should serialize"); + assert_eq!(value["active_consumer_count"], 2); assert_eq!( value["platform"], json!({ diff --git a/crates/hypercolor-daemon/src/api/ws/tests.rs b/crates/hypercolor-daemon/src/api/ws/tests.rs index 6d6c10e3e..528cd32d8 100644 --- a/crates/hypercolor-daemon/src/api/ws/tests.rs +++ b/crates/hypercolor-daemon/src/api/ws/tests.rs @@ -2551,6 +2551,7 @@ fn event_message_parts_exposes_input_status_as_a_dedicated_safe_event() { kind: hypercolor_core::bus::INPUT_STATUS_EVENT_KIND.to_owned(), payload: serde_json::json!({ "source_id": "host-interaction", + "active_consumer_count": 3, "state": "failed", "session_generation": 9, }), @@ -2559,6 +2560,7 @@ fn event_message_parts_exposes_input_status_as_a_dedicated_safe_event() { let (event_name, event_data) = event_message_parts(&event); assert_eq!(event_name, "input_source_status_changed"); assert_eq!(event_data["source_id"], "host-interaction"); + assert_eq!(event_data["active_consumer_count"], 3); assert_eq!(event_data["state"], "failed"); assert_eq!(event_data["session_generation"], 9); } diff --git a/crates/hypercolor-ui/src/api/system.rs b/crates/hypercolor-ui/src/api/system.rs index 110b168f5..aa84ca310 100644 --- a/crates/hypercolor-ui/src/api/system.rs +++ b/crates/hypercolor-ui/src/api/system.rs @@ -174,6 +174,7 @@ pub struct InputSourceStatus { pub configured: bool, pub consented: bool, pub demanded: bool, + pub active_consumer_count: usize, pub state: String, pub freshness: String, pub source_graph_generation: u64, @@ -361,6 +362,7 @@ mod tests { #[test] fn input_source_status_decodes_macos_screen_platform_tolerantly() { let status: InputSourceStatus = serde_json::from_value(json!({ + "active_consumer_count": 3, "platform": { "type": "macos_screen", "state": "interrupted", @@ -387,6 +389,7 @@ mod tests { } })) .expect("macOS screen status should decode"); + assert_eq!(status.active_consumer_count, 3); let Some(InputSourcePlatformStatus::MacosScreen { state, @@ -437,6 +440,7 @@ mod tests { .expect("status without platform should decode"); assert_eq!(status.source_id, "linux:host-input"); + assert_eq!(status.active_consumer_count, 0); assert_eq!(status.platform, None); } diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index 3765817d5..d16ef18f1 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -672,18 +672,14 @@ fn screen_source_view(source: InputSourceStatus) -> impl IntoView { }; let issue_message = issue.map(|issue| issue.message.clone()); let source_remediation = issue.and_then(|issue| issue.remediation.clone()); - let demand = if source.demanded { - "active demand" - } else { - "no active consumers" - }; + let consumer_summary = screen_consumer_summary(source.active_consumer_count); view! {
{source.source_id}
-
{format!("{} · {demand}", source.backend)}
+
{format!("{} · {consumer_summary}", source.backend)}
{input::humanize(&source.state)} @@ -700,6 +696,14 @@ fn screen_source_view(source: InputSourceStatus) -> impl IntoView { } } +fn screen_consumer_summary(active_consumer_count: usize) -> String { + match active_consumer_count { + 0 => "no active consumers".to_owned(), + 1 => "1 active consumer".to_owned(), + count => format!("{count} active consumers"), + } +} + #[cfg(test)] mod macos_capture_tests { use crate::api::{ @@ -707,7 +711,16 @@ mod macos_capture_tests { SystemStatus, }; - use super::{macos_screen_restart_coordinates, validate_macos_restart_owner}; + use super::{ + macos_screen_restart_coordinates, screen_consumer_summary, validate_macos_restart_owner, + }; + + #[test] + fn screen_consumer_summary_reports_exact_committed_count() { + assert_eq!(screen_consumer_summary(0), "no active consumers"); + assert_eq!(screen_consumer_summary(1), "1 active consumer"); + assert_eq!(screen_consumer_summary(3), "3 active consumers"); + } fn system_status( input: InputStatus, diff --git a/crates/hypercolor-ui/src/ws/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index 0716a17de..333fc6b17 100644 --- a/crates/hypercolor-ui/src/ws/messages.rs +++ b/crates/hypercolor-ui/src/ws/messages.rs @@ -476,6 +476,7 @@ pub struct InputSourceStatusEventHint { pub configured: bool, pub consented: bool, pub demanded: bool, + pub active_consumer_count: usize, pub state: String, pub freshness: String, pub source_graph_generation: u64, @@ -1260,3 +1261,26 @@ fn extract_device_event_hint( found_count, }) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::extract_input_source_status_event_hint; + + #[test] + fn input_status_hint_decodes_exact_and_legacy_consumer_counts() { + let current = extract_input_source_status_event_hint(&json!({ + "source_id": "macos:session", + "active_consumer_count": 4 + })) + .expect("current input status event should decode"); + let legacy = extract_input_source_status_event_hint(&json!({ + "source_id": "macos:session" + })) + .expect("additive field should preserve legacy event decoding"); + + assert_eq!(current.active_consumer_count, 4); + assert_eq!(legacy.active_consumer_count, 0); + } +} diff --git a/protocol/websocket-v1.json b/protocol/websocket-v1.json index 380d8a891..37a30998d 100644 --- a/protocol/websocket-v1.json +++ b/protocol/websocket-v1.json @@ -196,6 +196,7 @@ "configured", "consented", "demanded", + "active_consumer_count", "state", "freshness", "source_graph_generation", diff --git a/python/src/hypercolor/_generated/models/input_source_status.py b/python/src/hypercolor/_generated/models/input_source_status.py index 150015fc4..1a3b4dbf0 100644 --- a/python/src/hypercolor/_generated/models/input_source_status.py +++ b/python/src/hypercolor/_generated/models/input_source_status.py @@ -26,6 +26,7 @@ class InputSourceStatus: """Lock-free lifecycle and freshness status for one input source. Attributes: + active_consumer_count (int): backend (str): configured (bool): consented (bool): @@ -47,6 +48,7 @@ class InputSourceStatus: platform (InputSourcePlatformStatusType0 | InputSourcePlatformStatusType1 | None | Unset): """ + active_consumer_count: int backend: str configured: bool consented: bool @@ -79,6 +81,8 @@ def to_dict(self) -> dict[str, Any]: InputSourcePlatformStatusType1, ) + active_consumer_count = self.active_consumer_count + backend = self.backend configured = self.configured @@ -155,6 +159,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update(self.additional_properties) field_dict.update( { + "active_consumer_count": active_consumer_count, "backend": backend, "configured": configured, "consented": consented, @@ -196,6 +201,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) d = dict(src_dict) + active_consumer_count = d.pop("active_consumer_count") + backend = d.pop("backend") configured = d.pop("configured") @@ -342,6 +349,7 @@ def _parse_platform( platform = _parse_platform(d.pop("platform", UNSET)) input_source_status = cls( + active_consumer_count=active_consumer_count, backend=backend, configured=configured, consented=consented, From 247e2ff131f8a8da16defb598df774ba6ff2a93a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 04:57:02 -0700 Subject: [PATCH 080/144] feat(macos): capture Tahoe screenshot references Add source and generation fenced ScreenCaptureKit screenshot transactions for single SDR and paired SDR/HDR reference images. Retain returned CGImage ownership, bound reference conversion, and fail closed when any required Core Graphics tone-mapping facility is unavailable. The diagnostic reports pending first-frame capability without dispatching. Writing captured pixels requires explicit paths and a privacy warning. Co-Authored-By: Nova (GPT-5.6 Codex) --- crates/hypercolor-macos-capture/Cargo.toml | 15 +- .../capture_macos_screenshot_reference.rs | 305 ++++++++ .../src/diagnostics.rs | 12 + crates/hypercolor-macos-capture/src/frame.rs | 29 +- crates/hypercolor-macos-capture/src/lib.rs | 8 + crates/hypercolor-macos-capture/src/native.rs | 650 +++++++++++++++++- .../src/screenshot.rs | 595 ++++++++++++++++ 7 files changed, 1590 insertions(+), 24 deletions(-) create mode 100644 crates/hypercolor-macos-capture/examples/capture_macos_screenshot_reference.rs create mode 100644 crates/hypercolor-macos-capture/src/screenshot.rs diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml index b25b38f85..fd7df2b62 100644 --- a/crates/hypercolor-macos-capture/Cargo.toml +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -32,12 +32,24 @@ objc2-core-foundation = { workspace = true, features = [ "CFArray", "CFCGTypes", "CFDictionary", + "CFData", "CFNumber", "CFString", + "CFURL", "CFUUID", "objc2", ] } -objc2-core-graphics = { workspace = true, features = ["std", "CGGeometry", "CGWindow"] } +objc2-core-graphics = { workspace = true, features = [ + "std", + "CGBitmapContext", + "CGColorSpace", + "CGContext", + "CGDataProvider", + "CGGeometry", + "CGImage", + "CGToneMapping", + "CGWindow", +] } objc2-core-media = { workspace = true, features = [ "std", "CMSampleBuffer", @@ -79,6 +91,7 @@ objc2-screen-capture-kit = { workspace = true, features = [ "SCContentSharingPicker", "SCError", "SCShareableContent", + "SCScreenshotManager", "SCStream", "objc2-core-foundation", "objc2-core-graphics", diff --git a/crates/hypercolor-macos-capture/examples/capture_macos_screenshot_reference.rs b/crates/hypercolor-macos-capture/examples/capture_macos_screenshot_reference.rs new file mode 100644 index 000000000..b04563a24 --- /dev/null +++ b/crates/hypercolor-macos-capture/examples/capture_macos_screenshot_reference.rs @@ -0,0 +1,305 @@ +//! Run Tahoe SDR or paired SDR/HDR screenshot reference diagnostics. +//! +//! Metadata-only mode is the default. Prompts, picker presentation, and pixel +//! writes each require an explicit command-line flag. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::Duration; + +use hypercolor_macos_capture::MacosCaptureSelector; + +#[derive(Debug)] +struct Options { + selector: MacosCaptureSelector, + authorize: bool, + picker: bool, + hdr: bool, + timeout: Duration, + sdr_output: Option, + hdr_output: Option, +} + +fn main() { + let options = match parse_args(std::env::args_os().skip(1)) { + Ok(Some(options)) => options, + Ok(None) => { + println!("{}", usage()); + return; + } + Err(error) => { + eprintln!("{error}\n\n{}", usage()); + std::process::exit(2); + } + }; + + #[cfg(not(target_os = "macos"))] + { + let _ = options; + eprintln!("capture_macos_screenshot_reference requires macOS 26 or newer"); + std::process::exit(1); + } + + #[cfg(target_os = "macos")] + if let Err(error) = run(options) { + eprintln!("screenshot reference diagnostic failed: {error}"); + std::process::exit(1); + } +} + +fn usage() -> &'static str { + "Usage: capture_macos_screenshot_reference [OPTIONS]\n\ +\n\ + --source SELECTOR auto, primary_display, display:, or session_scoped\n\ + --hdr Request paired SDR/HDR references\n\ + --authorize Explicitly request Screen Recording authorization\n\ + --picker Explicitly present Apple's system content picker\n\ + --timeout-seconds N Diagnostic budget, 1 through 300 (default: 30)\n\ + --sdr-output PATH Explicitly encode the SDR reference as PNG\n\ + --hdr-output PATH Explicitly encode the HDR reference as PNG\n\ + -h, --help Print this help\n\ +\n\ +Metadata-only mode is the default. Output flags print a privacy warning first." +} + +fn parse_args(args: impl IntoIterator) -> Result, String> { + let mut options = Options { + selector: MacosCaptureSelector::Auto, + authorize: false, + picker: false, + hdr: false, + timeout: Duration::from_secs(30), + sdr_output: None, + hdr_output: None, + }; + let mut args = args.into_iter(); + while let Some(argument) = args.next() { + let argument = argument + .to_str() + .ok_or_else(|| "option names must be valid UTF-8".to_owned())?; + match argument { + "-h" | "--help" => return Ok(None), + "--source" => { + let source = next_utf8(&mut args, "--source")?; + options.selector = MacosCaptureSelector::parse(&source) + .map_err(|_| "invalid --source selector".to_owned())?; + } + "--hdr" => options.hdr = true, + "--authorize" => options.authorize = true, + "--picker" => options.picker = true, + "--timeout-seconds" => { + let seconds = next_utf8(&mut args, "--timeout-seconds")? + .parse::() + .map_err(|_| "--timeout-seconds expects an integer".to_owned())?; + if !(1..=300).contains(&seconds) { + return Err("--timeout-seconds must be between 1 and 300".to_owned()); + } + options.timeout = Duration::from_secs(seconds); + } + "--sdr-output" => { + options.sdr_output = Some(PathBuf::from(next_os(&mut args, "--sdr-output")?)); + } + "--hdr-output" => { + options.hdr_output = Some(PathBuf::from(next_os(&mut args, "--hdr-output")?)); + } + other => return Err(format!("unknown option: {other}")), + } + } + if options.selector == MacosCaptureSelector::SessionScoped && !options.picker { + return Err("session_scoped capture requires the explicit --picker action".to_owned()); + } + if options.hdr_output.is_some() && !options.hdr { + return Err("--hdr-output requires --hdr".to_owned()); + } + Ok(Some(options)) +} + +fn next_utf8(args: &mut impl Iterator, option: &str) -> Result { + next_os(args, option)? + .into_string() + .map_err(|_| format!("{option} expects valid UTF-8")) +} + +fn next_os(args: &mut impl Iterator, option: &str) -> Result { + args.next() + .ok_or_else(|| format!("{option} requires a value")) +} + +#[cfg(target_os = "macos")] +fn run(options: Options) -> Result<(), String> { + use std::time::Instant; + + use hypercolor_macos_capture::{ + MacosCaptureCadence, MacosFrameEvent, MacosScreenCaptureSession, + MacosScreenshotPreferredDynamicRange, MacosScreenshotReferenceCapability, + MacosScreenshotReferenceSet, MacosStreamRequest, + }; + + let request = if options.hdr { + MacosStreamRequest::new_hdr(MacosCaptureCadence::NativeRefresh, true) + } else { + MacosStreamRequest::new(MacosCaptureCadence::NativeRefresh, true) + } + .map_err(|_| "native-refresh capture configuration was rejected".to_owned())?; + let session = MacosScreenCaptureSession::new(request, options.selector.clone()) + .map_err(|_| "could not create the production ScreenCaptureKit session".to_owned())?; + if options.authorize { + println!("user action: requesting Screen Recording authorization"); + println!("authorization state: {:?}", session.request_authorization()); + } + if !MacosScreenCaptureSession::screen_authorized() { + return Err("Screen Recording is not authorized; use --authorize to request it".to_owned()); + } + if options.picker { + println!("user action: presenting Apple's system content picker"); + session + .present_picker() + .map_err(|_| "Apple's content picker could not be presented".to_owned())?; + } + + println!( + "reference mode: {}; pixels: {}", + if options.hdr { "paired SDR/HDR" } else { "SDR" }, + if options.sdr_output.is_some() || options.hdr_output.is_some() { + "explicit export" + } else { + "metadata only" + } + ); + let deadline = Instant::now() + options.timeout; + let mailbox = session.mailbox(); + session.set_capture_active(true); + let result = (|| { + let mut reported_pending = false; + loop { + match session.screenshot_reference_capability() { + Ok(MacosScreenshotReferenceCapability::PendingFirstFrame) => { + if !reported_pending { + println!( + "selection capability: pending_first_frame; screenshot capture: none" + ); + reported_pending = true; + } + } + Ok(capability) => { + println!("selection capability: {}", capability_name(&capability)); + break; + } + Err(_) => return Err("Tahoe screenshot capability probe failed".to_owned()), + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("timed out before the first complete frame".to_owned()); + } + match mailbox.wait_latest(remaining) { + Some(Ok(MacosFrameEvent::Frame(_))) + | Some(Ok(MacosFrameEvent::Lifecycle(_))) + | Some(Ok(MacosFrameEvent::RecoverableError(_))) => {} + Some(Err(_)) => { + return Err("capture failed before capability resolution".to_owned()); + } + None => return Err("timed out before capability resolution".to_owned()), + } + } + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + session + .capture_screenshot_reference(move |result| { + let _ = result_tx.send(result); + }) + .map_err(|_| "Tahoe screenshot transaction could not start".to_owned())?; + let references = result_rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|_| "timed out waiting for Tahoe screenshot output".to_owned())? + .map_err(|_| "Tahoe screenshot transaction failed".to_owned())?; + match references { + MacosScreenshotReferenceSet::Sdr { image } => { + print_metadata("sdr", &image); + print_reference_output( + "sdr", + &image, + MacosScreenshotPreferredDynamicRange::Standard, + )?; + write_if_requested("SDR", &image, options.sdr_output.as_deref())?; + } + MacosScreenshotReferenceSet::Paired { sdr, hdr } => { + print_metadata("sdr", &sdr); + print_metadata("hdr", &hdr); + print_reference_output( + "sdr", + &sdr, + MacosScreenshotPreferredDynamicRange::Standard, + )?; + print_reference_output("hdr", &hdr, MacosScreenshotPreferredDynamicRange::High)?; + write_if_requested("SDR", &sdr, options.sdr_output.as_deref())?; + write_if_requested("HDR", &hdr, options.hdr_output.as_deref())?; + } + } + Ok(()) + })(); + session.stop(); + result +} + +#[cfg(target_os = "macos")] +fn capability_name( + capability: &hypercolor_macos_capture::MacosScreenshotReferenceCapability, +) -> &'static str { + use hypercolor_macos_capture::MacosScreenshotReferenceCapability; + + match capability { + MacosScreenshotReferenceCapability::PendingFirstFrame => "pending_first_frame", + MacosScreenshotReferenceCapability::SdrOnly { .. } => "sdr_only", + MacosScreenshotReferenceCapability::PairedSdrHdr { .. } => "paired_sdr_hdr", + } +} + +#[cfg(target_os = "macos")] +fn print_metadata(label: &str, image: &hypercolor_macos_capture::MacosScreenshotReferenceImage) { + let metadata = image.metadata(); + println!( + "{label}: {}x{} color_space={} range={:?} bits={}x{} row_bytes={} headroom={:?} average_light={:?}", + metadata.extent.width, + metadata.extent.height, + metadata.color_space, + metadata.dynamic_range, + metadata.bits_per_component, + metadata.bits_per_pixel, + metadata.bytes_per_row, + metadata.content_headroom, + metadata.content_average_light_level, + ); +} + +#[cfg(target_os = "macos")] +fn print_reference_output( + label: &str, + image: &hypercolor_macos_capture::MacosScreenshotReferenceImage, + preferred_dynamic_range: hypercolor_macos_capture::MacosScreenshotPreferredDynamicRange, +) -> Result<(), String> { + let reference = image + .copy_reference_rgba8(preferred_dynamic_range) + .map_err(|_| format!("could not create the {label} Core Graphics reference output"))?; + println!( + "{label} reference_output: {}x{} row_bytes={} rgba8_bytes={}", + reference.extent.width, + reference.extent.height, + reference.bytes_per_row, + reference.rgba8.len(), + ); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn write_if_requested( + label: &str, + image: &hypercolor_macos_capture::MacosScreenshotReferenceImage, + path: Option<&std::path::Path>, +) -> Result<(), String> { + let Some(path) = path else { + return Ok(()); + }; + eprintln!("PRIVACY WARNING: writing {label} captured screen pixels to an explicit destination"); + image + .encode_png(path) + .map_err(|_| format!("could not encode the {label} reference PNG")) +} diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index 5499a4d30..a181b37e5 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -85,6 +85,18 @@ impl MacosFrameDropReason { | MacosCaptureError::StreamDeliveryRejected(_) | MacosCaptureError::FrameDeliveryDropped(_) | MacosCaptureError::CapabilityProbeFailed(_) + | MacosCaptureError::TahoePlatformDefect(_) + | MacosCaptureError::ScreenshotCapabilityPending + | MacosCaptureError::ScreenshotSelectionChanged + | MacosCaptureError::MissingScreenshotImage(_) + | MacosCaptureError::ScreenshotMetadataOutOfRange(_) + | MacosCaptureError::MissingScreenshotColorSpace + | MacosCaptureError::ScreenshotReferenceTooLarge { .. } + | MacosCaptureError::ScreenshotReferenceContextFailed + | MacosCaptureError::ScreenshotToneMappingOptionsFailed + | MacosCaptureError::ScreenshotOutputUrlFailed + | MacosCaptureError::ScreenshotEncoderCreateFailed + | MacosCaptureError::ScreenshotEncodeFailed | MacosCaptureError::Geometry(_) => Self::Validation, MacosCaptureError::ScreenResourceExhausted { .. } => Self::Resource, } diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs index d22455b6e..5e133c436 100644 --- a/crates/hypercolor-macos-capture/src/frame.rs +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -23,7 +23,7 @@ use crate::geometry::{ MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, }; -use crate::{MacosDeliveredFrameMetadata, MacosStreamDeliveryRejection}; +use crate::{MacosCaptureDynamicRange, MacosDeliveredFrameMetadata, MacosStreamDeliveryRejection}; pub const MACOS_STREAM_QUEUE_DEPTH: usize = 8; @@ -1170,6 +1170,33 @@ pub enum MacosCaptureError { PixelBufferFixtureCreateFailed(i32), #[error("ScreenCaptureKit filter retention failed")] RetainNativeFilterFailed, + #[error("Tahoe platform capability is missing: {0}")] + TahoePlatformDefect(&'static str), + #[error("Tahoe screenshot capability is pending the first complete frame")] + ScreenshotCapabilityPending, + #[error("the selected capture source changed during the screenshot transaction")] + ScreenshotSelectionChanged, + #[error("Tahoe screenshot output omitted the requested {0:?} image")] + MissingScreenshotImage(MacosCaptureDynamicRange), + #[error("Tahoe screenshot metadata is outside the supported range: {0}")] + ScreenshotMetadataOutOfRange(&'static str), + #[error("Tahoe screenshot output has no named color space")] + MissingScreenshotColorSpace, + #[error("Tahoe screenshot needs {requested_bytes} bytes; the limit is {maximum_bytes}")] + ScreenshotReferenceTooLarge { + requested_bytes: u64, + maximum_bytes: u64, + }, + #[error("Core Graphics could not create the screenshot reference context")] + ScreenshotReferenceContextFailed, + #[error("Core Graphics could not create screenshot tone-mapping options")] + ScreenshotToneMappingOptionsFailed, + #[error("Core Foundation could not represent the screenshot output path")] + ScreenshotOutputUrlFailed, + #[error("ImageIO could not create the screenshot PNG encoder")] + ScreenshotEncoderCreateFailed, + #[error("ImageIO could not finalize the screenshot PNG")] + ScreenshotEncodeFailed, #[error("failed to start the macOS capture worker: {0}")] CaptureWorkerStartFailed(String), #[error("the macOS capture worker panicked")] diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 54d6ab55f..a27fcd4c7 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -11,12 +11,20 @@ mod geometry; mod mailbox; #[cfg(target_os = "macos")] mod native; +#[cfg(target_os = "macos")] +mod screenshot; mod session; mod stream_contract; mod worker; #[cfg(target_os = "macos")] pub use native::MacosScreenCaptureSession; +#[cfg(target_os = "macos")] +pub use screenshot::{ + MAX_MACOS_SCREENSHOT_REFERENCE_BYTES, MacosScreenshotPixelCopy, + MacosScreenshotPreferredDynamicRange, MacosScreenshotReferenceCapability, + MacosScreenshotReferenceImage, MacosScreenshotReferenceMetadata, MacosScreenshotReferenceSet, +}; pub use clock::{MacosDisplayClock, MacosDisplayClockError}; pub use cpu::MacosCpuSourceView; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index daef464b3..4e29317f2 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -16,7 +16,7 @@ use objc2_core_foundation::{ CGRect, CGSize, }; use objc2_core_graphics::{ - CGDirectDisplayID, CGMainDisplayID, CGPreflightScreenCaptureAccess, + CGDirectDisplayID, CGImage, CGMainDisplayID, CGPreflightScreenCaptureAccess, CGRectMakeWithDictionaryRepresentation, CGRequestScreenCaptureAccess, }; use objc2_core_media::{CMSampleBuffer, CMTime}; @@ -61,7 +61,8 @@ use crate::{ MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosHostArchitecture, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosRuntimeCapability, - MacosScale, MacosStreamDeliveryRejection, MacosStreamDeliveryState, + MacosScale, MacosScreenshotReferenceCapability, MacosScreenshotReferenceImage, + MacosScreenshotReferenceSet, MacosStreamDeliveryRejection, MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, MacosTransferFunction, MacosValidatedStreamDelivery, MacosYuvMatrix, @@ -519,6 +520,269 @@ struct NativeFilter(Retained); // the process that owns every consuming SCStream. Rust never mutates it. unsafe impl Send for NativeFilter {} +#[derive(Clone)] +enum ScreenshotFilterHandle { + Native(NativeFilter), + #[cfg(test)] + Fixture(u64), +} + +#[derive(Clone)] +struct ScreenshotTransactionSnapshot { + filter: ScreenshotFilterHandle, + source_id: Arc, + generation: u64, + selection_revision: u64, + capability: MacosScreenshotReferenceCapability, +} + +type ScreenshotCompletion = + Box) + Send>; +type ScreenshotImageCompletion = + Box) + Send>; + +trait ScreenshotCaptureBackend: Send + Sync { + fn capture( + &self, + filter: ScreenshotFilterHandle, + dynamic_range: MacosCaptureDynamicRange, + cursor_composed: bool, + completion: ScreenshotImageCompletion, + ) -> Result<(), MacosCaptureError>; +} + +trait ScreenshotIdentityFence: Send + Sync { + fn matches(&self, source_id: &str, generation: u64, selection_revision: u64) -> bool; +} + +struct NativeScreenshotCaptureBackend; + +impl ScreenshotCaptureBackend for NativeScreenshotCaptureBackend { + fn capture( + &self, + filter: ScreenshotFilterHandle, + dynamic_range: MacosCaptureDynamicRange, + cursor_composed: bool, + completion: ScreenshotImageCompletion, + ) -> Result<(), MacosCaptureError> { + #[cfg(not(test))] + let ScreenshotFilterHandle::Native(filter) = filter; + #[cfg(test)] + let filter = match filter { + ScreenshotFilterHandle::Native(filter) => filter, + ScreenshotFilterHandle::Fixture(_) => { + return Err(MacosCaptureError::TahoePlatformDefect( + "native screenshot filter", + )); + } + }; + let configuration_class = AnyClass::get(c"SCScreenshotConfiguration").ok_or( + MacosCaptureError::TahoePlatformDefect("SCScreenshotConfiguration"), + )?; + let manager_class = AnyClass::get(c"SCScreenshotManager").ok_or( + MacosCaptureError::TahoePlatformDefect("SCScreenshotManager"), + )?; + for (class, selector, capability) in [ + ( + configuration_class, + sel!(setShowsCursor:), + "SCScreenshotConfiguration.setShowsCursor", + ), + ( + configuration_class, + sel!(setDisplayIntent:), + "SCScreenshotConfiguration.setDisplayIntent", + ), + ( + configuration_class, + sel!(setDynamicRange:), + "SCScreenshotConfiguration.setDynamicRange", + ), + ] { + if !class.responds_to(selector) { + return Err(MacosCaptureError::TahoePlatformDefect(capability)); + } + } + if !manager_class.metaclass().responds_to(sel!( + captureScreenshotWithFilter:configuration:completionHandler: + )) { + return Err(MacosCaptureError::TahoePlatformDefect( + "SCScreenshotManager.captureScreenshot", + )); + } + // SAFETY: the runtime probes above establish the Tahoe class and each + // selector before the dynamically dispatched configuration calls. + let configuration: Retained = unsafe { msg_send![configuration_class, new] }; + let range_value = match dynamic_range { + MacosCaptureDynamicRange::Sdr => 0_isize, + MacosCaptureDynamicRange::Hdr => 1_isize, + }; + // SAFETY: values match the SDK-declared BOOL and NSInteger properties. + unsafe { + let _: () = msg_send![&*configuration, setShowsCursor: cursor_composed]; + let _: () = msg_send![&*configuration, setDisplayIntent: 0_isize]; + let _: () = msg_send![&*configuration, setDynamicRange: range_value]; + } + let completion = Arc::new(Mutex::new(Some(completion))); + let completion_slot = Arc::clone(&completion); + let retained_filter = filter.clone(); + let callback = RcBlock::new(move |output: *mut AnyObject, error: *mut NSError| { + let Some(completion) = lock(&completion_slot).take() else { + return; + }; + // SAFETY: ScreenCaptureKit supplies callback objects for this + // invocation. The selected CGImage is retained before return. + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(native_error("capture Tahoe screenshot", error)) + } else if let Some(output) = unsafe { output.as_ref() } { + // SAFETY: the live Objective-C output supports the NSObject + // protocol query for its Tahoe image selector. + unsafe { + let selector = match dynamic_range { + MacosCaptureDynamicRange::Sdr => sel!(sdrImage), + MacosCaptureDynamicRange::Hdr => sel!(hdrImage), + }; + let responds: bool = msg_send![output, respondsToSelector: selector]; + if !responds { + Err(MacosCaptureError::TahoePlatformDefect( + "SCScreenshotOutput image selector", + )) + } else { + let image: Option> = match dynamic_range { + MacosCaptureDynamicRange::Sdr => msg_send![output, sdrImage], + MacosCaptureDynamicRange::Hdr => msg_send![output, hdrImage], + }; + image + .ok_or(MacosCaptureError::MissingScreenshotImage(dynamic_range)) + .and_then(|image| { + MacosScreenshotReferenceImage::from_native(image, dynamic_range) + }) + } + } + } else { + Err(MacosCaptureError::TahoePlatformDefect("SCScreenshotOutput")) + }; + drop(retained_filter.clone()); + completion(result); + }); + // SAFETY: the runtime probe establishes this class selector. The API + // copies the block and retains the filter and configuration while the + // asynchronous capture is pending. + unsafe { + let _: () = msg_send![ + manager_class, + captureScreenshotWithFilter: &*filter.0, + configuration: &*configuration, + completionHandler: &*callback + ]; + } + Ok(()) + } +} + +fn execute_screenshot_transaction( + snapshot: ScreenshotTransactionSnapshot, + fence: Arc, + backend: Arc, + cursor_composed: bool, + completion: ScreenshotCompletion, +) -> Result<(), MacosCaptureError> { + if matches!( + snapshot.capability, + MacosScreenshotReferenceCapability::PendingFirstFrame + ) { + return Err(MacosCaptureError::ScreenshotCapabilityPending); + } + let completion = Arc::new(Mutex::new(Some(completion))); + let first_filter = snapshot.filter.clone(); + let second_filter = snapshot.filter.clone(); + let first_source_id = Arc::clone(&snapshot.source_id); + let first_fence = Arc::clone(&fence); + let second_backend = Arc::clone(&backend); + let capability = snapshot.capability.clone(); + let generation = snapshot.generation; + let selection_revision = snapshot.selection_revision; + let first_completion = Arc::clone(&completion); + backend.capture( + first_filter, + MacosCaptureDynamicRange::Sdr, + cursor_composed, + Box::new(move |sdr| { + if !first_fence.matches(&first_source_id, generation, selection_revision) { + finish_screenshot( + &first_completion, + Err(MacosCaptureError::ScreenshotSelectionChanged), + ); + return; + } + let sdr = match sdr { + Ok(sdr) => sdr, + Err(error) => { + finish_screenshot(&first_completion, Err(error)); + return; + } + }; + match capability { + MacosScreenshotReferenceCapability::PendingFirstFrame => { + finish_screenshot( + &first_completion, + Err(MacosCaptureError::ScreenshotCapabilityPending), + ); + } + MacosScreenshotReferenceCapability::SdrOnly { .. } => { + finish_screenshot( + &first_completion, + Ok(MacosScreenshotReferenceSet::Sdr { image: sdr }), + ); + } + MacosScreenshotReferenceCapability::PairedSdrHdr { .. } => { + let second_source_id = Arc::clone(&first_source_id); + let second_fence = Arc::clone(&first_fence); + let second_completion = Arc::clone(&first_completion); + let start_completion = Arc::clone(&first_completion); + let start = second_backend.capture( + second_filter, + MacosCaptureDynamicRange::Hdr, + cursor_composed, + Box::new(move |hdr| { + if !second_fence.matches( + &second_source_id, + generation, + selection_revision, + ) { + finish_screenshot( + &second_completion, + Err(MacosCaptureError::ScreenshotSelectionChanged), + ); + return; + } + match hdr { + Ok(hdr) => finish_screenshot( + &second_completion, + Ok(MacosScreenshotReferenceSet::Paired { sdr, hdr }), + ), + Err(error) => finish_screenshot(&second_completion, Err(error)), + } + }), + ); + if let Err(error) = start { + finish_screenshot(&start_completion, Err(error)); + } + } + } + }), + ) +} + +fn finish_screenshot( + completion: &Arc>>, + result: Result, +) { + if let Some(completion) = lock(completion).take() { + completion(result); + } +} + struct NativeStream { stream: Retained, filter: NativeFilter, @@ -671,6 +935,7 @@ struct StreamState { current: Option, candidate: Option, selected_filter: Option, + selection_revision: u64, } struct StreamSlot { @@ -712,7 +977,14 @@ impl StreamSlot { reserve_pool, )?; let stream = candidate.stream.clone(); - let replaced = lock(&self.state).candidate.replace(candidate); + let replaced = { + let mut state = lock(&self.state); + state.selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.candidate.replace(candidate) + }; if let Some(replaced) = replaced { self.stop_stream(replaced); } @@ -819,11 +1091,80 @@ impl StreamSlot { Retained::retain(ptr::from_ref(filter).cast_mut()) .ok_or(MacosCaptureError::RetainNativeFilterFailed)? }; - lock(&self.state).selected_filter = Some(NativeFilter(filter)); + let mut state = lock(&self.state); + state.selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.selected_filter = Some(NativeFilter(filter)); + drop(state); self.shared.set_unconfirmed_selection(selection); Ok(()) } + fn screenshot_capability( + &self, + ) -> Result { + let state = lock(&self.state); + let Some(current) = state.current.as_ref() else { + return Ok(MacosScreenshotReferenceCapability::PendingFirstFrame); + }; + self.capability_for_current(current) + } + + fn screenshot_snapshot(&self) -> Result { + let state = lock(&self.state); + let current = state + .current + .as_ref() + .ok_or(MacosCaptureError::ScreenshotCapabilityPending)?; + let capability = self.capability_for_current(current)?; + Ok(ScreenshotTransactionSnapshot { + filter: ScreenshotFilterHandle::Native(current.filter.clone()), + source_id: Arc::clone(¤t.source_id), + generation: current.epoch(), + selection_revision: state.selection_revision, + capability, + }) + } + + fn capability_for_current( + &self, + current: &NativeStream, + ) -> Result { + if !self.shared.tahoe.screenshot_api.is_present() { + return Err(MacosCaptureError::TahoePlatformDefect( + "Tahoe screenshot API", + )); + } + if !self.shared.tahoe.content_tone_mapping_info.is_present() { + return Err(MacosCaptureError::TahoePlatformDefect( + "Core Graphics Tahoe tone mapping", + )); + } + crate::screenshot::require_tahoe_reference_output_symbols()?; + let capability = self + .shared + .tahoe_selection_for(¤t.source_id, current.epoch()) + .ok_or(MacosCaptureError::ScreenshotCapabilityPending)?; + if capability.hdr_capture { + if !capability.dual_range_screenshots { + return Err(MacosCaptureError::TahoePlatformDefect( + "paired SDR and HDR screenshots", + )); + } + Ok(MacosScreenshotReferenceCapability::PairedSdrHdr { + source_id: capability.source_id, + generation: capability.capture_session_generation, + }) + } else { + Ok(MacosScreenshotReferenceCapability::SdrOnly { + source_id: capability.source_id, + generation: capability.capture_session_generation, + }) + } + } + fn selected_filter(&self) -> Option { lock(&self.state).selected_filter.clone() } @@ -838,6 +1179,7 @@ impl StreamSlot { fn stop(&self) { let (current, candidate) = { let mut state = lock(&self.state); + state.selection_revision = state.selection_revision.saturating_add(1); if state.current.is_none() && state.selected_filter.is_none() && let Some(candidate) = state.candidate.as_ref() @@ -863,6 +1205,20 @@ impl StreamSlot { } } +impl ScreenshotIdentityFence for StreamSlot { + fn matches(&self, source_id: &str, generation: u64, selection_revision: u64) -> bool { + let state = lock(&self.state); + state.selection_revision == selection_revision + && state.current.as_ref().is_some_and(|current| { + current.epoch() == generation && current.source_id.as_ref() == source_id + }) + && self + .shared + .tahoe_selection_for(source_id, generation) + .is_some() + } +} + fn start_stream( stream: &SCStream, epoch: u64, @@ -1292,6 +1648,26 @@ impl MacosScreenCaptureSession { self.shared.tahoe_selection_for(&source_id, epoch) } + pub fn screenshot_reference_capability( + &self, + ) -> Result { + self.streams.screenshot_capability() + } + + pub fn capture_screenshot_reference(&self, completion: F) -> Result<(), MacosCaptureError> + where + F: FnOnce(Result) + Send + 'static, + { + let snapshot = self.streams.screenshot_snapshot()?; + execute_screenshot_transaction( + snapshot, + Arc::clone(&self.streams) as Arc, + Arc::new(NativeScreenshotCaptureBackend), + self.request.cursor_composed, + Box::new(completion), + ) + } + pub fn mailbox(&self) -> MacosFrameMailbox { self.shared.mailbox.clone() } @@ -1567,9 +1943,9 @@ fn native_capture_capabilities() -> Result Result bool { - #[link(name = "System", kind = "dylib")] - unsafe extern "C-unwind" { - fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; - } - - let default_handle = ptr::without_provenance_mut::(usize::MAX - 1); - // SAFETY: RTLD_DEFAULT is the Darwin sentinel pointer with address -2, - // and the supplied symbol name is nul-terminated. - !unsafe { dlsym(default_handle, symbol.as_ptr()) }.is_null() -} - fn stream_configuration( filter: &SCContentFilter, request: MacosStreamRequest, @@ -2442,7 +2806,9 @@ fn exact_u32(value: f64) -> Option { #[cfg(test)] mod tests { + use std::collections::VecDeque; use std::sync::Arc; + use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use super::{ @@ -2453,12 +2819,110 @@ mod tests { MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTransferFunction, MacosValidatedStreamDelivery, PoolBackingLifetime, PoolObservation, SCCaptureDynamicRange, - SCStreamConfiguration, SCStreamConfigurationPreset, SessionShared, SysctlI32Value, + SCStreamConfiguration, SCStreamConfigurationPreset, ScreenshotCaptureBackend, + ScreenshotFilterHandle, ScreenshotIdentityFence, ScreenshotImageCompletion, + ScreenshotTransactionSnapshot, SessionShared, SysctlI32Value, capture_capabilities_from_probes, capture_dynamic_range, classify_delivery_error, - color_range_from_fourcc, conservative_pool_quote, session_selection_source_id, - with_admitted_surface, + color_range_from_fourcc, conservative_pool_quote, execute_screenshot_transaction, + session_selection_source_id, with_admitted_surface, + }; + use crate::{ + MacosScreenshotReferenceCapability, MacosScreenshotReferenceImage, + MacosScreenshotReferenceSet, }; + struct FixtureScreenshotCall { + filter_id: u64, + dynamic_range: MacosCaptureDynamicRange, + completion: ScreenshotImageCompletion, + } + + #[derive(Default)] + struct FixtureScreenshotBackend { + calls: Mutex>, + } + + impl FixtureScreenshotBackend { + fn calls(&self) -> Vec<(u64, MacosCaptureDynamicRange)> { + super::lock(&self.calls) + .iter() + .map(|call| (call.filter_id, call.dynamic_range)) + .collect() + } + + fn complete_next(&self, result: Result) { + let call = super::lock(&self.calls) + .pop_front() + .expect("fixture callback should be pending"); + (call.completion)(result); + } + } + + impl ScreenshotCaptureBackend for FixtureScreenshotBackend { + fn capture( + &self, + filter: ScreenshotFilterHandle, + dynamic_range: MacosCaptureDynamicRange, + _cursor_composed: bool, + completion: ScreenshotImageCompletion, + ) -> Result<(), MacosCaptureError> { + let ScreenshotFilterHandle::Fixture(filter_id) = filter else { + panic!("fixture backend requires a fixture filter"); + }; + super::lock(&self.calls).push_back(FixtureScreenshotCall { + filter_id, + dynamic_range, + completion, + }); + Ok(()) + } + } + + struct FixtureScreenshotFence { + identity: Mutex<(Arc, u64, u64)>, + } + + impl ScreenshotIdentityFence for FixtureScreenshotFence { + fn matches(&self, source_id: &str, generation: u64, revision: u64) -> bool { + let identity = super::lock(&self.identity); + identity.0.as_ref() == source_id && identity.1 == generation && identity.2 == revision + } + } + + fn screenshot_fixture( + capability: MacosScreenshotReferenceCapability, + ) -> ( + ScreenshotTransactionSnapshot, + Arc, + Arc, + ) { + let (source_id, generation) = match &capability { + MacosScreenshotReferenceCapability::PendingFirstFrame => (Arc::from("pending"), 0), + MacosScreenshotReferenceCapability::SdrOnly { + source_id, + generation, + } + | MacosScreenshotReferenceCapability::PairedSdrHdr { + source_id, + generation, + } => (Arc::clone(source_id), *generation), + }; + let selection_revision = 11; + ( + ScreenshotTransactionSnapshot { + filter: ScreenshotFilterHandle::Fixture(7), + source_id: Arc::clone(&source_id), + generation, + selection_revision, + capability, + }, + Arc::new(FixtureScreenshotFence { + identity: Mutex::new((source_id, generation, selection_revision)), + }), + Arc::new(FixtureScreenshotBackend::default()), + ) + } + const ABSENT_TAHOE_PROBES: MacosTahoeRuntimeProbes = MacosTahoeRuntimeProbes { content_tone_mapping_info_symbol: MacosRuntimeCapability::Absent, screenshot_configuration_class: MacosRuntimeCapability::Absent, @@ -2702,6 +3166,148 @@ mod tests { assert_eq!(shared.tahoe_selection_for("display:b", 2), None); } + #[test] + fn pending_screenshot_capability_dispatches_no_native_call() { + let (snapshot, fence, backend) = + screenshot_fixture(MacosScreenshotReferenceCapability::PendingFirstFrame); + let result = execute_screenshot_transaction( + snapshot, + fence, + Arc::clone(&backend) as Arc, + false, + Box::new(|_| panic!("pending capability must not complete asynchronously")), + ); + + assert_eq!(result, Err(MacosCaptureError::ScreenshotCapabilityPending)); + assert!(backend.calls().is_empty()); + } + + #[test] + fn sdr_screenshot_dispatches_one_configuration() { + let capability = MacosScreenshotReferenceCapability::SdrOnly { + source_id: Arc::from("display:a"), + generation: 4, + }; + let (snapshot, fence, backend) = screenshot_fixture(capability); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + execute_screenshot_transaction( + snapshot, + fence, + Arc::clone(&backend) as Arc, + false, + Box::new(move |result| result_tx.send(result).expect("receiver remains live")), + ) + .expect("SDR transaction should start"); + assert_eq!(backend.calls(), vec![(7, MacosCaptureDynamicRange::Sdr)]); + + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Sdr, + 1, + ))); + assert!(matches!( + result_rx.recv().expect("SDR result should arrive"), + Ok(MacosScreenshotReferenceSet::Sdr { .. }) + )); + assert!(backend.calls().is_empty()); + } + + #[test] + fn paired_screenshot_dispatches_exactly_two_ranges_on_one_filter() { + let capability = MacosScreenshotReferenceCapability::PairedSdrHdr { + source_id: Arc::from("display:a"), + generation: 4, + }; + let (snapshot, fence, backend) = screenshot_fixture(capability); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + execute_screenshot_transaction( + snapshot, + fence, + Arc::clone(&backend) as Arc, + false, + Box::new(move |result| result_tx.send(result).expect("receiver remains live")), + ) + .expect("paired transaction should start"); + + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Sdr, + 1, + ))); + assert_eq!(backend.calls(), vec![(7, MacosCaptureDynamicRange::Hdr)]); + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Hdr, + 2, + ))); + assert!(matches!( + result_rx.recv().expect("paired result should arrive"), + Ok(MacosScreenshotReferenceSet::Paired { .. }) + )); + assert!(backend.calls().is_empty()); + } + + #[test] + fn paired_screenshot_partial_failure_publishes_no_partial_set() { + let capability = MacosScreenshotReferenceCapability::PairedSdrHdr { + source_id: Arc::from("display:a"), + generation: 4, + }; + let (snapshot, fence, backend) = screenshot_fixture(capability); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + execute_screenshot_transaction( + snapshot, + fence, + Arc::clone(&backend) as Arc, + false, + Box::new(move |result| result_tx.send(result).expect("receiver remains live")), + ) + .expect("paired transaction should start"); + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Sdr, + 1, + ))); + backend.complete_next(Err(MacosCaptureError::NativeOperation { + operation: "fixture HDR screenshot", + code: 9, + message: "redacted".to_owned(), + })); + + assert!(matches!( + result_rx.recv().expect("failure should arrive"), + Err(MacosCaptureError::NativeOperation { code: 9, .. }) + )); + } + + #[test] + fn repick_between_paired_callbacks_rejects_the_complete_pair() { + let capability = MacosScreenshotReferenceCapability::PairedSdrHdr { + source_id: Arc::from("display:a"), + generation: 4, + }; + let (snapshot, fence, backend) = screenshot_fixture(capability); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + execute_screenshot_transaction( + snapshot, + Arc::clone(&fence) as Arc, + Arc::clone(&backend) as Arc, + false, + Box::new(move |result| result_tx.send(result).expect("receiver remains live")), + ) + .expect("paired transaction should start"); + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Sdr, + 1, + ))); + super::lock(&fence.identity).2 = 12; + backend.complete_next(Ok(MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Hdr, + 2, + ))); + + assert!(matches!( + result_rx.recv().expect("fence failure should arrive"), + Err(MacosCaptureError::ScreenshotSelectionChanged) + )); + } + #[test] fn canonical_hdr_preset_resolves_to_a_valid_hdr_configuration() { // SAFETY: The deployment floor includes this pure configuration diff --git a/crates/hypercolor-macos-capture/src/screenshot.rs b/crates/hypercolor-macos-capture/src/screenshot.rs new file mode 100644 index 000000000..e93ef97a3 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/screenshot.rs @@ -0,0 +1,595 @@ +use std::ffi::{CStr, OsStr, c_void}; +use std::fmt; +use std::path::Path; +use std::ptr::{self, NonNull}; +use std::sync::Arc; + +use objc2::rc::Retained; +use objc2_core_foundation::{ + CFDictionary, CFNumber, CFNumberType, CFRetained, CFString, CFURL, + kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, +}; +#[cfg(test)] +use objc2_core_graphics::CGBitmapContextCreateImage; +use objc2_core_graphics::{ + CGBitmapContextCreate, CGColorSpace, CGContentToneMappingInfo, CGContext, CGImage, + CGImageAlphaInfo, CGImageByteOrderInfo, CGToneMapping, kCGColorSpaceSRGB, +}; + +use crate::{MacosCaptureDynamicRange, MacosCaptureError, MacosPixelExtent}; + +pub const MAX_MACOS_SCREENSHOT_REFERENCE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_COLOR_SPACE_NAME_BYTES: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacosScreenshotReferenceCapability { + PendingFirstFrame, + SdrOnly { + source_id: Arc, + generation: u64, + }, + PairedSdrHdr { + source_id: Arc, + generation: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacosScreenshotPreferredDynamicRange { + Standard, + Constrained, + High, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MacosScreenshotReferenceMetadata { + pub extent: MacosPixelExtent, + pub color_space: Arc, + pub dynamic_range: MacosCaptureDynamicRange, + pub bits_per_component: u16, + pub bits_per_pixel: u16, + pub bytes_per_row: u64, + pub content_headroom: Option, + pub content_average_light_level: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacosScreenshotPixelCopy { + pub extent: MacosPixelExtent, + pub bytes_per_row: u64, + pub rgba8: Vec, +} + +#[derive(Clone)] +pub struct MacosScreenshotReferenceImage { + image: Retained, + metadata: MacosScreenshotReferenceMetadata, +} + +impl MacosScreenshotReferenceImage { + pub(crate) fn from_native( + image: Retained, + dynamic_range: MacosCaptureDynamicRange, + ) -> Result { + let width = u32::try_from(CGImage::width(Some(&image))) + .map_err(|_| MacosCaptureError::ScreenshotMetadataOutOfRange("width"))?; + let height = u32::try_from(CGImage::height(Some(&image))) + .map_err(|_| MacosCaptureError::ScreenshotMetadataOutOfRange("height"))?; + let extent = MacosPixelExtent::new(width, height)?; + let bits_per_component = u16::try_from(CGImage::bits_per_component(Some(&image))) + .map_err(|_| MacosCaptureError::ScreenshotMetadataOutOfRange("bits_per_component"))?; + let bits_per_pixel = u16::try_from(CGImage::bits_per_pixel(Some(&image))) + .map_err(|_| MacosCaptureError::ScreenshotMetadataOutOfRange("bits_per_pixel"))?; + let bytes_per_row = u64::try_from(CGImage::bytes_per_row(Some(&image))) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let allocation_bytes = bytes_per_row + .checked_mul(u64::from(extent.height)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if allocation_bytes == 0 || allocation_bytes > MAX_MACOS_SCREENSHOT_REFERENCE_BYTES { + return Err(MacosCaptureError::ScreenshotReferenceTooLarge { + requested_bytes: allocation_bytes, + maximum_bytes: MAX_MACOS_SCREENSHOT_REFERENCE_BYTES, + }); + } + let color_space = CGImage::color_space(Some(&image)) + .and_then(|color_space| CGColorSpace::name(Some(&color_space))) + .ok_or(MacosCaptureError::MissingScreenshotColorSpace)? + .to_string(); + if color_space.is_empty() || color_space.len() > MAX_COLOR_SPACE_NAME_BYTES { + return Err(MacosCaptureError::ScreenshotMetadataOutOfRange( + "color_space", + )); + } + let content_headroom = positive_finite(CGImage::content_headroom(Some(&image))); + let content_average_light_level = load_required_tahoe_symbol::( + c"CGImageGetContentAverageLightLevel", + "CGImageGetContentAverageLightLevel", + )?; + // SAFETY: the dynamically resolved Tahoe function has the SDK-declared + // signature and the retained CGImage remains live for this call. + let content_average_light_level = + positive_finite(unsafe { content_average_light_level(Some(&image)) }); + Ok(Self { + image, + metadata: MacosScreenshotReferenceMetadata { + extent, + color_space: Arc::from(color_space), + dynamic_range, + bits_per_component, + bits_per_pixel, + bytes_per_row, + content_headroom, + content_average_light_level, + }, + }) + } + + #[must_use] + pub const fn metadata(&self) -> &MacosScreenshotReferenceMetadata { + &self.metadata + } + + pub fn copy_reference_rgba8( + &self, + preferred_dynamic_range: MacosScreenshotPreferredDynamicRange, + ) -> Result { + let symbols = TahoeReferenceOutputSymbols::load()?; + self.copy_reference_rgba8_with_symbols(preferred_dynamic_range, symbols) + } + + fn copy_reference_rgba8_with_symbols( + &self, + preferred_dynamic_range: MacosScreenshotPreferredDynamicRange, + symbols: TahoeReferenceOutputSymbols, + ) -> Result { + let width = usize::try_from(self.metadata.extent.width) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let height = usize::try_from(self.metadata.extent.height) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let bytes_per_row = width + .checked_mul(4) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let byte_len = bytes_per_row + .checked_mul(height) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + if u64::try_from(byte_len).map_err(|_| MacosCaptureError::ArithmeticOverflow)? + > MAX_MACOS_SCREENSHOT_REFERENCE_BYTES + { + return Err(MacosCaptureError::ScreenshotReferenceTooLarge { + requested_bytes: u64::try_from(byte_len) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + maximum_bytes: MAX_MACOS_SCREENSHOT_REFERENCE_BYTES, + }); + } + let mut rgba8 = vec![0_u8; byte_len]; + // SAFETY: Core Graphics exports a process-lifetime immutable CFString. + let srgb = unsafe { kCGColorSpaceSRGB }; + let color_space = CGColorSpace::with_name(Some(srgb)) + .ok_or(MacosCaptureError::ScreenshotReferenceContextFailed)?; + let bitmap_info = + CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0; + // SAFETY: the vector owns byte_len writable bytes and remains fixed + // while the context exists. Its row and extent arithmetic is checked. + let context = unsafe { + CGBitmapContextCreate( + rgba8.as_mut_ptr().cast(), + width, + height, + 8, + bytes_per_row, + Some(&color_space), + bitmap_info, + ) + } + .ok_or(MacosCaptureError::ScreenshotReferenceContextFailed)?; + let options = tone_mapping_options( + preferred_dynamic_range, + self.metadata.content_average_light_level, + symbols, + )?; + // SAFETY: the dynamically resolved Tahoe function has the SDK-declared + // signature. The context and retained options dictionary remain live. + unsafe { + (symbols.set_tone_mapping)( + &context, + CGContentToneMappingInfo { + method: CGToneMapping::ReferenceWhiteBased, + options: ptr::from_ref(&*options), + }, + ); + } + CGContext::draw_image( + Some(&context), + objc2_core_foundation::CGRect::new( + objc2_core_foundation::CGPoint::new(0.0, 0.0), + objc2_core_foundation::CGSize::new( + f64::from(width as u32), + f64::from(height as u32), + ), + ), + Some(&self.image), + ); + drop(context); + Ok(MacosScreenshotPixelCopy { + extent: self.metadata.extent, + bytes_per_row: u64::try_from(bytes_per_row) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?, + rgba8, + }) + } + + pub fn encode_png(&self, path: impl AsRef) -> Result<(), MacosCaptureError> { + encode_png(&self.image, path.as_ref()) + } + + #[cfg(test)] + pub(crate) fn new_fixture(dynamic_range: MacosCaptureDynamicRange, marker: u8) -> Self { + let extent = MacosPixelExtent::new(1, 1).expect("fixture extent is valid"); + let mut pixel = [marker, marker, marker, u8::MAX]; + // SAFETY: Core Graphics exports a process-lifetime immutable CFString. + let srgb = unsafe { kCGColorSpaceSRGB }; + let color_space = + CGColorSpace::with_name(Some(srgb)).expect("fixture color space is available"); + // SAFETY: pixel is a fixed four-byte RGBA buffer retained until the + // context creates its immutable CGImage copy. + let context = unsafe { + CGBitmapContextCreate( + pixel.as_mut_ptr().cast(), + 1, + 1, + 8, + 4, + Some(&color_space), + CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0, + ) + } + .expect("fixture bitmap context is available"); + let image = CGBitmapContextCreateImage(Some(&context)) + .expect("fixture image should be materialized"); + Self { + image: image.into(), + metadata: MacosScreenshotReferenceMetadata { + extent, + color_space: Arc::from("kCGColorSpaceSRGB"), + dynamic_range, + bits_per_component: 8, + bits_per_pixel: 32, + bytes_per_row: 4, + content_headroom: (dynamic_range == MacosCaptureDynamicRange::Hdr).then_some(4.0), + content_average_light_level: Some(0.25), + }, + } + } +} + +impl fmt::Debug for MacosScreenshotReferenceImage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacosScreenshotReferenceImage") + .field("metadata", &self.metadata) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub enum MacosScreenshotReferenceSet { + Sdr { + image: MacosScreenshotReferenceImage, + }, + Paired { + sdr: MacosScreenshotReferenceImage, + hdr: MacosScreenshotReferenceImage, + }, +} + +type SetContentToneMappingInfo = unsafe extern "C-unwind" fn(&CGContext, CGContentToneMappingInfo); +type GetContentAverageLightLevel = unsafe extern "C-unwind" fn(Option<&CGImage>) -> f32; + +#[derive(Clone, Copy)] +struct TahoeReferenceOutputSymbols { + set_tone_mapping: SetContentToneMappingInfo, + preferred_key: NonNull, + standard_range: NonNull, + constrained_range: NonNull, + high_range: NonNull, + average_light_key: NonNull, +} + +impl TahoeReferenceOutputSymbols { + fn load() -> Result { + Ok(Self { + set_tone_mapping: load_required_tahoe_symbol( + c"CGContextSetContentToneMappingInfo", + "CGContextSetContentToneMappingInfo", + )?, + preferred_key: load_required_tahoe_cf_string( + c"kCGPreferredDynamicRange", + "kCGPreferredDynamicRange", + )?, + standard_range: load_required_tahoe_cf_string( + c"kCGDynamicRangeStandard", + "kCGDynamicRangeStandard", + )?, + constrained_range: load_required_tahoe_cf_string( + c"kCGDynamicRangeConstrained", + "kCGDynamicRangeConstrained", + )?, + high_range: load_required_tahoe_cf_string( + c"kCGDynamicRangeHigh", + "kCGDynamicRangeHigh", + )?, + average_light_key: load_required_tahoe_cf_string( + c"kCGContentAverageLightLevel", + "kCGContentAverageLightLevel", + )?, + }) + } + + const fn preferred_range( + self, + preferred_dynamic_range: MacosScreenshotPreferredDynamicRange, + ) -> NonNull { + match preferred_dynamic_range { + MacosScreenshotPreferredDynamicRange::Standard => self.standard_range, + MacosScreenshotPreferredDynamicRange::Constrained => self.constrained_range, + MacosScreenshotPreferredDynamicRange::High => self.high_range, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TahoeReferenceSymbolKind { + Function, + CfString, +} + +const REQUIRED_TAHOE_REFERENCE_OUTPUT_SYMBOLS: [(&CStr, TahoeReferenceSymbolKind); 8] = [ + ( + c"CGContextGetContentToneMappingInfo", + TahoeReferenceSymbolKind::Function, + ), + ( + c"CGContextSetContentToneMappingInfo", + TahoeReferenceSymbolKind::Function, + ), + ( + c"CGImageGetContentAverageLightLevel", + TahoeReferenceSymbolKind::Function, + ), + ( + c"kCGPreferredDynamicRange", + TahoeReferenceSymbolKind::CfString, + ), + ( + c"kCGDynamicRangeStandard", + TahoeReferenceSymbolKind::CfString, + ), + ( + c"kCGDynamicRangeConstrained", + TahoeReferenceSymbolKind::CfString, + ), + (c"kCGDynamicRangeHigh", TahoeReferenceSymbolKind::CfString), + ( + c"kCGContentAverageLightLevel", + TahoeReferenceSymbolKind::CfString, + ), +]; + +fn tone_mapping_options( + preferred_dynamic_range: MacosScreenshotPreferredDynamicRange, + content_average_light_level: Option, + symbols: TahoeReferenceOutputSymbols, +) -> Result, MacosCaptureError> { + let preferred_key = symbols.preferred_key; + let preferred_value = symbols.preferred_range(preferred_dynamic_range); + let average_value = content_average_light_level + .map(|value| { + // SAFETY: value points to an initialized f32 for this call. + unsafe { + CFNumber::new( + None, + CFNumberType::Float32Type, + ptr::from_ref(&value).cast(), + ) + } + .ok_or(MacosCaptureError::ScreenshotToneMappingOptionsFailed) + }) + .transpose()?; + let mut keys = vec![preferred_key.as_ptr().cast::()]; + let mut values = vec![preferred_value.as_ptr().cast::()]; + if let Some(value) = average_value.as_ref() { + keys.push(symbols.average_light_key.as_ptr().cast::()); + values.push(NonNull::from(&**value).as_ptr().cast()); + } + let count = isize::try_from(keys.len()).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + // SAFETY: keys and values are valid CFType references for this call. The + // standard callbacks retain them into the immutable dictionary. + unsafe { + CFDictionary::new( + None, + keys.as_mut_ptr().cast(), + values.as_mut_ptr().cast(), + count, + ptr::from_ref(&kCFTypeDictionaryKeyCallBacks), + ptr::from_ref(&kCFTypeDictionaryValueCallBacks), + ) + } + .ok_or(MacosCaptureError::ScreenshotToneMappingOptionsFailed) +} + +fn positive_finite(value: f32) -> Option { + (value.is_finite() && value > 0.0).then_some(value) +} + +fn load_required_tahoe_cf_string( + symbol: &std::ffi::CStr, + capability: &'static str, +) -> Result, MacosCaptureError> { + load_tahoe_cf_string(symbol).ok_or(MacosCaptureError::TahoePlatformDefect(capability)) +} + +fn load_tahoe_cf_string(symbol: &CStr) -> Option> { + let slot = load_raw_symbol(symbol)?.cast::<*mut CFString>(); + // SAFETY: Tahoe exports these names as CFStringRef globals. A null value + // is treated as an absent capability rather than passed to Core Graphics. + NonNull::new(unsafe { *slot.as_ptr() }) +} + +fn load_required_tahoe_symbol( + symbol: &std::ffi::CStr, + capability: &'static str, +) -> Result { + let raw = load_raw_symbol(symbol).ok_or(MacosCaptureError::TahoePlatformDefect(capability))?; + // SAFETY: callers select T to match the SDK declaration for this symbol. + Ok(unsafe { std::mem::transmute_copy::, T>(&raw) }) +} + +fn load_raw_symbol(symbol: &std::ffi::CStr) -> Option> { + #[link(name = "System", kind = "dylib")] + unsafe extern "C-unwind" { + fn dlsym(handle: *mut c_void, symbol: *const std::ffi::c_char) -> *mut c_void; + } + let default_handle = ptr::without_provenance_mut::(usize::MAX - 1); + // SAFETY: RTLD_DEFAULT is the Darwin sentinel at address -2, and the + // symbol is nul-terminated. + NonNull::new(unsafe { dlsym(default_handle, symbol.as_ptr()) }) +} + +fn tahoe_reference_output_symbols_present_with( + mut present: impl FnMut(&CStr, TahoeReferenceSymbolKind) -> bool, +) -> bool { + REQUIRED_TAHOE_REFERENCE_OUTPUT_SYMBOLS + .iter() + .all(|(symbol, kind)| present(symbol, *kind)) +} + +pub(crate) fn tahoe_reference_output_symbols_present() -> bool { + tahoe_reference_output_symbols_present_with(|symbol, kind| match kind { + TahoeReferenceSymbolKind::Function => load_raw_symbol(symbol).is_some(), + TahoeReferenceSymbolKind::CfString => load_tahoe_cf_string(symbol).is_some(), + }) +} + +pub(crate) fn require_tahoe_reference_output_symbols() -> Result<(), MacosCaptureError> { + if !tahoe_reference_output_symbols_present() { + return Err(MacosCaptureError::TahoePlatformDefect( + "Core Graphics Tahoe reference output", + )); + } + TahoeReferenceOutputSymbols::load().map(drop) +} + +fn encode_png(image: &CGImage, path: &Path) -> Result<(), MacosCaptureError> { + use std::os::unix::ffi::OsStrExt as _; + + #[repr(C)] + struct ImageDestination(c_void); + + #[link(name = "ImageIO", kind = "framework")] + unsafe extern "C-unwind" { + fn CGImageDestinationCreateWithURL( + url: &CFURL, + image_type: &CFString, + count: usize, + options: *const CFDictionary, + ) -> Option>; + fn CGImageDestinationAddImage( + destination: NonNull, + image: &CGImage, + properties: *const CFDictionary, + ); + fn CGImageDestinationFinalize(destination: NonNull) -> bool; + } + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C-unwind" { + fn CFRelease(value: NonNull); + } + + let bytes = OsStr::new(path).as_bytes(); + let byte_len = + isize::try_from(bytes.len()).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + // SAFETY: the path byte slice remains live for the duration of this call. + let url = + unsafe { CFURL::from_file_system_representation(None, bytes.as_ptr(), byte_len, false) } + .ok_or(MacosCaptureError::ScreenshotOutputUrlFailed)?; + let png = CFString::from_static_str("public.png"); + // SAFETY: URL, UTI, and image are retained for the complete destination + // transaction. ImageIO consumes neither borrowed value. + let destination = unsafe { + CGImageDestinationCreateWithURL(&url, &png, 1, ptr::null()) + .ok_or(MacosCaptureError::ScreenshotEncoderCreateFailed)? + }; + // SAFETY: the destination is live and expects exactly one image. + unsafe { + CGImageDestinationAddImage(destination, image, ptr::null()); + } + // SAFETY: finalization consumes no ownership and is called exactly once. + let finalized = unsafe { CGImageDestinationFinalize(destination) }; + // SAFETY: the create-rule destination owns one Core Foundation retain. + unsafe { CFRelease(destination.cast()) }; + if finalized { + Ok(()) + } else { + Err(MacosCaptureError::ScreenshotEncodeFailed) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + static REFERENCE_TONE_MAPPING_APPLIED: AtomicBool = AtomicBool::new(false); + + unsafe extern "C-unwind" fn record_reference_tone_mapping( + _context: &CGContext, + info: CGContentToneMappingInfo, + ) { + assert_eq!(info.method, CGToneMapping::ReferenceWhiteBased); + assert!(!info.options.is_null()); + REFERENCE_TONE_MAPPING_APPLIED.store(true, Ordering::Release); + } + + #[test] + fn injected_probe_requires_every_reference_output_symbol() { + assert!(tahoe_reference_output_symbols_present_with(|_, _| true)); + + for (missing_symbol, missing_kind) in REQUIRED_TAHOE_REFERENCE_OUTPUT_SYMBOLS { + assert!(!tahoe_reference_output_symbols_present_with( + |symbol, kind| symbol != missing_symbol || kind != missing_kind + )); + } + } + + #[test] + fn injected_reference_output_applies_reference_white_tone_mapping() { + let preferred_key = CFString::from_static_str("preferred"); + let standard_range = CFString::from_static_str("standard"); + let constrained_range = CFString::from_static_str("constrained"); + let high_range = CFString::from_static_str("high"); + let average_light_key = CFString::from_static_str("average-light"); + let symbols = TahoeReferenceOutputSymbols { + set_tone_mapping: record_reference_tone_mapping, + preferred_key: NonNull::from(&*preferred_key), + standard_range: NonNull::from(&*standard_range), + constrained_range: NonNull::from(&*constrained_range), + high_range: NonNull::from(&*high_range), + average_light_key: NonNull::from(&*average_light_key), + }; + let image = MacosScreenshotReferenceImage::new_fixture(MacosCaptureDynamicRange::Sdr, 0x40); + REFERENCE_TONE_MAPPING_APPLIED.store(false, Ordering::Release); + + let output = image + .copy_reference_rgba8_with_symbols( + MacosScreenshotPreferredDynamicRange::Standard, + symbols, + ) + .expect("injected reference output should render"); + + assert!(REFERENCE_TONE_MAPPING_APPLIED.load(Ordering::Acquire)); + assert_eq!(output.extent, MacosPixelExtent::new(1, 1).expect("extent")); + assert_eq!(output.bytes_per_row, 4); + assert_eq!(output.rgba8.len(), 4); + } +} From a6f3717f420946ac931e7680f1031f859aa862cf Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 05:12:44 -0700 Subject: [PATCH 081/144] feat(macos): publish Tahoe selection capabilities Forward source and session-fenced Tahoe capability evidence from the native capture session into core screen status. Mirror native lifecycle invalidation in deterministic fixtures so repicks and deactivation cannot expose stale capabilities. Cover pre-confirmation absence, asymmetric capability bits, exact source and session identity, repick reconfirmation, and inactive clearing. Co-Authored-By: Nova (Codex) --- .../hypercolor-core/src/input/screen/macos.rs | 45 ++++++++++++-- .../tests/macos_screen_capture_tests.rs | 60 ++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 2c136a104..95a970753 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -9,7 +9,8 @@ use hypercolor_macos_capture::{ MacosCaptureContentStyle, MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, - MacosProtectedSourceState as NativeProtectedSourceState, MacosTransferFunction, + MacosProtectedSourceState as NativeProtectedSourceState, + MacosTahoeSelectionCapabilities as NativeTahoeSelectionCapabilities, MacosTransferFunction, }; use tokio::sync::oneshot; @@ -50,8 +51,8 @@ use crate::input::traits::{ }; use crate::input::{ MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, - MacosScreenPlatformStatus, MacosSelectionState, SourceKind, SourcePlatformStatus, - SourceStatusHandle, SourceStatusReporter, + MacosScreenPlatformStatus, MacosSelectionState, MacosTahoeSelectionCapabilities, SourceKind, + SourcePlatformStatus, SourceStatusHandle, SourceStatusReporter, }; const WORKER_WAIT: Duration = Duration::from_millis(100); @@ -277,6 +278,7 @@ trait MacosCaptureControl: Send + Sync { fn request_authorization(&self) -> NativeProtectedSourceState; fn status(&self) -> NativeProtectedSourceState; fn selection(&self) -> MacosCaptureSelection; + fn tahoe_selection_capabilities(&self) -> Option; fn authorization(&self) -> MacosAuthorizationState; fn captured_at(&self, display_time: u64) -> anyhow::Result; } @@ -313,6 +315,10 @@ impl MacosCaptureControl for NativeCaptureControl { self.session.selection() } + fn tahoe_selection_capabilities(&self) -> Option { + self.session.tahoe_selection_capabilities() + } + fn authorization(&self) -> MacosAuthorizationState { if MacosScreenCaptureSession::screen_authorized() { MacosAuthorizationState::Authorized @@ -737,7 +743,10 @@ impl MacosScreenCaptureInput { tcc: self.control.authorization(), owner: self.owner, selection: map_selection(self.control.selection()), - tahoe_selection: None, + tahoe_selection: self + .control + .tahoe_selection_capabilities() + .map(map_tahoe_selection_capabilities), owner_conflict: self.owner_conflict.clone(), }, )))?; @@ -2388,6 +2397,17 @@ fn map_selection(selection: MacosCaptureSelection) -> MacosSelectionState { } } +fn map_tahoe_selection_capabilities( + capabilities: NativeTahoeSelectionCapabilities, +) -> MacosTahoeSelectionCapabilities { + MacosTahoeSelectionCapabilities { + source_id: capabilities.source_id, + capture_session_generation: capabilities.capture_session_generation, + hdr_capture: capabilities.hdr_capture, + dual_range_screenshots: capabilities.dual_range_screenshots, + } +} + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() @@ -2401,6 +2421,7 @@ struct FixtureControl { active_transitions: AtomicU64, status: Mutex, selection: Mutex, + tahoe_selection: Mutex>, captured_at: Mutex>, } @@ -2413,6 +2434,7 @@ impl Default for FixtureControl { active_transitions: AtomicU64::new(0), status: Mutex::new(NativeProtectedSourceState::ReadyIdle), selection: Mutex::new(MacosCaptureSelection::None), + tahoe_selection: Mutex::new(None), captured_at: Mutex::new(None), } } @@ -2427,6 +2449,9 @@ impl MacosCaptureControl for FixtureControl { fn set_active(&self, active: bool) { self.active_transitions.fetch_add(1, Ordering::AcqRel); self.active.store(active, Ordering::Release); + if !active { + *lock(&self.tahoe_selection) = None; + } *lock(&self.status) = if active { NativeProtectedSourceState::Starting } else { @@ -2451,6 +2476,10 @@ impl MacosCaptureControl for FixtureControl { lock(&self.selection).clone() } + fn tahoe_selection_capabilities(&self) -> Option { + lock(&self.tahoe_selection).clone() + } + fn authorization(&self) -> MacosAuthorizationState { match self.status() { NativeProtectedSourceState::PermissionDenied | NativeProtectedSourceState::Revoked => { @@ -2509,8 +2538,16 @@ impl MacosScreenCaptureFixture { } pub fn set_selection(&self, selection: MacosCaptureSelection) { + *lock(&self.control.tahoe_selection) = None; *lock(&self.control.selection) = selection; } + + pub fn set_tahoe_selection_capabilities( + &self, + capabilities: Option, + ) { + *lock(&self.control.tahoe_selection) = capabilities; + } } #[cfg(all(test, feature = "macos-capture-fixtures"))] diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index f34789782..fc7e668ad 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -18,7 +18,7 @@ use hypercolor_macos_capture::{ MacosCapturePixelFormat, MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, - MacosRawFrameAttachments, MacosTransferFunction, + MacosRawFrameAttachments, MacosTahoeSelectionCapabilities, MacosTransferFunction, }; const BGRA8: u32 = 0x4247_5241; @@ -170,9 +170,23 @@ fn fixture_capture_activates_only_for_live_demand() { .set_screen_capture_demand(ScreenCaptureDemand::try_active(4, 2).expect("valid demand")) .expect("fixture demand activates"); assert!(fixture.is_active()); + let source_id = Arc::from("display:00000000-0000-0000-0000-000000000001"); fixture.set_selection(MacosCaptureSelection::Display { - source_id: Arc::from("display:00000000-0000-0000-0000-000000000001"), + source_id: Arc::clone(&source_id), }); + assert!(matches!(source.sample(), Ok(InputData::None))); + let selected = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = selected.platform.as_deref() else { + panic!("expected selected macOS screen platform status"); + }; + assert_eq!(platform.tahoe_selection, None); + + fixture.set_tahoe_selection_capabilities(Some(MacosTahoeSelectionCapabilities { + source_id: Arc::clone(&source_id), + capture_session_generation: 1, + hdr_capture: true, + dual_range_screenshots: false, + })); let captured_at = Instant::now(); fixture.publish_at(fixture_frame(1, [0, 0, 255, 255]), captured_at); let data = wait_for_screen(&mut source); @@ -190,9 +204,48 @@ fn fixture_capture_activates_only_for_live_demand() { assert_eq!( platform.selection, MacosSelectionState::Display { - source_id: Arc::from("display:00000000-0000-0000-0000-000000000001"), + source_id: Arc::clone(&source_id), } ); + let tahoe = platform + .tahoe_selection + .as_ref() + .expect("confirmed stream should publish Tahoe selection capabilities"); + assert_eq!(tahoe.source_id, source_id); + assert_eq!(tahoe.capture_session_generation, 1); + assert!(tahoe.hdr_capture); + assert!(!tahoe.dual_range_screenshots); + + let replacement_source_id = Arc::from("display:00000000-0000-0000-0000-000000000002"); + fixture.set_selection(MacosCaptureSelection::Display { + source_id: Arc::clone(&replacement_source_id), + }); + assert!(matches!(source.sample(), Ok(InputData::Screen(_)))); + let repicked = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = repicked.platform.as_deref() else { + panic!("expected repicked macOS screen platform status"); + }; + assert_eq!(platform.tahoe_selection, None); + + fixture.set_tahoe_selection_capabilities(Some(MacosTahoeSelectionCapabilities { + source_id: Arc::clone(&replacement_source_id), + capture_session_generation: 2, + hdr_capture: false, + dual_range_screenshots: true, + })); + assert!(matches!(source.sample(), Ok(InputData::Screen(_)))); + let reconfirmed = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = reconfirmed.platform.as_deref() else { + panic!("expected reconfirmed macOS screen platform status"); + }; + let tahoe = platform + .tahoe_selection + .as_ref() + .expect("replacement stream should publish Tahoe selection capabilities"); + assert_eq!(tahoe.source_id, replacement_source_id); + assert_eq!(tahoe.capture_session_generation, 2); + assert!(!tahoe.hdr_capture); + assert!(tahoe.dual_range_screenshots); source .set_screen_capture_demand(ScreenCaptureDemand::Inactive) @@ -204,6 +257,7 @@ fn fixture_capture_activates_only_for_live_demand() { panic!("expected inactive macOS screen platform status"); }; assert_eq!(platform.state, CoreProtectedSourceState::ReadyIdle); + assert_eq!(platform.tahoe_selection, None); } #[test] From f381dad8a1dcbbb4d01020a4e49e1d0b0c7b67fc Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 05:33:55 -0700 Subject: [PATCH 082/144] perf(macos): benchmark native screen reduction Compare the committed wgpu Metal reducer against the scalar oracle on one retained IOSurface with exact byte parity and bounded timing samples. Probe the active Metal device for every required Metal 4 facility without mislabeling an unimplemented prototype as a performance comparison. Co-Authored-By: Nova Benchmark Agent --- .../hypercolor-macos-gpu-interop/Cargo.toml | 5 + .../examples/bench_macos_reduction.rs | 852 ++++++++++++++++++ .../hypercolor-macos-gpu-interop/src/macos.rs | 88 +- .../hypercolor-macos-gpu-interop/src/stubs.rs | 68 ++ .../tests/descriptor_tests.rs | 25 + 5 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index 24d37f4fb..095ae3dd9 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -88,6 +88,11 @@ name = "screen_capture_bridge_tests" path = "tests/screen_capture_bridge_tests.rs" required-features = ["screen-capture"] +[[example]] +name = "bench_macos_reduction" +path = "examples/bench_macos_reduction.rs" +required-features = ["screen-capture"] + [lints.rust] unsafe_code = "allow" diff --git a/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs b/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs new file mode 100644 index 000000000..77dd3b740 --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs @@ -0,0 +1,852 @@ +use std::fmt; + +const DEFAULT_SOURCE: Extent = Extent { + width: 1920, + height: 1080, +}; +const DEFAULT_OUTPUT: Extent = Extent { + width: 320, + height: 180, +}; +const DEFAULT_ITERATIONS: usize = 20; +const DEFAULT_WARMUP: usize = 3; +const MAX_DIMENSION: u32 = 8_192; +const MAX_PIXELS: u64 = 67_108_864; +const MAX_ITERATIONS: usize = 10_000; +const MAX_WARMUP: usize = 1_000; +const MAX_OPTION_PAIRS: usize = 5; +const BYTES_PER_PIXEL: u64 = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Extent { + width: u32, + height: u32, +} + +impl Extent { + fn parse(value: &str, name: &str) -> Result { + let (width, height) = value + .split_once('x') + .ok_or_else(|| format!("{name} must use WIDTHxHEIGHT"))?; + let extent = Self { + width: parse_u32(width, name)?, + height: parse_u32(height, name)?, + }; + extent.byte_len()?; + Ok(extent) + } + + fn pixels(self) -> Result { + if self.width == 0 + || self.height == 0 + || self.width > MAX_DIMENSION + || self.height > MAX_DIMENSION + { + return Err(format!( + "extent must be between 1x1 and {MAX_DIMENSION}x{MAX_DIMENSION}" + )); + } + let pixels = u64::from(self.width) * u64::from(self.height); + if pixels > MAX_PIXELS { + return Err(format!( + "extent exceeds the {MAX_PIXELS}-pixel allocation bound" + )); + } + Ok(pixels) + } + + fn byte_len(self) -> Result { + let bytes = self + .pixels()? + .checked_mul(BYTES_PER_PIXEL) + .ok_or_else(|| "pixel byte count overflowed".to_owned())?; + usize::try_from(bytes).map_err(|_| "pixel byte count does not fit usize".to_owned()) + } +} + +impl fmt::Display for Extent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}x{}", self.width, self.height) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Filter { + Nearest, + Bilinear, + #[default] + Area, +} + +impl Filter { + fn parse(value: &str) -> Result { + match value { + "nearest" => Ok(Self::Nearest), + "bilinear" => Ok(Self::Bilinear), + "area" => Ok(Self::Area), + _ => Err("filter must be nearest, bilinear, or area".to_owned()), + } + } +} + +impl fmt::Display for Filter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nearest => formatter.write_str("nearest"), + Self::Bilinear => formatter.write_str("bilinear"), + Self::Area => formatter.write_str("area"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Args { + source: Extent, + output: Extent, + filter: Filter, + iterations: usize, + warmup: usize, +} + +impl Default for Args { + fn default() -> Self { + Self { + source: DEFAULT_SOURCE, + output: DEFAULT_OUTPUT, + filter: Filter::Area, + iterations: DEFAULT_ITERATIONS, + warmup: DEFAULT_WARMUP, + } + } +} + +impl Args { + fn parse_from(arguments: impl IntoIterator) -> Result { + let mut parsed = Self::default(); + let mut arguments = arguments.into_iter(); + let _program = arguments.next(); + let mut option_pairs = 0; + while let Some(argument) = arguments.next() { + option_pairs += 1; + if option_pairs > MAX_OPTION_PAIRS { + return Err(format!( + "at most {MAX_OPTION_PAIRS} option pairs are accepted" + )); + } + let value = arguments + .next() + .ok_or_else(|| format!("{argument} requires a value"))?; + match argument.as_str() { + "--source" => parsed.source = Extent::parse(&value, "source")?, + "--output" => parsed.output = Extent::parse(&value, "output")?, + "--filter" => parsed.filter = Filter::parse(&value)?, + "--iterations" => { + parsed.iterations = + parse_bounded_usize(&value, "iterations", 1, MAX_ITERATIONS)?; + } + "--warmup" => { + parsed.warmup = parse_bounded_usize(&value, "warmup", 0, MAX_WARMUP)?; + } + _ => return Err(format!("unknown argument {argument}")), + } + } + parsed.source.byte_len()?; + parsed.output.byte_len()?; + Ok(parsed) + } +} + +fn parse_u32(value: &str, name: &str) -> Result { + value + .parse() + .map_err(|_| format!("{name} contains an invalid integer")) +} + +fn parse_bounded_usize( + value: &str, + name: &str, + minimum: usize, + maximum: usize, +) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("{name} must be an integer"))?; + if (minimum..=maximum).contains(&parsed) { + Ok(parsed) + } else { + Err(format!("{name} must be between {minimum} and {maximum}")) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Percentiles { + p50_ns: u128, + p95_ns: u128, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Metal4Decision { + NotQualified { + missing_facilities: [Option<&'static str>; 5], + }, + NotImplemented, +} + +fn metal4_decision( + probe: hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe, +) -> Metal4Decision { + if probe.all_required_facilities() { + Metal4Decision::NotImplemented + } else { + Metal4Decision::NotQualified { + missing_facilities: probe.missing_facilities(), + } + } +} + +fn percentiles(samples: &[u128]) -> Result { + if samples.is_empty() { + return Err("at least one timing sample is required".to_owned()); + } + let mut sorted = Vec::new(); + sorted + .try_reserve_exact(samples.len()) + .map_err(|_| "timing sample allocation failed".to_owned())?; + sorted.extend_from_slice(samples); + sorted.sort_unstable(); + Ok(Percentiles { + p50_ns: sorted[percentile_index(sorted.len(), 50)], + p95_ns: sorted[percentile_index(sorted.len(), 95)], + }) +} + +const fn percentile_index(sample_count: usize, percentile: usize) -> usize { + (sample_count * percentile).div_ceil(100).saturating_sub(1) +} + +#[cfg(target_os = "macos")] +fn main() { + if let Err(error) = macos::run() { + eprintln!("bench_macos_reduction: {error}"); + std::process::exit(2); + } +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("bench_macos_reduction requires macOS and a Metal-backed wgpu device"); + std::process::exit(2); +} + +#[cfg(target_os = "macos")] +mod macos { + use std::sync::{Arc, mpsc}; + use std::time::Instant; + + use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, + MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + }; + use hypercolor_macos_gpu_interop::{ + MacosMetal4CapabilityProbe, MacosNativeReducer, MacosNativeReductionDescriptor, + MacosNativeReductionFilter, MacosNativeTargetFormat, MacosScreenBridge, + probe_macos_metal4_capabilities, + }; + + use super::{Args, BYTES_PER_PIXEL, Extent, Filter, Percentiles, percentiles}; + + pub fn run() -> Result<(), String> { + let args = Args::parse_from(std::env::args())?; + let source_pixels = synthetic_bgra(args.source)?; + let frame = Arc::new(capture_frame(args.source, &source_pixels)?); + let wgpu = WgpuFixture::new()?; + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let imported = bridge + .import_frame(&wgpu.device, 1, Arc::clone(&frame)) + .map_err(|error| error.to_string())?; + let reducer = MacosNativeReducer::new(&wgpu.device).map_err(|error| error.to_string())?; + let target = reducer + .create_target( + &wgpu.device, + args.output.width, + args.output.height, + MacosNativeTargetFormat::Rgba8, + ) + .map_err(|error| error.to_string())?; + let descriptor = reduction_descriptor(args)?; + let mut cpu_output = allocate_bytes(args.output.byte_len()?, "CPU output")?; + + for _ in 0..args.warmup { + reduce_scalar(&frame, args.output, args.filter, &mut cpu_output)?; + reduce_wgpu( + &wgpu.device, + &wgpu.queue, + &reducer, + &imported, + &target, + descriptor, + )?; + } + + let cpu_times = measure(args.iterations, || { + reduce_scalar(&frame, args.output, args.filter, &mut cpu_output) + })?; + let gpu_times = measure(args.iterations, || { + reduce_wgpu( + &wgpu.device, + &wgpu.queue, + &reducer, + &imported, + &target, + descriptor, + ) + })?; + let gpu_output = + read_texture_pixels(&wgpu.device, &wgpu.queue, target.texture(), args.output)?; + if cpu_output != gpu_output { + let mismatch = cpu_output + .iter() + .zip(&gpu_output) + .position(|(cpu, gpu)| cpu != gpu) + .unwrap_or(cpu_output.len()); + return Err(format!( + "exact output parity failed at byte {mismatch}: CPU={:?}, wgpu={:?}", + cpu_output.get(mismatch), + gpu_output.get(mismatch) + )); + } + + let cpu = percentiles(&cpu_times)?; + let gpu = percentiles(&gpu_times)?; + let metal4 = + probe_macos_metal4_capabilities(&wgpu.device).map_err(|error| error.to_string())?; + print_report(args, &frame, &wgpu.adapter_info, cpu, gpu, metal4); + Ok(()) + } + + fn allocate_bytes(length: usize, name: &str) -> Result, String> { + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| format!("{name} allocation of {length} bytes failed"))?; + bytes.resize(length, 0); + Ok(bytes) + } + + fn synthetic_bgra(extent: Extent) -> Result, String> { + let mut pixels = allocate_bytes(extent.byte_len()?, "source fixture")?; + let width = usize::try_from(extent.width).map_err(|error| error.to_string())?; + for (index, pixel) in pixels + .chunks_exact_mut(BYTES_PER_PIXEL as usize) + .enumerate() + { + let x = index % width; + let y = index / width; + pixel.copy_from_slice(&[ + ((x * 17 + y * 29) & 0xff) as u8, + ((x * 31 + y * 7) & 0xff) as u8, + ((x * 11 + y * 43) & 0xff) as u8, + 255, + ]); + } + Ok(pixels) + } + + fn capture_frame(extent: Extent, pixels: &[u8]) -> Result { + let extent = MacosPixelExtent::new(extent.width, extent.height) + .map_err(|error| error.to_string())?; + let (surface, plane) = MacosCaptureSurface::new_native_bgra_fixture(extent, pixels) + .map_err(|error| error.to_string())?; + Ok(MacosCaptureFrame { + epoch: 1, + sequence: 1, + display_time: 1, + storage_extent: extent, + planes: Arc::from([plane]), + pixel_format: MacosCapturePixelFormat::Bgra8, + color: MacosCaptureColorimetry { + primaries: MacosColorPrimaries::Srgb, + transfer: MacosTransferFunction::Srgb, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }, + geometry: MacosCaptureGeometry { + display_scale_factor: MacosScale::display(1.0) + .map_err(|error| error.to_string())?, + content_scale: MacosScale::new(1.0).map_err(|error| error.to_string())?, + content_rect_points: MacosPointRect::new( + 0.0, + 0.0, + extent.width.into(), + extent.height.into(), + ) + .map_err(|error| error.to_string())?, + content_rect_pixels: MacosPixelRect::new(0, 0, extent.width, extent.height) + .map_err(|error| error.to_string())?, + screen_rect_points: None, + bounding_rect_points: None, + bounding_rect_pixels: None, + }, + damage: Arc::from([]), + cursor_composed: false, + surface, + }) + } + + fn reduction_descriptor(args: Args) -> Result { + MacosNativeReductionDescriptor::new( + [args.output.width, args.output.height], + [0, 0, args.output.width, args.output.height], + [ + 0.0, + 0.0, + args.source.width as f32, + args.source.height as f32, + ], + match args.filter { + Filter::Nearest => MacosNativeReductionFilter::Nearest, + Filter::Bilinear => MacosNativeReductionFilter::Bilinear, + Filter::Area => MacosNativeReductionFilter::Area, + }, + None, + ) + .map_err(|error| error.to_string()) + } + + fn measure( + iterations: usize, + mut operation: impl FnMut() -> Result<(), String>, + ) -> Result, String> { + let mut samples = Vec::new(); + samples + .try_reserve_exact(iterations) + .map_err(|_| "timing sample allocation failed".to_owned())?; + for _ in 0..iterations { + let started = Instant::now(); + operation()?; + samples.push(started.elapsed().as_nanos()); + } + Ok(samples) + } + + fn reduce_wgpu( + device: &wgpu::Device, + queue: &wgpu::Queue, + reducer: &MacosNativeReducer, + imported: &hypercolor_macos_gpu_interop::ImportedMacosScreenFrame, + target: &hypercolor_macos_gpu_interop::MacosNativeReductionTarget, + descriptor: MacosNativeReductionDescriptor, + ) -> Result<(), String> { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bench_macos_reduction wgpu iteration"), + }); + reducer + .encode(imported, target, descriptor, &mut encoder) + .map_err(|error| error.to_string())?; + let submission = queue.submit(Some(encoder.finish())); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| format!("wgpu reduction wait failed: {error:?}"))?; + Ok(()) + } + + fn reduce_scalar( + frame: &MacosCaptureFrame, + output_extent: Extent, + filter: Filter, + output: &mut [u8], + ) -> Result<(), String> { + if output.len() != output_extent.byte_len()? { + return Err("CPU output allocation does not match the requested extent".to_owned()); + } + frame + .with_cpu_source(|source| { + let scale_x = source.extent().width as f32 / output_extent.width as f32; + let scale_y = source.extent().height as f32 / output_extent.height as f32; + for y in 0..output_extent.height { + for x in 0..output_extent.width { + let start = [x as f32 * scale_x, y as f32 * scale_y]; + let end = [start[0] + scale_x, start[1] + scale_y]; + let sample = match filter { + Filter::Nearest => sample_nearest(source, start, end)?, + Filter::Bilinear => sample_bilinear(source, start, end)?, + Filter::Area => sample_area(source, start, end)?, + }; + let offset = ((y as usize * output_extent.width as usize) + x as usize) * 4; + output[offset..offset + 4].copy_from_slice( + &sample.map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8), + ); + } + } + Ok::<(), String>(()) + }) + .map_err(|error| error.to_string())? + } + + fn sample_nearest( + source: hypercolor_macos_capture::MacosCpuSourceView<'_>, + start: [f32; 2], + end: [f32; 2], + ) -> Result<[f32; 4], String> { + let center = [(start[0] + end[0]) * 0.5, (start[1] + end[1]) * 0.5]; + load_clamped(source, center[0].floor() as i32, center[1].floor() as i32) + } + + fn sample_bilinear( + source: hypercolor_macos_capture::MacosCpuSourceView<'_>, + start: [f32; 2], + end: [f32; 2], + ) -> Result<[f32; 4], String> { + let centered = [ + (start[0] + end[0]) * 0.5 - 0.5, + (start[1] + end[1]) * 0.5 - 0.5, + ]; + let lower = [centered[0].floor() as i32, centered[1].floor() as i32]; + let fraction = [ + centered[0] - centered[0].floor(), + centered[1] - centered[1].floor(), + ]; + let top = mix( + load_clamped(source, lower[0], lower[1])?, + load_clamped(source, lower[0] + 1, lower[1])?, + fraction[0], + ); + let bottom = mix( + load_clamped(source, lower[0], lower[1] + 1)?, + load_clamped(source, lower[0] + 1, lower[1] + 1)?, + fraction[0], + ); + Ok(mix(top, bottom, fraction[1])) + } + + fn sample_area( + source: hypercolor_macos_capture::MacosCpuSourceView<'_>, + start: [f32; 2], + end: [f32; 2], + ) -> Result<[f32; 4], String> { + let first = [start[0].floor() as i32, start[1].floor() as i32]; + let last = [end[0].ceil() as i32, end[1].ceil() as i32]; + let mut total = [0.0_f32; 4]; + let mut total_weight = 0.0_f32; + for y in first[1]..last[1] { + let height = (end[1].min((y + 1) as f32) - start[1].max(y as f32)).max(0.0); + for x in first[0]..last[0] { + let width = (end[0].min((x + 1) as f32) - start[0].max(x as f32)).max(0.0); + let weight = width * height; + let sample = load_clamped(source, x, y)?; + for channel in 0..4 { + total[channel] += sample[channel] * weight; + } + total_weight += weight; + } + } + Ok(total.map(|channel| channel / total_weight.max(f32::EPSILON))) + } + + fn load_clamped( + source: hypercolor_macos_capture::MacosCpuSourceView<'_>, + x: i32, + y: i32, + ) -> Result<[f32; 4], String> { + let maximum_x = source.extent().width.saturating_sub(1); + let maximum_y = source.extent().height.saturating_sub(1); + source + .sample_rgba32f( + x.clamp(0, maximum_x as i32) as u32, + y.clamp(0, maximum_y as i32) as u32, + ) + .map_err(|error| error.to_string()) + } + + fn mix(left: [f32; 4], right: [f32; 4], weight: f32) -> [f32; 4] { + std::array::from_fn(|channel| left[channel] + (right[channel] - left[channel]) * weight) + } + + fn read_texture_pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + extent: Extent, + ) -> Result, String> { + let unpadded = extent + .width + .checked_mul(BYTES_PER_PIXEL as u32) + .ok_or_else(|| "readback row byte count overflowed".to_owned())?; + let padded = unpadded.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer_size = u64::from(padded) + .checked_mul(u64::from(extent.height)) + .ok_or_else(|| "readback allocation overflowed".to_owned())?; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bench_macos_reduction readback"), + size: buffer_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bench_macos_reduction readback"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(extent.height), + }, + }, + wgpu::Extent3d { + width: extent.width, + height: extent.height, + depth_or_array_layers: 1, + }, + ); + let submission = queue.submit(Some(encoder.finish())); + let slice = buffer.slice(..buffer_size); + let (sender, receiver) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| format!("readback wait failed: {error:?}"))?; + receiver + .recv() + .map_err(|error| format!("readback callback failed: {error}"))? + .map_err(|error| format!("readback mapping failed: {error}"))?; + let mapped = slice.get_mapped_range(); + let mut pixels = allocate_bytes(extent.byte_len()?, "readback output")?; + for (source, target) in mapped + .chunks_exact(padded as usize) + .zip(pixels.chunks_exact_mut(unpadded as usize)) + { + target.copy_from_slice(&source[..unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(pixels) + } + + fn print_report( + args: Args, + frame: &MacosCaptureFrame, + adapter: &wgpu::AdapterInfo, + cpu: Percentiles, + gpu: Percentiles, + metal4: MacosMetal4CapabilityProbe, + ) { + println!("benchmark=bench_macos_reduction"); + println!("fixture=synthetic_iosurface"); + println!("source_pixels={}", args.source); + println!("output_pixels={}", args.output); + println!("source_bytes={}", args.source.byte_len().unwrap_or(0)); + println!( + "source_iosurface_allocation_bytes={}", + frame.surface.allocation_bytes + ); + println!("output_bytes={}", args.output.byte_len().unwrap_or(0)); + println!("source_pixel_format=bgra8_unorm"); + println!("output_pixel_format=rgba8_unorm"); + println!("dynamic_range=sdr"); + println!("filter={}", args.filter); + println!("iterations={}", args.iterations); + println!("warmup={}", args.warmup); + println!("device_name={}", adapter.name); + println!("backend={:?}", adapter.backend); + println!("driver={}", adapter.driver); + println!("driver_info={}", adapter.driver_info); + println!("cpu_metric=scalar_reduction_wall_time"); + println!("cpu_p50_ns={}", cpu.p50_ns); + println!("cpu_p95_ns={}", cpu.p95_ns); + println!("wgpu_metric=wgpu_encode_submit_to_completion_wall_time"); + println!("wgpu_p50_ns={}", gpu.p50_ns); + println!("wgpu_p95_ns={}", gpu.p95_ns); + println!("output_parity=exact"); + println!("metal4_registry_id={}", metal4.metal_registry_id); + println!("metal4_family={}", metal4.metal4_family); + println!("metal4_command_allocator={}", metal4.command_allocator); + println!("metal4_command_queue={}", metal4.command_queue); + println!("metal4_command_buffer={}", metal4.command_buffer); + println!("metal4_residency_set={}", metal4.residency_set); + match super::metal4_decision(metal4) { + super::Metal4Decision::NotQualified { missing_facilities } => { + let missing = missing_facilities + .into_iter() + .flatten() + .collect::>() + .join(","); + println!("metal4_status=not_qualified"); + println!("metal4_missing_facilities={missing}"); + } + super::Metal4Decision::NotImplemented => { + println!("metal4_status=not_implemented"); + println!( + "metal4_reason=direct_command_allocator_and_residency_set_prototype_not_implemented" + ); + } + } + } + + struct WgpuFixture { + _instance: wgpu::Instance, + adapter_info: wgpu::AdapterInfo, + device: wgpu::Device, + queue: wgpu::Queue, + } + + impl WgpuFixture { + fn new() -> Result { + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + })) + .map_err(|error| format!("could not create wgpu adapter: {error}"))?; + let adapter_info = adapter.get_info(); + if adapter_info.backend != wgpu::Backend::Metal { + return Err(format!( + "requires Metal wgpu backend, got {:?}", + adapter_info.backend + )); + } + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("bench_macos_reduction"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + })) + .map_err(|error| format!("could not create wgpu device: {error}"))?; + Ok(Self { + _instance: instance, + adapter_info, + device, + queue, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(arguments: &[&str]) -> Result { + Args::parse_from(arguments.iter().map(ToString::to_string)) + } + + #[test] + fn parser_accepts_every_bounded_option() { + assert_eq!( + parse(&[ + "bench", + "--source", + "3840x2160", + "--output", + "640x480", + "--filter", + "bilinear", + "--iterations", + "41", + "--warmup", + "7", + ]), + Ok(Args { + source: Extent { + width: 3840, + height: 2160, + }, + output: Extent { + width: 640, + height: 480, + }, + filter: Filter::Bilinear, + iterations: 41, + warmup: 7, + }) + ); + } + + #[test] + fn parser_rejects_unbounded_or_incomplete_inputs() { + assert!(parse(&["bench", "--source", "16384x16384"]).is_err()); + assert!(parse(&["bench", "--source", "8193x1"]).is_err()); + assert!(parse(&["bench", "--iterations", "10001"]).is_err()); + assert!(parse(&["bench", "--warmup", "1001"]).is_err()); + assert!(parse(&["bench", "--filter", "magic"]).is_err()); + assert!(parse(&["bench", "--output"]).is_err()); + assert!(parse(&["bench", "--mystery", "1"]).is_err()); + assert!( + parse(&[ + "bench", + "--source", + "1x1", + "--output", + "1x1", + "--filter", + "area", + "--iterations", + "1", + "--warmup", + "0", + "--source", + "1x1", + ]) + .is_err() + ); + } + + #[test] + fn percentile_ranks_are_nearest_rank_and_deterministic() { + let samples = [100, 20, 80, 40, 60, 10, 30, 50, 70, 90]; + assert_eq!( + percentiles(&samples), + Ok(Percentiles { + p50_ns: 50, + p95_ns: 100, + }) + ); + assert!(percentiles(&[]).is_err()); + } + + #[test] + fn metal4_decision_never_calls_ordinary_metal_a_comparison() { + let qualified = hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { + metal_registry_id: 9, + metal4_family: true, + command_allocator: true, + command_queue: true, + command_buffer: true, + residency_set: true, + }; + assert_eq!(metal4_decision(qualified), Metal4Decision::NotImplemented); + assert_eq!( + metal4_decision(hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { + command_queue: false, + ..qualified + }), + Metal4Decision::NotQualified { + missing_facilities: [None, None, Some("command_queue"), None, None] + } + ); + } +} diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index 065b4cc55..e9132aa42 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -4,9 +4,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; +use objc2::{runtime::NSObjectProtocol, sel}; +#[cfg(feature = "screen-capture")] +use objc2_core_foundation::CFRetained; use objc2_core_foundation::{ - CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault, - kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, + CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault, kCFTypeDictionaryKeyCallBacks, + kCFTypeDictionaryValueCallBacks, }; #[cfg(feature = "screen-capture")] use objc2_core_video::{ @@ -42,6 +45,87 @@ type CoreVideoMetalTexturePlane = ( /// Result type for macOS GPU interop operations. pub type Result = std::result::Result; +/// Runtime facilities required by the direct Metal 4 reduction prototype. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacosMetal4CapabilityProbe { + /// Registry identity of the exact Metal device behind the wgpu device. + pub metal_registry_id: u64, + /// Whether the active device reports the Metal 4 GPU family. + pub metal4_family: bool, + /// Whether the active device exposes Metal 4 command allocators. + pub command_allocator: bool, + /// Whether the active device exposes Metal 4 command queues. + pub command_queue: bool, + /// Whether the active device exposes Metal 4 command buffers. + pub command_buffer: bool, + /// Whether the active device exposes residency-set creation. + pub residency_set: bool, +} + +impl MacosMetal4CapabilityProbe { + /// Whether every facility required by the prototype is callable. + #[must_use] + pub const fn all_required_facilities(self) -> bool { + self.metal4_family + && self.command_allocator + && self.command_queue + && self.command_buffer + && self.residency_set + } + + /// Missing facilities in a stable order, padded with `None`. + #[must_use] + pub const fn missing_facilities(self) -> [Option<&'static str>; 5] { + [ + if self.metal4_family { + None + } else { + Some("metal4_family") + }, + if self.command_allocator { + None + } else { + Some("command_allocator") + }, + if self.command_queue { + None + } else { + Some("command_queue") + }, + if self.command_buffer { + None + } else { + Some("command_buffer") + }, + if self.residency_set { + None + } else { + Some("residency_set") + }, + ] + } +} + +/// Probe Metal 4 facilities on the exact Metal device behind a wgpu device. +pub fn probe_macos_metal4_capabilities( + device: &wgpu::Device, +) -> Result { + let (metal_registry_id, _) = metal_device_import_contract(device)?; + // SAFETY: the HAL device is borrowed only for immediate capability queries + // and never outlives the wgpu device. + let hal_device = unsafe { device.as_hal::() } + .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice)?; + let raw_device = hal_device.raw_device(); + Ok(MacosMetal4CapabilityProbe { + metal_registry_id, + metal4_family: raw_device.supportsFamily(MTLGPUFamily::Metal4), + command_allocator: raw_device.respondsToSelector(sel!(newCommandAllocator)), + command_queue: raw_device.respondsToSelector(sel!(newMTL4CommandQueue)), + command_buffer: raw_device.respondsToSelector(sel!(newCommandBuffer)), + residency_set: raw_device.respondsToSelector(sel!(newResidencySetWithDescriptor:error:)), + }) +} + /// Errors raised while preparing or importing macOS GPU surfaces. #[derive(Debug, Error, PartialEq, Eq)] #[non_exhaustive] diff --git a/crates/hypercolor-macos-gpu-interop/src/stubs.rs b/crates/hypercolor-macos-gpu-interop/src/stubs.rs index f9124a012..107f29a4d 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -5,6 +5,74 @@ use thiserror::Error; /// Result type for macOS GPU interop operations. pub type Result = std::result::Result; +/// Runtime facilities required by the direct Metal 4 reduction prototype. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacosMetal4CapabilityProbe { + /// Registry identity of the exact Metal device behind the wgpu device. + pub metal_registry_id: u64, + /// Whether the active device reports the Metal 4 GPU family. + pub metal4_family: bool, + /// Whether the active device exposes Metal 4 command allocators. + pub command_allocator: bool, + /// Whether the active device exposes Metal 4 command queues. + pub command_queue: bool, + /// Whether the active device exposes Metal 4 command buffers. + pub command_buffer: bool, + /// Whether the active device exposes residency-set creation. + pub residency_set: bool, +} + +impl MacosMetal4CapabilityProbe { + /// Whether every facility required by the prototype is callable. + #[must_use] + pub const fn all_required_facilities(self) -> bool { + self.metal4_family + && self.command_allocator + && self.command_queue + && self.command_buffer + && self.residency_set + } + + /// Missing facilities in a stable order, padded with `None`. + #[must_use] + pub const fn missing_facilities(self) -> [Option<&'static str>; 5] { + [ + if self.metal4_family { + None + } else { + Some("metal4_family") + }, + if self.command_allocator { + None + } else { + Some("command_allocator") + }, + if self.command_queue { + None + } else { + Some("command_queue") + }, + if self.command_buffer { + None + } else { + Some("command_buffer") + }, + if self.residency_set { + None + } else { + Some("residency_set") + }, + ] + } +} + +/// Probe Metal 4 facilities on the exact Metal device behind a wgpu device. +pub fn probe_macos_metal4_capabilities( + _device: &wgpu::Device, +) -> Result { + Err(MacosGpuInteropError::UnsupportedPlatform) +} + /// Errors raised while preparing or importing macOS GPU surfaces. #[derive(Debug, Error, PartialEq, Eq)] #[non_exhaustive] diff --git a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs index a58775e37..665c231b8 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs @@ -1,5 +1,6 @@ use hypercolor_macos_gpu_interop::{ ImportedFrameFormat, MacosGpuInteropError, MacosIosurfaceImportDescriptor, + MacosMetal4CapabilityProbe, }; #[test] @@ -8,6 +9,30 @@ fn descriptor_rejects_zero_sized_frames() { assert!(MacosIosurfaceImportDescriptor::new(1, 0, ImportedFrameFormat::Bgra8Unorm).is_err()); } +#[test] +fn metal4_probe_requires_every_facility() { + let complete = MacosMetal4CapabilityProbe { + metal_registry_id: 42, + metal4_family: true, + command_allocator: true, + command_queue: true, + command_buffer: true, + residency_set: true, + }; + assert!(complete.all_required_facilities()); + assert_eq!(complete.missing_facilities(), [None; 5]); + + let missing_command_buffer = MacosMetal4CapabilityProbe { + command_buffer: false, + ..complete + }; + assert!(!missing_command_buffer.all_required_facilities()); + assert_eq!( + missing_command_buffer.missing_facilities(), + [None, None, None, Some("command_buffer"), None] + ); +} + #[test] fn descriptor_rejects_iosurface_row_shapes_that_exceed_cfnumber_i32() { let width = i32::MAX as u32 / 4 + 1; From 1ebde18ea4bd717e234ba1bc368d092e1dd90328 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 06:08:26 -0700 Subject: [PATCH 083/144] feat(cli): watch daemon status from ownership events Replace timer polling with one acknowledged event subscription and coalesced authoritative status refreshes. Bearer authentication stays out of request targets, and one persistent interrupt covers every network phase. Co-Authored-By: Nova (GPT-5.6) --- Cargo.lock | 2 + crates/hypercolor-cli/Cargo.toml | 2 + crates/hypercolor-cli/src/client.rs | 311 ++++++++++++ crates/hypercolor-cli/src/commands/status.rs | 495 ++++++++++++++++++- crates/hypercolor-cli/src/lib.rs | 2 +- 5 files changed, 790 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19bebfae1..fc16dbd48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4960,6 +4960,7 @@ dependencies = [ "clap", "clap_complete", "dirs 6.0.0", + "futures-util", "hypercolor-core", "hypercolor-daemon", "hypercolor-macos-owner", @@ -4972,6 +4973,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tokio-tungstenite 0.26.2", "toml 0.8.2", "tracing-subscriber", "unicode-width", diff --git a/crates/hypercolor-cli/Cargo.toml b/crates/hypercolor-cli/Cargo.toml index 6b42c1534..6b5c1ab97 100644 --- a/crates/hypercolor-cli/Cargo.toml +++ b/crates/hypercolor-cli/Cargo.toml @@ -26,6 +26,8 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } +futures-util = { workspace = true } +tokio-tungstenite = { workspace = true } tracing-subscriber = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } diff --git a/crates/hypercolor-cli/src/client.rs b/crates/hypercolor-cli/src/client.rs index 272bfd323..c389c5129 100644 --- a/crates/hypercolor-cli/src/client.rs +++ b/crates/hypercolor-cli/src/client.rs @@ -5,8 +5,17 @@ //! rather than panicking. use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; use serde::Serialize; use std::time::Duration; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::{Message, http}; + +type DaemonWebSocket = + tokio_tungstenite::WebSocketStream>; +const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const WEBSOCKET_ACKNOWLEDGMENT_TIMEOUT: Duration = Duration::from_secs(5); /// HTTP client for the Hypercolor daemon REST API. #[derive(Debug, Clone)] @@ -54,6 +63,19 @@ impl DaemonClient { parse_api_response(response).await } + /// Subscribe to the daemon's event channel. + /// + /// The returned stream is acknowledged before this method completes, so + /// callers can fetch an authoritative REST snapshot without an event gap. + /// + /// # Errors + /// + /// Returns an error if the WebSocket cannot connect, the subscription is + /// rejected, or the connection closes before acknowledgment. + pub async fn subscribe_events(&self) -> Result { + DaemonEventSubscription::connect(&self.base_url, self.api_key.as_deref()).await + } + /// Send a GET request to a path mounted outside the `/api/v1` prefix, /// such as the top-level `/health` probe. /// @@ -180,6 +202,143 @@ impl DaemonClient { } } +/// Acknowledged daemon event-channel subscription. +pub struct DaemonEventSubscription { + stream: DaemonWebSocket, +} + +impl DaemonEventSubscription { + async fn connect(base_url: &str, api_key: Option<&str>) -> Result { + let request = websocket_request(base_url, api_key)?; + let (stream, _) = tokio::time::timeout( + WEBSOCKET_CONNECT_TIMEOUT, + tokio_tungstenite::connect_async(request), + ) + .await + .context("Timed out connecting to daemon event stream")? + .context("Failed to connect to daemon event stream")?; + let mut subscription = Self { stream }; + subscription + .stream + .send(Message::Text( + serde_json::json!({ + "type": "subscribe", + "channels": ["events"] + }) + .to_string() + .into(), + )) + .await + .context("Failed to subscribe to daemon events")?; + tokio::time::timeout( + WEBSOCKET_ACKNOWLEDGMENT_TIMEOUT, + subscription.wait_for_acknowledgment(), + ) + .await + .context("Timed out waiting for daemon event subscription acknowledgment")??; + Ok(subscription) + } + + async fn wait_for_acknowledgment(&mut self) -> Result<()> { + while let Some(message) = self.next_message().await? { + let Message::Text(text) = message else { + continue; + }; + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + if value.get("type").and_then(serde_json::Value::as_str) == Some("error") { + let reason = value + .get("message") + .or_else(|| value.get("error")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unspecified protocol error"); + anyhow::bail!("Daemon rejected event subscription: {reason}"); + } + if value.get("type").and_then(serde_json::Value::as_str) == Some("subscribed") + && value + .get("channels") + .and_then(serde_json::Value::as_array) + .is_some_and(|channels| channels.iter().any(|channel| channel == "events")) + { + return Ok(()); + } + } + anyhow::bail!("Daemon event stream closed before subscription acknowledgment") + } + + /// Wait for the next safe daemon event. + /// + /// # Errors + /// + /// Returns an error for WebSocket transport failures. + pub async fn next_event(&mut self) -> Result> { + while let Some(message) = self.next_message().await? { + let Message::Text(text) = message else { + continue; + }; + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + if value.get("type").and_then(serde_json::Value::as_str) == Some("event") { + return Ok(Some(value)); + } + } + Ok(None) + } + + async fn next_message(&mut self) -> Result> { + loop { + let Some(message) = self.stream.next().await else { + return Ok(None); + }; + let message = message.context("Daemon event stream failed")?; + match message { + Message::Close(_) => return Ok(None), + Message::Ping(payload) => { + self.stream + .send(Message::Pong(payload)) + .await + .context("Failed to answer daemon event-stream ping")?; + } + message => return Ok(Some(message)), + } + } + } + + /// Close the event subscription gracefully. + pub async fn close(mut self) { + let _ = self.stream.send(Message::Close(None)).await; + } +} + +fn websocket_url(base_url: &str) -> String { + let base = base_url.strip_prefix("https://").map_or_else( + || { + base_url + .strip_prefix("http://") + .map(|authority| format!("ws://{authority}")) + .unwrap_or_else(|| format!("ws://{base_url}")) + }, + |authority| format!("wss://{authority}"), + ); + format!("{base}/api/v1/ws") +} + +fn websocket_request(base_url: &str, api_key: Option<&str>) -> Result> { + let mut request = websocket_url(base_url) + .into_client_request() + .context("Failed to construct daemon event-stream request")?; + if let Some(api_key) = api_key { + let authorization = HeaderValue::from_str(&format!("Bearer {api_key}")) + .context("API key cannot be represented in an authorization header")?; + request + .headers_mut() + .insert(http::header::AUTHORIZATION, authorization); + } + Ok(request) +} + async fn parse_api_response(response: reqwest::Response) -> Result { let status = response.status(); if !status.is_success() { @@ -194,3 +353,155 @@ async fn parse_api_response(response: reqwest::Response) -> Result Result; + async fn status_snapshot(&self) -> Result; +} + +trait StatusWatchEvents { + async fn next_status_event(&mut self) -> Result>; + async fn close(self); +} + +impl StatusWatchClient for DaemonClient { + type Events = DaemonEventSubscription; + + async fn subscribe_status_events(&self) -> Result { + self.subscribe_events().await + } + + async fn status_snapshot(&self) -> Result { + self.get("/status").await + } +} + +impl StatusWatchEvents for DaemonEventSubscription { + async fn next_status_event(&mut self) -> Result> { + self.next_event().await + } + + async fn close(self) { + self.close().await; + } +} + +#[derive(Debug)] +struct StatusWatchError { + exit_code: i32, + source: anyhow::Error, +} + +impl StatusWatchError { + fn connection(source: anyhow::Error) -> Self { + Self { + exit_code: 2, + source, + } + } + + fn stream(source: anyhow::Error) -> Self { + Self { + exit_code: 1, + source, + } + } +} + +impl std::fmt::Display for StatusWatchError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("status watch failed") + } +} + +impl std::error::Error for StatusWatchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + +pub(crate) fn exit_code_for_error(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(|error| error.exit_code) +} + /// Execute the `status` subcommand. /// /// # Errors @@ -25,31 +102,115 @@ pub struct StatusArgs { /// Returns an error if the daemon is unreachable. pub async fn execute(args: &StatusArgs, client: &DaemonClient, ctx: &OutputContext) -> Result<()> { if args.watch { - let interval = args.interval.max(0.2); - loop { - let response = client.get("/status").await?; - render_status(&response, ctx)?; + return watch_status(args, client, ctx).await; + } + + let response = client.get("/status").await?; + render_status(&response, ctx)?; + + Ok(()) +} + +async fn watch_status(args: &StatusArgs, client: &DaemonClient, ctx: &OutputContext) -> Result<()> { + watch_status_until(args, client, ctx, tokio::signal::ctrl_c()).await +} - let sleep = tokio::time::sleep(std::time::Duration::from_secs_f64(interval)); - tokio::pin!(sleep); +async fn watch_status_until( + args: &StatusArgs, + client: &C, + ctx: &OutputContext, + interrupt: F, +) -> Result<()> +where + C: StatusWatchClient, + F: Future>, +{ + let minimum_interval = Duration::from_secs_f64(args.interval.max(0.2)); + tokio::pin!(interrupt); + let mut events = tokio::select! { + subscription = client.subscribe_status_events() => { + subscription.map_err(StatusWatchError::connection)? + } + signal = interrupt.as_mut() => { + signal?; + report_watch_stopped(ctx); + return Ok(()); + } + }; + let initial = tokio::select! { + response = client.status_snapshot() => { + response.map_err(StatusWatchError::connection)? + } + signal = interrupt.as_mut() => { + signal?; + events.close().await; + report_watch_stopped(ctx); + return Ok(()); + } + }; + render_status(&initial, ctx)?; + let mut last_rendered = tokio::time::Instant::now(); + + loop { + let next = tokio::select! { + event = events.next_status_event() => { + event.map_err(StatusWatchError::stream)? + }, + signal = interrupt.as_mut() => { + signal?; + events.close().await; + report_watch_stopped(ctx); + return Ok(()); + } + }; + if next.is_none() { + return Err(StatusWatchError::stream(anyhow::anyhow!( + "Daemon event stream closed while watching status" + )) + .into()); + } + + let deadline = last_rendered + minimum_interval; + while tokio::time::Instant::now() < deadline { tokio::select! { - () = &mut sleep => {} - _ = tokio::signal::ctrl_c() => { - if !ctx.quiet { - println!(); - ctx.info("Stopped status watch."); + () = tokio::time::sleep_until(deadline) => break, + event = events.next_status_event() => { + if event.map_err(StatusWatchError::stream)?.is_none() { + return Err(StatusWatchError::stream(anyhow::anyhow!( + "Daemon event stream closed while watching status" + )).into()); } - break; + } + signal = interrupt.as_mut() => { + signal?; + events.close().await; + report_watch_stopped(ctx); + return Ok(()); } } } - return Ok(()); - } - let response = client.get("/status").await?; - render_status(&response, ctx)?; + let status = tokio::select! { + response = client.status_snapshot() => { + response.map_err(StatusWatchError::stream)? + } + signal = interrupt.as_mut() => { + signal?; + events.close().await; + report_watch_stopped(ctx); + return Ok(()); + } + }; + render_status(&status, ctx)?; + last_rendered = tokio::time::Instant::now(); + } +} - Ok(()) +fn report_watch_stopped(ctx: &OutputContext) { + if !ctx.quiet { + println!(); + ctx.info("Stopped status watch."); + } } fn render_status(data: &serde_json::Value, ctx: &OutputContext) -> Result<()> { @@ -517,10 +678,302 @@ fn format_scene_summary(data: &serde_json::Value, p: &Painter) -> Option #[cfg(test)] mod tests { - use super::{format_count, format_kib, format_uptime, status_table_lines}; - use crate::output::Painter; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::Duration; + + use anyhow::Result; + use tokio::sync::{Mutex, Notify, mpsc, oneshot}; + + use super::{ + StatusArgs, StatusWatchClient, StatusWatchError, StatusWatchEvents, exit_code_for_error, + format_count, format_kib, format_uptime, status_table_lines, watch_status_until, + }; + use crate::output::{OutputContext, OutputFormat, Painter}; use serde_json::json; + struct FakeWatchClient { + statuses: Mutex>>, + events: Mutex>>>>, + subscriptions: AtomicUsize, + status_requests: AtomicUsize, + closed: Arc, + subscription_gate: Option>, + } + + struct FakeWatchEvents { + events: mpsc::UnboundedReceiver>>, + closed: Arc, + } + + type FakeStatusSender = mpsc::UnboundedSender>; + type FakeEventSender = mpsc::UnboundedSender>>; + + impl StatusWatchClient for FakeWatchClient { + type Events = FakeWatchEvents; + + async fn subscribe_status_events(&self) -> Result { + self.subscriptions.fetch_add(1, Ordering::AcqRel); + if let Some(gate) = &self.subscription_gate { + gate.notified().await; + } + let events = self + .events + .lock() + .await + .take() + .ok_or_else(|| anyhow::anyhow!("fixture subscription already consumed"))?; + Ok(FakeWatchEvents { + events, + closed: Arc::clone(&self.closed), + }) + } + + async fn status_snapshot(&self) -> Result { + self.status_requests.fetch_add(1, Ordering::AcqRel); + self.statuses + .lock() + .await + .recv() + .await + .ok_or_else(|| anyhow::anyhow!("fixture status stream closed"))? + } + } + + impl StatusWatchEvents for FakeWatchEvents { + async fn next_status_event(&mut self) -> Result> { + self.events.recv().await.unwrap_or(Ok(None)) + } + + async fn close(self) { + self.closed.store(true, Ordering::Release); + } + } + + fn fake_watch_client() -> (Arc, FakeStatusSender, FakeEventSender) { + let (status_tx, status_rx) = mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + ( + Arc::new(FakeWatchClient { + statuses: Mutex::new(status_rx), + events: Mutex::new(Some(event_rx)), + subscriptions: AtomicUsize::new(0), + status_requests: AtomicUsize::new(0), + closed: Arc::new(AtomicBool::new(false)), + subscription_gate: None, + }), + status_tx, + event_tx, + ) + } + + fn watch_context() -> OutputContext { + OutputContext::new(OutputFormat::Plain, false, true, true, None) + } + + async fn wait_for_status_requests(client: &FakeWatchClient, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while client.status_requests.load(Ordering::Acquire) != expected { + tokio::task::yield_now().await; + } + }) + .await + .expect("fixture should reach the expected request count"); + } + + async fn wait_for_subscriptions(client: &FakeWatchClient, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while client.subscriptions.load(Ordering::Acquire) != expected { + tokio::task::yield_now().await; + } + }) + .await + .expect("fixture should reach the expected subscription count"); + } + + #[tokio::test] + async fn watch_interrupt_cancels_a_blocked_subscription() { + let (status_tx, status_rx) = mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let client = Arc::new(FakeWatchClient { + statuses: Mutex::new(status_rx), + events: Mutex::new(Some(event_rx)), + subscriptions: AtomicUsize::new(0), + status_requests: AtomicUsize::new(0), + closed: Arc::new(AtomicBool::new(false)), + subscription_gate: Some(Arc::new(Notify::new())), + }); + let (interrupt_tx, interrupt_rx) = oneshot::channel(); + let task = tokio::spawn({ + let client = Arc::clone(&client); + async move { + watch_status_until( + &StatusArgs { + watch: true, + interval: 0.2, + }, + client.as_ref(), + &watch_context(), + async move { + interrupt_rx + .await + .map_err(|_| std::io::Error::other("fixture interrupt sender dropped")) + }, + ) + .await + } + }); + + wait_for_subscriptions(&client, 1).await; + interrupt_tx.send(()).expect("interrupt should deliver"); + task.await + .expect("watch task should join") + .expect("interrupt should cancel the blocked subscription"); + + assert_eq!(client.status_requests.load(Ordering::Acquire), 0); + assert!(!client.closed.load(Ordering::Acquire)); + drop(status_tx); + drop(event_tx); + } + + #[tokio::test] + async fn watch_refreshes_only_after_events_and_reports_stream_close() { + let (client, status_tx, event_tx) = fake_watch_client(); + status_tx + .send(Ok(json!({ "active_effect": "initial" }))) + .expect("initial status should queue"); + let task = tokio::spawn({ + let client = Arc::clone(&client); + async move { + watch_status_until( + &StatusArgs { + watch: true, + interval: 0.2, + }, + client.as_ref(), + &watch_context(), + std::future::pending::>(), + ) + .await + } + }); + + wait_for_status_requests(&client, 1).await; + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!(client.status_requests.load(Ordering::Acquire), 1); + + status_tx + .send(Ok(json!({ "active_effect": "updated" }))) + .expect("updated status should queue"); + event_tx + .send(Ok(Some(json!({ "type": "event" })))) + .expect("event should queue"); + wait_for_status_requests(&client, 2).await; + drop(event_tx); + + let error = task + .await + .expect("watch task should join") + .expect_err("unexpected stream close should fail"); + assert_eq!(exit_code_for_error(&error), Some(1)); + assert_eq!(client.subscriptions.load(Ordering::Acquire), 1); + } + + #[tokio::test] + async fn watch_coalesces_event_bursts_and_closes_on_interrupt() { + let (client, status_tx, event_tx) = fake_watch_client(); + status_tx + .send(Ok(json!({ "active_effect": "initial" }))) + .expect("initial status should queue"); + status_tx + .send(Ok(json!({ "active_effect": "coalesced" }))) + .expect("coalesced status should queue"); + let (interrupt_tx, interrupt_rx) = oneshot::channel(); + let task = tokio::spawn({ + let client = Arc::clone(&client); + async move { + watch_status_until( + &StatusArgs { + watch: true, + interval: 0.2, + }, + client.as_ref(), + &watch_context(), + async move { + interrupt_rx + .await + .map_err(|_| std::io::Error::other("fixture interrupt sender dropped")) + }, + ) + .await + } + }); + + wait_for_status_requests(&client, 1).await; + for sequence in 1..=3 { + event_tx + .send(Ok(Some(json!({ "sequence": sequence })))) + .expect("burst event should queue"); + } + wait_for_status_requests(&client, 2).await; + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!(client.status_requests.load(Ordering::Acquire), 2); + + interrupt_tx.send(()).expect("interrupt should deliver"); + task.await + .expect("watch task should join") + .expect("interrupt should stop cleanly"); + assert!(client.closed.load(Ordering::Acquire)); + assert_eq!(client.subscriptions.load(Ordering::Acquire), 1); + } + + #[tokio::test] + async fn watch_interrupt_remains_live_during_rest_refresh() { + let (client, status_tx, event_tx) = fake_watch_client(); + status_tx + .send(Ok(json!({ "active_effect": "initial" }))) + .expect("initial status should queue"); + let (interrupt_tx, interrupt_rx) = oneshot::channel(); + let task = tokio::spawn({ + let client = Arc::clone(&client); + async move { + watch_status_until( + &StatusArgs { + watch: true, + interval: 0.2, + }, + client.as_ref(), + &watch_context(), + async move { + interrupt_rx + .await + .map_err(|_| std::io::Error::other("fixture interrupt sender dropped")) + }, + ) + .await + } + }); + + wait_for_status_requests(&client, 1).await; + tokio::time::sleep(Duration::from_millis(220)).await; + event_tx + .send(Ok(Some(json!({ "type": "event" })))) + .expect("event should queue"); + wait_for_status_requests(&client, 2).await; + interrupt_tx.send(()).expect("interrupt should deliver"); + + task.await + .expect("watch task should join") + .expect("interrupt should cancel an in-flight refresh"); + assert!(client.closed.load(Ordering::Acquire)); + } + + #[test] + fn watch_connection_failures_use_exit_code_two() { + let error: anyhow::Error = StatusWatchError::connection(anyhow::anyhow!("offline")).into(); + assert_eq!(exit_code_for_error(&error), Some(2)); + } + #[test] fn format_uptime_formats_correctly() { assert_eq!(format_uptime(0), "0s"); diff --git a/crates/hypercolor-cli/src/lib.rs b/crates/hypercolor-cli/src/lib.rs index 887bba34a..cec016829 100644 --- a/crates/hypercolor-cli/src/lib.rs +++ b/crates/hypercolor-cli/src/lib.rs @@ -298,7 +298,7 @@ pub async fn run_with_extensions(extensions: &[&dyn CliExtension]) -> Result<()> if let Err(e) = result { ctx.error(&format!("{e:#}")); - std::process::exit(1); + std::process::exit(commands::status::exit_code_for_error(&e).unwrap_or(1)); } Ok(()) From 746887f33406e224fb6152413272b68340ac427e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 06:41:02 -0700 Subject: [PATCH 084/144] feat(macos): publish process-stable Tahoe capabilities Expose native host architecture, process translation state, Core Graphics tone-mapping support, and the active Metal device capability. Retain this state so late capture and host-input sources publish the same tuple. Carry the fields through REST, OpenAPI, generated Python models, and the UI. Keep tolerant UI decoding and semantically accurate Rosetta labels. Co-Authored-By: Nova (GPT-5) --- crates/hypercolor-core/src/input/mod.rs | 33 +++++- .../hypercolor-core/src/input/screen/macos.rs | 71 ++++++++++-- crates/hypercolor-core/src/input/status.rs | 2 + crates/hypercolor-core/src/input/traits.rs | 5 + crates/hypercolor-core/tests/input_tests.rs | 103 +++++++++++++++++- .../tests/macos_screen_capture_tests.rs | 69 ++++++++++-- crates/hypercolor-daemon/src/api/system.rs | 57 +++++++++- .../src/render_thread/pipeline_runtime.rs | 13 ++- .../src/render_thread/sparkleflinger/gpu.rs | 12 ++ .../src/render_thread/sparkleflinger/mod.rs | 8 ++ crates/hypercolor-ui/src/api/system.rs | 31 +++++- .../src/components/settings_sections.rs | 1 + .../src/components/settings_sections/input.rs | 54 ++++++++- .../hypercolor/_generated/models/__init__.py | 4 + .../input_source_platform_status_type_1.py | 14 +++ .../models/macos_architecture_api.py | 9 ++ .../macos_tahoe_capabilities_api_status.py | 87 +++++++++++++++ 17 files changed, 541 insertions(+), 32 deletions(-) create mode 100644 python/src/hypercolor/_generated/models/macos_architecture_api.py create mode 100644 python/src/hypercolor/_generated/models/macos_tahoe_capabilities_api_status.py diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index e29e4db98..cb8ebcbe1 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -313,6 +313,9 @@ pub struct InputManager { source_status_registry: SourceStatusRegistry, event_scratch: Vec, audio_capture_active: Option, + macos_capability_owner: MacosCapabilityOwner, + macos_owner_conflict: Option, + macos_metal4: bool, screen_capture_demand: Option, screen_publication_demand: Option, screen_publication_source_snapshot: Vec<(u64, u64)>, @@ -568,6 +571,9 @@ impl InputManager { source_status_registry: SourceStatusRegistry::new(), event_scratch: Vec::with_capacity(INPUT_EVENT_RING_CAPACITY), audio_capture_active: None, + macos_capability_owner: MacosCapabilityOwner::Standalone, + macos_owner_conflict: None, + macos_metal4: false, screen_capture_demand: None, screen_publication_demand: None, screen_publication_source_snapshot: Vec::new(), @@ -1950,6 +1956,8 @@ impl InputManager { owner: MacosCapabilityOwner, conflict: Option, ) -> anyhow::Result<()> { + self.macos_capability_owner = owner; + self.macos_owner_conflict.clone_from(&conflict); for source in &mut self.sources { source.set_macos_daemon_ownership(owner, conflict.clone())?; } @@ -1957,6 +1965,20 @@ impl InputManager { Ok(()) } + /// Publish the active renderer device's Metal 4 capability into macOS source status. + /// + /// # Errors + /// + /// Returns an error if a source can no longer publish status. + pub fn set_macos_metal4_capability(&mut self, metal4: bool) -> anyhow::Result<()> { + self.macos_metal4 = metal4; + for source in &mut self.sources { + source.set_macos_metal4_capability(metal4)?; + } + self.publish_source_status_registry(); + Ok(()) + } + /// Resolve the explicit Input Monitoring request without retaining the /// input-manager lock while native authorization UI runs. #[must_use] @@ -2031,9 +2053,18 @@ impl InputManager { fn create_managed_source( &mut self, - source: Box, + mut source: Box, source_graph_generation: u64, ) -> ManagedInputSource { + source + .set_macos_daemon_ownership( + self.macos_capability_owner, + self.macos_owner_conflict.clone(), + ) + .expect("new source accepts retained macOS ownership status"); + source + .set_macos_metal4_capability(self.macos_metal4) + .expect("new source accepts retained macOS Metal 4 status"); let id = self.next_source_slot_id; self.next_source_slot_id = self .next_source_slot_id diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 95a970753..0b5336029 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -6,12 +6,15 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureContentStyle, MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, - MacosCaptureSelection, MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, - MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosCaptureCapabilities as NativeCaptureCapabilities, MacosCaptureContentStyle, + MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, + MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, MacosFrameEvent, MacosFrameMailbox, + MacosFrameStatus, MacosHostArchitecture as NativeHostArchitecture, MacosProtectedSourceState as NativeProtectedSourceState, MacosTahoeSelectionCapabilities as NativeTahoeSelectionCapabilities, MacosTransferFunction, }; +#[cfg(feature = "macos-capture-fixtures")] +use hypercolor_macos_capture::{MacosRuntimeCapability, MacosTahoeRuntimeProbes}; use tokio::sync::oneshot; #[cfg(target_os = "macos")] @@ -50,9 +53,10 @@ use crate::input::traits::{ InputData, InputSource, ProtectedSourceAuthorizationAction, ScreenSourcePickerAction, }; use crate::input::{ - MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, - MacosScreenPlatformStatus, MacosSelectionState, MacosTahoeSelectionCapabilities, SourceKind, - SourcePlatformStatus, SourceStatusHandle, SourceStatusReporter, + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, + MacosScreenPlatformStatus, MacosSelectionState, MacosTahoeCapabilities, + MacosTahoeSelectionCapabilities, SourceKind, SourcePlatformStatus, SourceStatusHandle, + SourceStatusReporter, }; const WORKER_WAIT: Duration = Duration::from_millis(100); @@ -279,6 +283,7 @@ trait MacosCaptureControl: Send + Sync { fn status(&self) -> NativeProtectedSourceState; fn selection(&self) -> MacosCaptureSelection; fn tahoe_selection_capabilities(&self) -> Option; + fn host_capabilities(&self) -> NativeCaptureCapabilities; fn authorization(&self) -> MacosAuthorizationState; fn captured_at(&self, display_time: u64) -> anyhow::Result; } @@ -287,6 +292,7 @@ trait MacosCaptureControl: Send + Sync { struct NativeCaptureControl { session: MacosScreenCaptureSession, clock: MacosDisplayClock, + host_capabilities: NativeCaptureCapabilities, } #[cfg(target_os = "macos")] @@ -319,6 +325,10 @@ impl MacosCaptureControl for NativeCaptureControl { self.session.tahoe_selection_capabilities() } + fn host_capabilities(&self) -> NativeCaptureCapabilities { + self.host_capabilities + } + fn authorization(&self) -> MacosAuthorizationState { if MacosScreenCaptureSession::screen_authorized() { MacosAuthorizationState::Authorized @@ -641,6 +651,7 @@ pub struct MacosScreenCaptureInput { status_session: SourceSessionSlot, owner: MacosCapabilityOwner, owner_conflict: Option>, + metal4: bool, } impl MacosScreenCaptureInput { @@ -654,6 +665,7 @@ impl MacosScreenCaptureInput { false, )?; let selector = MacosCaptureSelector::parse(&config.source)?; + let host_capabilities = MacosScreenCaptureSession::capabilities()?; let pool_coordinator = admission.clone(); let session = MacosScreenCaptureSession::new_with_pool_admission( request, @@ -674,7 +686,11 @@ impl MacosScreenCaptureInput { Ok(Self::with_control( config, admission, - Arc::new(NativeCaptureControl { session, clock }), + Arc::new(NativeCaptureControl { + session, + clock, + host_capabilities, + }), )) } @@ -705,6 +721,7 @@ impl MacosScreenCaptureInput { status_session: SourceSessionSlot::new(), owner: MacosCapabilityOwner::Standalone, owner_conflict: None, + metal4: false, }; source .refresh_platform_status() @@ -743,6 +760,7 @@ impl MacosScreenCaptureInput { tcc: self.control.authorization(), owner: self.owner, selection: map_selection(self.control.selection()), + tahoe: map_tahoe_capabilities(self.control.host_capabilities(), self.metal4), tahoe_selection: self .control .tahoe_selection_capabilities() @@ -898,6 +916,11 @@ impl InputSource for MacosScreenCaptureInput { self.refresh_platform_status() } + fn set_macos_metal4_capability(&mut self, metal4: bool) -> anyhow::Result<()> { + self.metal4 = metal4; + self.refresh_platform_status() + } + fn start(&mut self) -> anyhow::Result<()> { if self.running { return Ok(()); @@ -2408,6 +2431,21 @@ fn map_tahoe_selection_capabilities( } } +fn map_tahoe_capabilities( + capabilities: NativeCaptureCapabilities, + metal4: bool, +) -> MacosTahoeCapabilities { + MacosTahoeCapabilities { + host_architecture: match capabilities.host_architecture { + NativeHostArchitecture::AppleSilicon => MacosArchitecture::AppleSilicon, + NativeHostArchitecture::Intel => MacosArchitecture::Intel, + }, + translated_process: capabilities.translated_process, + content_tone_mapping_info: capabilities.tahoe.content_tone_mapping_info.is_present(), + metal4, + } +} + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() @@ -2422,6 +2460,7 @@ struct FixtureControl { status: Mutex, selection: Mutex, tahoe_selection: Mutex>, + host_capabilities: Mutex, captured_at: Mutex>, } @@ -2435,6 +2474,16 @@ impl Default for FixtureControl { status: Mutex::new(NativeProtectedSourceState::ReadyIdle), selection: Mutex::new(MacosCaptureSelection::None), tahoe_selection: Mutex::new(None), + host_capabilities: Mutex::new(NativeCaptureCapabilities::from_runtime( + NativeHostArchitecture::AppleSilicon, + true, + MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Present, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + }, + )), captured_at: Mutex::new(None), } } @@ -2480,6 +2529,10 @@ impl MacosCaptureControl for FixtureControl { lock(&self.tahoe_selection).clone() } + fn host_capabilities(&self) -> NativeCaptureCapabilities { + *lock(&self.host_capabilities) + } + fn authorization(&self) -> MacosAuthorizationState { match self.status() { NativeProtectedSourceState::PermissionDenied | NativeProtectedSourceState::Revoked => { @@ -2548,6 +2601,10 @@ impl MacosScreenCaptureFixture { ) { *lock(&self.control.tahoe_selection) = capabilities; } + + pub fn set_host_capabilities(&self, capabilities: NativeCaptureCapabilities) { + *lock(&self.control.host_capabilities) = capabilities; + } } #[cfg(all(test, feature = "macos-capture-fixtures"))] diff --git a/crates/hypercolor-core/src/input/status.rs b/crates/hypercolor-core/src/input/status.rs index 07ca9dc38..9af013aa2 100644 --- a/crates/hypercolor-core/src/input/status.rs +++ b/crates/hypercolor-core/src/input/status.rs @@ -249,6 +249,8 @@ pub struct MacosScreenPlatformStatus { pub owner: MacosCapabilityOwner, /// Current system-picker selection. pub selection: MacosSelectionState, + /// Process-stable Tahoe host and active Metal-device capabilities. + pub tahoe: MacosTahoeCapabilities, /// Tahoe capabilities for the active selected stream. pub tahoe_selection: Option, /// Latest daemon-owner conflict, when one exists. diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 4de8b8bbf..79b9c1f10 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -901,6 +901,11 @@ pub trait InputSource: Send { Ok(()) } + /// Publish whether the active renderer device exposes required Metal 4 facilities. + fn set_macos_metal4_capability(&mut self, _metal4: bool) -> anyhow::Result<()> { + Ok(()) + } + /// Discard any persisted source selection and prompt the user to pick again. /// /// # Errors diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index bd230e5f7..7df865a44 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -16,11 +16,12 @@ use hypercolor_core::input::screen::{ }; use hypercolor_core::input::{ AudioReconfigurationConflict, BrowserInputSource, INPUT_EVENT_RING_CAPACITY, InputData, - InputManager, InputSource, MacosAuthorizationState, MacosCapabilityOwner, - MacosProtectedSourceState, MacosScreenPlatformStatus, MacosSelectionState, MediaSource, - NetSource, ScreenData, ScreenReconfigurationConflict, SourceFreshness, SourceIssue, SourceKind, - SourcePlatformStatus, SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, - SourceState, SourceStatusError, SourceStatusHandle, SourceStatusReporter, SourceStatusWriter, + InputManager, InputSource, MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosSelectionState, MacosTahoeCapabilities, MediaSource, NetSource, ScreenData, + ScreenReconfigurationConflict, SourceFreshness, SourceIssue, SourceKind, SourcePlatformStatus, + SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, SourceState, + SourceStatusError, SourceStatusHandle, SourceStatusReporter, SourceStatusWriter, SourceTimestampField, TerminalFailureLatch, classify_source_resource_scan, }; use hypercolor_core::types::audio::{AudioData, AudioPipelineConfig, AudioSourceType}; @@ -46,6 +47,57 @@ struct StatusAwareScreenSource { session_sink: Arc>>, } +#[derive(Debug, PartialEq)] +struct RetainedMacosState { + owner: MacosCapabilityOwner, + conflict: Option, + metal4: bool, +} + +struct MacosStateAwareSource { + state: Arc>, + running: bool, +} + +impl InputSource for MacosStateAwareSource { + fn name(&self) -> &'static str { + "MacosStateAware" + } + + fn start(&mut self) -> anyhow::Result<()> { + self.running = true; + Ok(()) + } + + fn stop(&mut self) { + self.running = false; + } + + fn sample(&mut self) -> anyhow::Result { + Ok(InputData::None) + } + + fn is_running(&self) -> bool { + self.running + } + + fn set_macos_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + ) -> anyhow::Result<()> { + let mut state = self.state.lock().expect("macOS state lock"); + state.owner = owner; + state.conflict = conflict; + Ok(()) + } + + fn set_macos_metal4_capability(&mut self, metal4: bool) -> anyhow::Result<()> { + self.state.lock().expect("macOS state lock").metal4 = metal4; + Ok(()) + } +} + impl StatusAwareScreenSource { fn new(session_sink: Arc>>) -> Self { Self { @@ -992,6 +1044,41 @@ fn screen_source_produces_zone_colors() { } } +#[test] +fn late_source_inherits_retained_macos_process_state() { + let conflict = MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::LaunchdService, + contender: MacosCapabilityOwner::AppSidecar, + observed_at_ms: 73, + }; + let state = Arc::new(Mutex::new(RetainedMacosState { + owner: MacosCapabilityOwner::Standalone, + conflict: None, + metal4: false, + })); + let mut manager = InputManager::new(); + manager + .set_macos_daemon_ownership(MacosCapabilityOwner::LaunchdService, Some(conflict.clone())) + .expect("manager retains macOS ownership before registration"); + manager + .set_macos_metal4_capability(true) + .expect("manager retains Metal 4 before registration"); + + manager.add_source(Box::new(MacosStateAwareSource { + state: Arc::clone(&state), + running: false, + })); + + assert_eq!( + *state.lock().expect("macOS state lock"), + RetainedMacosState { + owner: MacosCapabilityOwner::LaunchdService, + conflict: Some(conflict), + metal4: true, + } + ); +} + #[test] fn failing_source_reports_error() { let mut src = FailingSource; @@ -3278,6 +3365,12 @@ fn source_platform_updates_preserve_lifecycle_and_deduplicate() { tcc: MacosAuthorizationState::Authorized, owner: MacosCapabilityOwner::AppSidecar, selection: MacosSelectionState::None, + tahoe: MacosTahoeCapabilities { + host_architecture: MacosArchitecture::AppleSilicon, + translated_process: false, + content_tone_mapping_info: true, + metal4: false, + }, tahoe_selection: None, owner_conflict: None, }); diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index fc7e668ad..712b6156a 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -9,16 +9,19 @@ use hypercolor_core::input::screen::{ ScreenByteAdmissionCoordinator, ScreenCaptureDemand, }; use hypercolor_core::input::{ - InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, - MacosDaemonOwnerConflict, MacosProtectedSourceState as CoreProtectedSourceState, - MacosSelectionState, SourcePlatformStatus, + InputData, InputManager, InputSource, MacosArchitecture, MacosAuthorizationState, + MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosProtectedSourceState as CoreProtectedSourceState, MacosSelectionState, + SourcePlatformStatus, }; use hypercolor_macos_capture::{ - MacosAttachment, MacosCaptureColorimetry, MacosCaptureError, MacosCaptureFrame, - MacosCapturePixelFormat, MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, - MacosColorRange, MacosFrameDecoder, MacosFrameEvent, MacosPixelExtent, MacosPointRect, - MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, - MacosRawFrameAttachments, MacosTahoeSelectionCapabilities, MacosTransferFunction, + MacosAttachment, MacosCaptureCapabilities, MacosCaptureColorimetry, MacosCaptureError, + MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, MacosCaptureSurface, + MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, + MacosHostArchitecture, MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, + MacosRuntimeCapability, MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, + MacosTransferFunction, }; const BGRA8: u32 = 0x4247_5241; @@ -123,6 +126,16 @@ fn fixture_capture_activates_only_for_live_demand() { ..CaptureConfig::default() }; let (mut source, fixture) = fixture_source(config); + fixture.set_host_capabilities(MacosCaptureCapabilities::from_runtime( + MacosHostArchitecture::Intel, + false, + MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Absent, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + }, + )); assert_eq!(source.name(), "macos_screen_capture"); assert_eq!( @@ -139,6 +152,9 @@ fn fixture_capture_activates_only_for_live_demand() { }), ) .expect("fixture owner status updates"); + source + .set_macos_metal4_capability(true) + .expect("fixture Metal 4 status updates"); source .source_status_reporter() .expect("macOS fixture exposes status reporting") @@ -162,6 +178,10 @@ fn fixture_capture_activates_only_for_live_demand() { }) ); assert_eq!(platform.selection, MacosSelectionState::None); + assert_eq!(platform.tahoe.host_architecture, MacosArchitecture::Intel); + assert!(!platform.tahoe.translated_process); + assert!(!platform.tahoe.content_tone_mapping_info); + assert!(platform.tahoe.metal4); assert!(!fixture.is_active()); source.start().expect("fixture source starts idle"); assert!(matches!(source.sample(), Ok(InputData::None))); @@ -331,3 +351,36 @@ fn authorization_and_picker_actions_run_outside_graph_ownership() { assert_eq!(platform.tcc, MacosAuthorizationState::Authorized); assert_eq!(platform.state, CoreProtectedSourceState::NeedsSelection); } + +#[test] +fn late_macos_capture_source_inherits_process_capabilities() { + let (source, _) = fixture_source(CaptureConfig::default()); + let status = source + .source_status_handle() + .expect("macOS fixture exposes status"); + let conflict = MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::HomebrewService, + contender: MacosCapabilityOwner::AppSidecar, + observed_at_ms: 42, + }; + let mut manager = InputManager::new(); + manager + .set_macos_daemon_ownership( + MacosCapabilityOwner::HomebrewService, + Some(conflict.clone()), + ) + .expect("manager retains ownership before source registration"); + manager + .set_macos_metal4_capability(true) + .expect("manager retains Metal 4 before source registration"); + + manager.add_source(Box::new(source)); + + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = snapshot.platform.as_deref() else { + panic!("expected macOS screen platform status"); + }; + assert_eq!(platform.owner, MacosCapabilityOwner::HomebrewService); + assert_eq!(platform.owner_conflict.as_deref(), Some(&conflict)); + assert!(platform.tahoe.metal4); +} diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index 58683abed..18ab82bd4 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -14,10 +14,10 @@ use hypercolor_core::input::screen::{ PixelExtent, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, }; use hypercolor_core::input::{ - MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, - MacosSelectionState, MacosTahoeSelectionCapabilities, SourceFreshness, SourceIssue, SourceKind, - SourcePlatformStatus, SourceState, SourceStatus, + MacosSelectionState, MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, SourceFreshness, + SourceIssue, SourceKind, SourcePlatformStatus, SourceState, SourceStatus, }; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::sensor::SystemSnapshot; @@ -281,6 +281,21 @@ pub struct MacosTahoeSelectionCapabilitiesApiStatus { pub dual_range_screenshots: bool, } +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MacosArchitectureApi { + AppleSilicon, + Intel, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosTahoeCapabilitiesApiStatus { + pub host_architecture: MacosArchitectureApi, + pub translated_process: bool, + pub content_tone_mapping_info: bool, + pub metal4: bool, +} + #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputSourcePlatformStatus { @@ -298,6 +313,7 @@ pub enum InputSourcePlatformStatus { tcc: MacosAuthorizationStateApi, owner: MacosCapabilityOwnerApi, selection: MacosSelectionStateApi, + tahoe: MacosTahoeCapabilitiesApiStatus, #[serde(default, skip_serializing_if = "Option::is_none")] tahoe_selection: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -770,6 +786,7 @@ fn macos_screen_platform_status(status: &MacosScreenPlatformStatus) -> InputSour tcc: macos_authorization_state(status.tcc), owner: macos_capability_owner(status.owner), selection: macos_selection_state(&status.selection), + tahoe: macos_tahoe_capabilities(&status.tahoe), tahoe_selection: status .tahoe_selection .as_ref() @@ -935,6 +952,20 @@ fn macos_tahoe_selection_capabilities( } } +fn macos_tahoe_capabilities( + capabilities: &MacosTahoeCapabilities, +) -> MacosTahoeCapabilitiesApiStatus { + MacosTahoeCapabilitiesApiStatus { + host_architecture: match capabilities.host_architecture { + MacosArchitecture::AppleSilicon => MacosArchitectureApi::AppleSilicon, + MacosArchitecture::Intel => MacosArchitectureApi::Intel, + }, + translated_process: capabilities.translated_process, + content_tone_mapping_info: capabilities.content_tone_mapping_info, + metal4: capabilities.metal4, + } +} + fn input_source_issue_status(issue: &SourceIssue) -> InputSourceIssueStatus { InputSourceIssueStatus { code: issue.code.to_string(), @@ -1930,10 +1961,10 @@ mod tests { use hypercolor_core::bus::CanvasFrame; use hypercolor_core::input::screen::ScreenAdmissionCapacity; use hypercolor_core::input::{ - MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, - MacosSelectionState, MacosTahoeSelectionCapabilities, SourceFreshness, SourceKind, - SourcePlatformStatus, SourceState, SourceStatus, + MacosSelectionState, MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, + SourceFreshness, SourceKind, SourcePlatformStatus, SourceState, SourceStatus, }; use hypercolor_types::canvas::Canvas; use hypercolor_types::sensor::{SensorReading, SensorUnit, SystemSnapshot}; @@ -2049,6 +2080,12 @@ mod tests { selection: MacosSelectionState::SessionScoped { content_style: Arc::from("multiple_windows"), }, + tahoe: MacosTahoeCapabilities { + host_architecture: MacosArchitecture::AppleSilicon, + translated_process: true, + content_tone_mapping_info: true, + metal4: false, + }, tahoe_selection: Some(MacosTahoeSelectionCapabilities { source_id: Arc::from("session:23"), capture_session_generation: 29, @@ -2076,6 +2113,12 @@ mod tests { "type": "session_scoped", "content_style": "multiple_windows" }, + "tahoe": { + "host_architecture": "apple_silicon", + "translated_process": true, + "content_tone_mapping_info": true, + "metal4": false + }, "tahoe_selection": { "source_id": "session:23", "capture_session_generation": 29, @@ -2161,6 +2204,8 @@ mod tests { assert!(schemas.contains_key("MacosDaemonOwnerRecoveryRequiredApiStatus")); assert!(schemas.contains_key("MacosDaemonHandoverPhaseApi")); assert!(schemas.contains_key("MacosSelectionStateApi")); + assert!(schemas.contains_key("MacosArchitectureApi")); + assert!(schemas.contains_key("MacosTahoeCapabilitiesApiStatus")); assert!(schemas.contains_key("MacosTahoeSelectionCapabilitiesApiStatus")); let platform_schema = &schemas["InputSourcePlatformStatus"]; let encoded = serde_json::to_string(platform_schema).expect("schema should encode"); diff --git a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs index 7c080ddc0..f9f11096e 100644 --- a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs +++ b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs @@ -2097,7 +2097,7 @@ impl PipelineRuntime { input_demands: InputPublicationDemandHandle, ) -> Result { let initial_spatial_engine = state.spatial_engine.read().await.clone(); - Self::new_with_gpu_device( + let pipeline = Self::new_with_gpu_device( state.canvas_dims.width(), state.canvas_dims.height(), initial_spatial_engine, @@ -2110,7 +2110,16 @@ impl PipelineRuntime { input_reader, input_demands, state.interaction_routing.clone(), - ) + )?; + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + state + .input_manager + .lock() + .await + .set_macos_metal4_capability( + pipeline.render.sparkleflinger.macos_metal4_capability(), + )?; + Ok(pipeline) } #[cfg(test)] diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index f2d718ee5..cc91e2b00 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -62,6 +62,7 @@ use hypercolor_macos_gpu_interop::{ MacosNativeOutputTransfer, MacosNativeReducer, MacosNativeReductionDescriptor, MacosNativeReductionFilter, MacosNativeReductionTarget, MacosNativeTargetFormat, MacosScreenBridge as MacosInteropScreenBridge, MacosScreenStorageIdentity, + probe_macos_metal4_capabilities, }; use hypercolor_types::scene::ZoneId; #[cfg(target_os = "windows")] @@ -1274,6 +1275,8 @@ pub(crate) struct GpuSparkleFlinger { screen_bridge: Option>, #[cfg(all(target_os = "macos", feature = "screen-capture"))] screen_target: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + metal4_capable: bool, #[cfg(test)] superseded_frame_count: usize, #[cfg(test)] @@ -1637,6 +1640,8 @@ impl GpuSparkleFlinger { #[cfg(all(target_os = "macos", feature = "screen-capture"))] let (screen_bridge, screen_target) = create_screen_bridge(&device, probe.max_texture_dimension_2d); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let metal4_capable = probe_macos_metal4_capabilities(&device)?.all_required_facilities(); Ok(Self { _render_device: render_device, @@ -1679,6 +1684,8 @@ impl GpuSparkleFlinger { screen_bridge, #[cfg(all(target_os = "macos", feature = "screen-capture"))] screen_target, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + metal4_capable, #[cfg(test)] superseded_frame_count: 0, #[cfg(test)] @@ -1704,6 +1711,11 @@ impl GpuSparkleFlinger { }) } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + pub(crate) const fn macos_metal4_capability(&self) -> bool { + self.metal4_capable + } + fn take_sampling_readback_failure_injection(&mut self) -> bool { #[cfg(test)] { diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs index f7389a8ad..8510db3bf 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs @@ -682,6 +682,14 @@ pub(crate) enum DisplayFinalizeFrame { pub(crate) struct PendingDisplayFinalization(PendingGpuDisplayFinalize); impl SparkleFlinger { + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) fn macos_metal4_capability(&self) -> bool { + match &self.backend { + SparkleFlingerBackend::Cpu(_) => false, + SparkleFlingerBackend::Gpu { gpu, .. } => gpu.macos_metal4_capability(), + } + } + #[cfg_attr(not(feature = "wgpu"), allow(unused_variables))] pub(crate) fn prepare_zone_sampling_plan( &mut self, diff --git a/crates/hypercolor-ui/src/api/system.rs b/crates/hypercolor-ui/src/api/system.rs index aa84ca310..fd3a08a59 100644 --- a/crates/hypercolor-ui/src/api/system.rs +++ b/crates/hypercolor-ui/src/api/system.rs @@ -128,6 +128,16 @@ pub struct MacosTahoeSelectionStatus { pub dual_range_screenshots: Option, } +/// Process-stable Tahoe host and active Metal-device capabilities. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct MacosTahoeStatus { + pub host_architecture: Option, + pub translated_process: Option, + pub content_tone_mapping_info: Option, + pub metal4: Option, +} + /// Platform-specific source state carried by the daemon status endpoint. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -156,6 +166,8 @@ pub enum InputSourcePlatformStatus { #[serde(default)] selection: Option, #[serde(default)] + tahoe: Option, + #[serde(default)] tahoe_selection: Option, #[serde(default)] owner_conflict: Option, @@ -249,7 +261,7 @@ mod tests { use super::{ InputSourcePlatformStatus, InputSourceStatus, MacosDaemonOwnerConflictStatus, MacosDaemonOwnerRecoveryRequiredStatus, MacosDaemonOwnershipStatus, MacosSelectionStatus, - MacosTahoeSelectionStatus, + MacosTahoeSelectionStatus, MacosTahoeStatus, }; #[test] @@ -373,6 +385,13 @@ mod tests { "content_style": "multiple_windows", "future_selection_field": "ignored" }, + "tahoe": { + "host_architecture": "apple_silicon", + "translated_process": true, + "content_tone_mapping_info": true, + "metal4": false, + "future_host_field": "ignored" + }, "tahoe_selection": { "source_id": "session:23", "capture_session_generation": 29, @@ -396,6 +415,7 @@ mod tests { tcc, owner, selection, + tahoe, tahoe_selection, owner_conflict, }) = status.platform @@ -406,6 +426,15 @@ mod tests { assert_eq!(state.as_deref(), Some("interrupted")); assert_eq!(tcc.as_deref(), Some("denied")); assert_eq!(owner.as_deref(), Some("standalone")); + assert_eq!( + tahoe, + Some(MacosTahoeStatus { + host_architecture: Some("apple_silicon".to_owned()), + translated_process: Some(true), + content_tone_mapping_info: Some(true), + metal4: Some(false), + }) + ); assert_eq!( selection, Some(MacosSelectionStatus::SessionScoped { diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index d16ef18f1..cda329545 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -756,6 +756,7 @@ mod macos_capture_tests { tcc: Some("authorized".to_owned()), owner: Some("launchd_service".to_owned()), selection: None, + tahoe: None, tahoe_selection: None, owner_conflict: None, }), diff --git a/crates/hypercolor-ui/src/components/settings_sections/input.rs b/crates/hypercolor-ui/src/components/settings_sections/input.rs index 4950bc542..5d8d90d24 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/input.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/input.rs @@ -349,6 +349,7 @@ pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl tcc, owner, selection, + tahoe, tahoe_selection, owner_conflict, } => { @@ -362,6 +363,7 @@ pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl "Dynamic range pending", |hdr| if hdr { "HDR" } else { "SDR" }, ); + let host = tahoe.as_ref().map(tahoe_host_label); view! {
{format!( @@ -371,6 +373,9 @@ pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl humanize_optional(owner.as_deref()), )}
{format!("{selection} · {range}")}
+ {host.map(|host| view! { +
{host}
+ })} {owner_conflict.map(|conflict| view! {
{format!( @@ -416,6 +421,32 @@ fn humanize_optional(value: Option<&str>) -> String { value.map_or_else(|| "unknown".to_owned(), humanize) } +const fn capability_label(value: Option) -> &'static str { + match value { + Some(true) => "available", + Some(false) => "unavailable", + None => "unknown", + } +} + +const fn boolean_label(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "unknown", + } +} + +fn tahoe_host_label(capabilities: &crate::api::MacosTahoeStatus) -> String { + format!( + "Host {} · Rosetta translated {} · Core Graphics tone mapping {} · Metal 4 {}", + humanize_optional(capabilities.host_architecture.as_deref()), + boolean_label(capabilities.translated_process), + capability_label(capabilities.content_tone_mapping_info), + capability_label(capabilities.metal4), + ) +} + pub(super) fn macos_keyboard_needs_authorization(status: &InputStatus) -> bool { status.sources.iter().any(|source| { if source.retired { @@ -490,10 +521,12 @@ pub(super) fn humanize(value: &str) -> String { mod tests { use crate::api::{ InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, - SystemStatus, + MacosTahoeStatus, SystemStatus, }; - use super::{macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates}; + use super::{ + macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates, tahoe_host_label, + }; fn system_status( input: InputStatus, @@ -561,6 +594,7 @@ mod tests { tcc: Some("denied".to_owned()), owner: Some("app_sidecar".to_owned()), selection: None, + tahoe: None, tahoe_selection: None, owner_conflict: None, }), @@ -579,12 +613,28 @@ mod tests { tcc: Some("authorized".to_owned()), owner: Some("app_sidecar".to_owned()), selection: None, + tahoe: None, tahoe_selection: None, owner_conflict: None, }); assert!(!super::super::macos_screen_needs_authorization(&status)); } + #[test] + fn tahoe_host_label_reports_current_rosetta_translation_state() { + let capabilities = MacosTahoeStatus { + host_architecture: Some("apple_silicon".to_owned()), + translated_process: Some(false), + content_tone_mapping_info: Some(true), + metal4: Some(false), + }; + + assert_eq!( + tahoe_host_label(&capabilities), + "Host Apple silicon · Rosetta translated no · Core Graphics tone mapping available · Metal 4 unavailable" + ); + } + #[test] fn restart_coordinates_require_exact_state_owner_and_epoch() { let mut status = system_status( diff --git a/python/src/hypercolor/_generated/models/__init__.py b/python/src/hypercolor/_generated/models/__init__.py index 27f86483b..592f2dba3 100644 --- a/python/src/hypercolor/_generated/models/__init__.py +++ b/python/src/hypercolor/_generated/models/__init__.py @@ -270,6 +270,7 @@ from .led_topology_type_5_type import LedTopologyType5Type from .led_topology_type_6 import LedTopologyType6 from .led_topology_type_6_type import LedTopologyType6Type +from .macos_architecture_api import MacosArchitectureApi from .macos_authorization_state_api import MacosAuthorizationStateApi from .macos_capability_owner_api import MacosCapabilityOwnerApi from .macos_daemon_handover_phase_api import MacosDaemonHandoverPhaseApi @@ -285,6 +286,7 @@ from .macos_selection_state_api_type_1_type import MacosSelectionStateApiType1Type from .macos_selection_state_api_type_2 import MacosSelectionStateApiType2 from .macos_selection_state_api_type_2_type import MacosSelectionStateApiType2Type +from .macos_tahoe_capabilities_api_status import MacosTahoeCapabilitiesApiStatus from .macos_tahoe_selection_capabilities_api_status import ( MacosTahoeSelectionCapabilitiesApiStatus, ) @@ -601,6 +603,7 @@ "LedTopologyType5Type", "LedTopologyType6", "LedTopologyType6Type", + "MacosArchitectureApi", "MacosAuthorizationStateApi", "MacosCapabilityOwnerApi", "MacosDaemonHandoverPhaseApi", @@ -614,6 +617,7 @@ "MacosSelectionStateApiType1Type", "MacosSelectionStateApiType2", "MacosSelectionStateApiType2Type", + "MacosTahoeCapabilitiesApiStatus", "MacosTahoeSelectionCapabilitiesApiStatus", "Meta", "NormalizedPosition", diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py index 2ef96af2f..14cfc62aa 100644 --- a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py @@ -21,6 +21,9 @@ from ..models.macos_selection_state_api_type_0 import MacosSelectionStateApiType0 from ..models.macos_selection_state_api_type_1 import MacosSelectionStateApiType1 from ..models.macos_selection_state_api_type_2 import MacosSelectionStateApiType2 + from ..models.macos_tahoe_capabilities_api_status import ( + MacosTahoeCapabilitiesApiStatus, + ) from ..models.macos_tahoe_selection_capabilities_api_status import ( MacosTahoeSelectionCapabilitiesApiStatus, ) @@ -36,6 +39,7 @@ class InputSourcePlatformStatusType1: owner (MacosCapabilityOwnerApi): selection (MacosSelectionStateApiType0 | MacosSelectionStateApiType1 | MacosSelectionStateApiType2): state (MacosProtectedSourceStateApi): + tahoe (MacosTahoeCapabilitiesApiStatus): tcc (MacosAuthorizationStateApi): type_ (InputSourcePlatformStatusType1Type): owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): @@ -49,6 +53,7 @@ class InputSourcePlatformStatusType1: | MacosSelectionStateApiType2 ) state: MacosProtectedSourceStateApi + tahoe: MacosTahoeCapabilitiesApiStatus tcc: MacosAuthorizationStateApi type_: InputSourcePlatformStatusType1Type owner_conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET @@ -81,6 +86,8 @@ def to_dict(self) -> dict[str, Any]: state = self.state.value + tahoe = self.tahoe.to_dict() + tcc = self.tcc.value type_ = self.type_.value @@ -108,6 +115,7 @@ def to_dict(self) -> dict[str, Any]: "owner": owner, "selection": selection, "state": state, + "tahoe": tahoe, "tcc": tcc, "type": type_, } @@ -133,6 +141,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.macos_selection_state_api_type_2 import ( MacosSelectionStateApiType2, ) + from ..models.macos_tahoe_capabilities_api_status import ( + MacosTahoeCapabilitiesApiStatus, + ) from ..models.macos_tahoe_selection_capabilities_api_status import ( MacosTahoeSelectionCapabilitiesApiStatus, ) @@ -179,6 +190,8 @@ def _parse_selection( state = MacosProtectedSourceStateApi(d.pop("state")) + tahoe = MacosTahoeCapabilitiesApiStatus.from_dict(d.pop("tahoe")) + tcc = MacosAuthorizationStateApi(d.pop("tcc")) type_ = InputSourcePlatformStatusType1Type(d.pop("type")) @@ -229,6 +242,7 @@ def _parse_tahoe_selection( owner=owner, selection=selection, state=state, + tahoe=tahoe, tcc=tcc, type_=type_, owner_conflict=owner_conflict, diff --git a/python/src/hypercolor/_generated/models/macos_architecture_api.py b/python/src/hypercolor/_generated/models/macos_architecture_api.py new file mode 100644 index 000000000..39e489b95 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_architecture_api.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class MacosArchitectureApi(str, Enum): + APPLE_SILICON = "apple_silicon" + INTEL = "intel" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/macos_tahoe_capabilities_api_status.py b/python/src/hypercolor/_generated/models/macos_tahoe_capabilities_api_status.py new file mode 100644 index 000000000..53f4fe7cc --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_tahoe_capabilities_api_status.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_architecture_api import MacosArchitectureApi + +T = TypeVar("T", bound="MacosTahoeCapabilitiesApiStatus") + + +@_attrs_define +class MacosTahoeCapabilitiesApiStatus: + """ + Attributes: + content_tone_mapping_info (bool): + host_architecture (MacosArchitectureApi): + metal4 (bool): + translated_process (bool): + """ + + content_tone_mapping_info: bool + host_architecture: MacosArchitectureApi + metal4: bool + translated_process: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + content_tone_mapping_info = self.content_tone_mapping_info + + host_architecture = self.host_architecture.value + + metal4 = self.metal4 + + translated_process = self.translated_process + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "content_tone_mapping_info": content_tone_mapping_info, + "host_architecture": host_architecture, + "metal4": metal4, + "translated_process": translated_process, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + content_tone_mapping_info = d.pop("content_tone_mapping_info") + + host_architecture = MacosArchitectureApi(d.pop("host_architecture")) + + metal4 = d.pop("metal4") + + translated_process = d.pop("translated_process") + + macos_tahoe_capabilities_api_status = cls( + content_tone_mapping_info=content_tone_mapping_info, + host_architecture=host_architecture, + metal4=metal4, + translated_process=translated_process, + ) + + macos_tahoe_capabilities_api_status.additional_properties = d + return macos_tahoe_capabilities_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties From bd743cf831e01cf85b1d193a2a10c77f8c2f6a39 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 06:57:31 -0700 Subject: [PATCH 085/144] feat(macos): fence screenshot references by capture identity Return the source and session generation validated by the native Tahoe screenshot transaction. Expose a detached core action so diagnostics can run without retaining the input-manager lock. Keep the callback compatible while rejecting stale picker or session results before they can be presented as current. Co-Authored-By: Nova (GPT-5) --- crates/hypercolor-core/src/input/mod.rs | 10 +++ .../hypercolor-core/src/input/screen/macos.rs | 37 ++++++++++- crates/hypercolor-core/src/input/traits.rs | 18 +++++ crates/hypercolor-macos-capture/src/lib.rs | 3 +- crates/hypercolor-macos-capture/src/native.rs | 30 +++++++-- .../src/screenshot.rs | 66 +++++++++++++++++++ 6 files changed, 156 insertions(+), 8 deletions(-) diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index cb8ebcbe1..990fbd693 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -53,6 +53,8 @@ pub use status::{ SourceStatusSubscription, SourceStatusWriter, SourceTimestampField, TerminalFailureLatch, classify_source_resource_scan, }; +#[cfg(target_os = "macos")] +pub use traits::MacosScreenshotReferenceAction; pub use traits::{ InputData, InputSource, InteractionBatch, InteractionData, InteractionDegradation, InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, @@ -2006,6 +2008,14 @@ impl InputManager { .find_map(|source| source.screen_source_picker_action()) } + #[cfg(target_os = "macos")] + #[must_use] + pub fn macos_screenshot_reference_action(&self) -> Option { + self.sources + .iter() + .find_map(|source| source.macos_screenshot_reference_action()) + } + /// Ask screen sources to discard their persisted selection and re-prompt. /// /// # Errors diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 0b5336029..b90bcfca0 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -19,7 +19,8 @@ use tokio::sync::oneshot; #[cfg(target_os = "macos")] use hypercolor_macos_capture::{ - MacosCaptureCadence, MacosCaptureSelector, MacosScreenCaptureSession, MacosStreamRequest, + MacosCaptureCadence, MacosCaptureSelector, MacosScreenCaptureSession, + MacosScreenshotReferenceCapture, MacosStreamRequest, }; use super::{ @@ -49,6 +50,8 @@ use super::{ #[cfg(target_os = "macos")] use super::{ScreenByteAdmissionError, ScreenByteLease}; use crate::input::status::SourceSessionSlot; +#[cfg(target_os = "macos")] +use crate::input::traits::MacosScreenshotReferenceAction; use crate::input::traits::{ InputData, InputSource, ProtectedSourceAuthorizationAction, ScreenSourcePickerAction, }; @@ -286,6 +289,17 @@ trait MacosCaptureControl: Send + Sync { fn host_capabilities(&self) -> NativeCaptureCapabilities; fn authorization(&self) -> MacosAuthorizationState; fn captured_at(&self, display_time: u64) -> anyhow::Result; + + #[cfg(target_os = "macos")] + fn capture_screenshot_reference( + &self, + ) -> anyhow::Result< + mpsc::Receiver< + Result, + >, + > { + anyhow::bail!("macOS screenshot references are unavailable for this capture control") + } } #[cfg(target_os = "macos")] @@ -344,6 +358,21 @@ impl MacosCaptureControl for NativeCaptureControl { .timestamp(display_time) .map_err(anyhow::Error::from) } + + fn capture_screenshot_reference( + &self, + ) -> anyhow::Result< + mpsc::Receiver< + Result, + >, + > { + let (result_tx, result_rx) = mpsc::sync_channel(1); + self.session + .capture_screenshot_reference_with_identity(move |result| { + let _ = result_tx.send(result); + })?; + Ok(result_rx) + } } #[derive(Default)] @@ -1256,6 +1285,12 @@ impl InputSource for MacosScreenCaptureInput { let control = Arc::clone(&self.control); Some(Arc::new(move || control.present_picker())) } + + #[cfg(target_os = "macos")] + fn macos_screenshot_reference_action(&self) -> Option { + let control = Arc::clone(&self.control); + Some(Arc::new(move || control.capture_screenshot_reference())) + } } fn resolve_macos_publication_branch( diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 79b9c1f10..aa4280f7d 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -20,6 +20,19 @@ pub type ProtectedSourceAuthorizationAction = Arc anyhow::Result anyhow::Result<()> + Send + Sync>; +#[cfg(target_os = "macos")] +pub type MacosScreenshotReferenceAction = Arc< + dyn Fn() -> anyhow::Result< + std::sync::mpsc::Receiver< + Result< + hypercolor_macos_capture::MacosScreenshotReferenceCapture, + hypercolor_macos_capture::MacosCaptureError, + >, + >, + > + Send + + Sync, +>; + // ── InputData ────────────────────────────────────────────────────────────── /// A single sample from an input source. @@ -929,4 +942,9 @@ pub trait InputSource: Send { fn screen_source_picker_action(&self) -> Option { None } + + #[cfg(target_os = "macos")] + fn macos_screenshot_reference_action(&self) -> Option { + None + } } diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index a27fcd4c7..4232b1212 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -23,7 +23,8 @@ pub use native::MacosScreenCaptureSession; pub use screenshot::{ MAX_MACOS_SCREENSHOT_REFERENCE_BYTES, MacosScreenshotPixelCopy, MacosScreenshotPreferredDynamicRange, MacosScreenshotReferenceCapability, - MacosScreenshotReferenceImage, MacosScreenshotReferenceMetadata, MacosScreenshotReferenceSet, + MacosScreenshotReferenceCapture, MacosScreenshotReferenceImage, + MacosScreenshotReferenceMetadata, MacosScreenshotReferenceSet, }; pub use clock::{MacosDisplayClock, MacosDisplayClockError}; diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 4e29317f2..1b8dd9e36 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -61,11 +61,11 @@ use crate::{ MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, MacosHostArchitecture, MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosRuntimeCapability, - MacosScale, MacosScreenshotReferenceCapability, MacosScreenshotReferenceImage, - MacosScreenshotReferenceSet, MacosStreamDeliveryRejection, MacosStreamDeliveryState, - MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeCapabilities, - MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, MacosTransferFunction, - MacosValidatedStreamDelivery, MacosYuvMatrix, + MacosScale, MacosScreenshotReferenceCapability, MacosScreenshotReferenceCapture, + MacosScreenshotReferenceImage, MacosScreenshotReferenceSet, MacosStreamDeliveryRejection, + MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, + MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, + MacosTransferFunction, MacosValidatedStreamDelivery, MacosYuvMatrix, }; type PoolBackingLifetime = Arc; @@ -1657,14 +1657,32 @@ impl MacosScreenCaptureSession { pub fn capture_screenshot_reference(&self, completion: F) -> Result<(), MacosCaptureError> where F: FnOnce(Result) + Send + 'static, + { + self.capture_screenshot_reference_with_identity(move |result| { + completion(result.map(MacosScreenshotReferenceCapture::into_references)); + }) + } + + pub fn capture_screenshot_reference_with_identity( + &self, + completion: F, + ) -> Result<(), MacosCaptureError> + where + F: FnOnce(Result) + Send + 'static, { let snapshot = self.streams.screenshot_snapshot()?; + let source_id = Arc::clone(&snapshot.source_id); + let generation = snapshot.generation; execute_screenshot_transaction( snapshot, Arc::clone(&self.streams) as Arc, Arc::new(NativeScreenshotCaptureBackend), self.request.cursor_composed, - Box::new(completion), + Box::new(move |result| { + completion(result.map(|references| { + MacosScreenshotReferenceCapture::new(source_id, generation, references) + })); + }), ) } diff --git a/crates/hypercolor-macos-capture/src/screenshot.rs b/crates/hypercolor-macos-capture/src/screenshot.rs index e93ef97a3..0d1b8cda5 100644 --- a/crates/hypercolor-macos-capture/src/screenshot.rs +++ b/crates/hypercolor-macos-capture/src/screenshot.rs @@ -282,6 +282,47 @@ pub enum MacosScreenshotReferenceSet { }, } +#[derive(Debug, Clone)] +pub struct MacosScreenshotReferenceCapture { + source_id: Arc, + capture_session_generation: u64, + references: MacosScreenshotReferenceSet, +} + +impl MacosScreenshotReferenceCapture { + pub(crate) fn new( + source_id: Arc, + capture_session_generation: u64, + references: MacosScreenshotReferenceSet, + ) -> Self { + Self { + source_id, + capture_session_generation, + references, + } + } + + #[must_use] + pub fn source_id(&self) -> &str { + &self.source_id + } + + #[must_use] + pub const fn capture_session_generation(&self) -> u64 { + self.capture_session_generation + } + + #[must_use] + pub const fn references(&self) -> &MacosScreenshotReferenceSet { + &self.references + } + + #[must_use] + pub fn into_references(self) -> MacosScreenshotReferenceSet { + self.references + } +} + type SetContentToneMappingInfo = unsafe extern "C-unwind" fn(&CGContext, CGContentToneMappingInfo); type GetContentAverageLightLevel = unsafe extern "C-unwind" fn(Option<&CGImage>) -> f32; @@ -592,4 +633,29 @@ mod tests { assert_eq!(output.bytes_per_row, 4); assert_eq!(output.rgba8.len(), 4); } + + #[test] + fn reference_capture_carries_the_fenced_source_identity() { + let capture = MacosScreenshotReferenceCapture::new( + Arc::from("display:main"), + 17, + MacosScreenshotReferenceSet::Sdr { + image: MacosScreenshotReferenceImage::new_fixture( + MacosCaptureDynamicRange::Sdr, + 0x80, + ), + }, + ); + + assert_eq!(capture.source_id(), "display:main"); + assert_eq!(capture.capture_session_generation(), 17); + assert!(matches!( + capture.references(), + MacosScreenshotReferenceSet::Sdr { .. } + )); + assert!(matches!( + capture.into_references(), + MacosScreenshotReferenceSet::Sdr { .. } + )); + } } From c19ebc2b6eefcfd1204c235553462289ee9ac8c9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 07:04:39 -0700 Subject: [PATCH 086/144] test(macos): pin screenshot row orientation Core Graphics reference copies already preserve the source image's top-left row order. Exercise that invariant with a two-row fixture so live pipeline parity never grows an unnecessary vertical transform. Co-Authored-By: Nova (OpenAI Codex) --- .../src/screenshot.rs | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/screenshot.rs b/crates/hypercolor-macos-capture/src/screenshot.rs index 0d1b8cda5..0fed78799 100644 --- a/crates/hypercolor-macos-capture/src/screenshot.rs +++ b/crates/hypercolor-macos-capture/src/screenshot.rs @@ -224,21 +224,40 @@ impl MacosScreenshotReferenceImage { #[cfg(test)] pub(crate) fn new_fixture(dynamic_range: MacosCaptureDynamicRange, marker: u8) -> Self { - let extent = MacosPixelExtent::new(1, 1).expect("fixture extent is valid"); - let mut pixel = [marker, marker, marker, u8::MAX]; + Self::new_rgba_fixture(dynamic_range, 1, 1, vec![marker, marker, marker, u8::MAX]) + } + + #[cfg(test)] + fn new_rgba_fixture( + dynamic_range: MacosCaptureDynamicRange, + width: u32, + height: u32, + mut rgba8: Vec, + ) -> Self { + let extent = MacosPixelExtent::new(width, height).expect("fixture extent is valid"); + let bytes_per_row = usize::try_from(width) + .expect("fixture width fits usize") + .checked_mul(4) + .expect("fixture row size is bounded"); + assert_eq!( + rgba8.len(), + bytes_per_row + .checked_mul(usize::try_from(height).expect("fixture height fits usize")) + .expect("fixture allocation is bounded") + ); // SAFETY: Core Graphics exports a process-lifetime immutable CFString. let srgb = unsafe { kCGColorSpaceSRGB }; let color_space = CGColorSpace::with_name(Some(srgb)).expect("fixture color space is available"); - // SAFETY: pixel is a fixed four-byte RGBA buffer retained until the + // SAFETY: rgba8 is a fixed RGBA buffer retained until the // context creates its immutable CGImage copy. let context = unsafe { CGBitmapContextCreate( - pixel.as_mut_ptr().cast(), - 1, - 1, + rgba8.as_mut_ptr().cast(), + usize::try_from(width).expect("fixture width fits usize"), + usize::try_from(height).expect("fixture height fits usize"), 8, - 4, + bytes_per_row, Some(&color_space), CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0, ) @@ -254,7 +273,7 @@ impl MacosScreenshotReferenceImage { dynamic_range, bits_per_component: 8, bits_per_pixel: 32, - bytes_per_row: 4, + bytes_per_row: u64::try_from(bytes_per_row).expect("fixture row size fits u64"), content_headroom: (dynamic_range == MacosCaptureDynamicRange::Hdr).then_some(4.0), content_average_light_level: Some(0.25), }, @@ -634,6 +653,41 @@ mod tests { assert_eq!(output.rgba8.len(), 4); } + #[test] + fn reference_output_preserves_top_left_row_order() { + let image = MacosScreenshotReferenceImage::new_rgba_fixture( + MacosCaptureDynamicRange::Sdr, + 1, + 2, + vec![0x20, 0x20, 0x20, u8::MAX, 0xe0, 0xe0, 0xe0, u8::MAX], + ); + let preferred_key = CFString::from_static_str("preferred"); + let standard_range = CFString::from_static_str("standard"); + let constrained_range = CFString::from_static_str("constrained"); + let high_range = CFString::from_static_str("high"); + let average_light_key = CFString::from_static_str("average-light"); + let symbols = TahoeReferenceOutputSymbols { + set_tone_mapping: record_reference_tone_mapping, + preferred_key: NonNull::from(&*preferred_key), + standard_range: NonNull::from(&*standard_range), + constrained_range: NonNull::from(&*constrained_range), + high_range: NonNull::from(&*high_range), + average_light_key: NonNull::from(&*average_light_key), + }; + + let output = image + .copy_reference_rgba8_with_symbols( + MacosScreenshotPreferredDynamicRange::Standard, + symbols, + ) + .expect("reference output should render"); + + assert_eq!( + output.rgba8, + vec![0x20, 0x20, 0x20, u8::MAX, 0xe0, 0xe0, 0xe0, u8::MAX] + ); + } + #[test] fn reference_capture_carries_the_fenced_source_identity() { let capture = MacosScreenshotReferenceCapture::new( From c19b5b5370d9ba5246c7c061d4f20ef9f39c2047 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 08:07:46 -0700 Subject: [PATCH 087/144] feat(macos): report live screen pipeline parity Route explicit parity diagnostics through the active render thread and its current native screen target. Fence Core Graphics references against exact source, session, plan, descriptor, layout, and publication identities. Sample the reduced texture directly so diagnostics cannot replace or reuse a stale compositor output. Report reference-white, gamut, highlight-rolloff, and final-zone parity without serializing screenshot pixels. Co-Authored-By: Nova (OpenAI GPT-5.6 Codex) --- .../hypercolor-cli/src/commands/diagnose.rs | 2 +- crates/hypercolor-daemon/src/api/diagnose.rs | 52 +- .../src/api/macos_screen_parity.rs | 904 ++++++++++++++++++ crates/hypercolor-daemon/src/api/mod.rs | 11 + crates/hypercolor-daemon/src/render_thread.rs | 25 + .../src/render_thread/frame_executor.rs | 9 + .../render_thread/macos_screen_diagnostics.rs | 249 +++++ .../src/render_thread/pipeline_runtime.rs | 49 +- .../src/render_thread/sparkleflinger/gpu.rs | 38 +- .../gpu/tests/sampler/spatial.rs | 35 + .../sparkleflinger/gpu_area_sat.rs | 2 +- .../sparkleflinger/gpu_sampling.rs | 4 +- .../src/render_thread/sparkleflinger/mod.rs | 14 + crates/hypercolor-daemon/src/startup/mod.rs | 9 + 14 files changed, 1396 insertions(+), 7 deletions(-) create mode 100644 crates/hypercolor-daemon/src/api/macos_screen_parity.rs create mode 100644 crates/hypercolor-daemon/src/render_thread/macos_screen_diagnostics.rs diff --git a/crates/hypercolor-cli/src/commands/diagnose.rs b/crates/hypercolor-cli/src/commands/diagnose.rs index 46f561edb..6e7e7f7c8 100644 --- a/crates/hypercolor-cli/src/commands/diagnose.rs +++ b/crates/hypercolor-cli/src/commands/diagnose.rs @@ -11,7 +11,7 @@ use crate::output::{OutputContext, OutputFormat}; /// Run system diagnostics and health checks. #[derive(Debug, Args)] pub struct DiagnoseArgs { - /// Run specific check(s) only (repeatable: daemon, devices, audio, render, config, permissions). + /// Run specific checks only (repeatable; includes `macos_screen_parity`). #[arg(long)] pub check: Vec, diff --git a/crates/hypercolor-daemon/src/api/diagnose.rs b/crates/hypercolor-daemon/src/api/diagnose.rs index 3423ad7f3..ecaf544f4 100644 --- a/crates/hypercolor-daemon/src/api/diagnose.rs +++ b/crates/hypercolor-daemon/src/api/diagnose.rs @@ -54,6 +54,8 @@ struct DiagnoseSnapshot { usb: DiagnoseUsbActorSnapshot, display_output: DiagnoseDisplayOutputSnapshot, device_output: DiagnoseDeviceOutputSnapshot, + #[serde(skip_serializing_if = "Option::is_none")] + macos_screen_parity: Option, } #[derive(Debug, Serialize)] @@ -235,7 +237,11 @@ pub async fn run_diagnostics( let display_output_metrics = state.display_frames.read().await.metrics_snapshot(); let device_metrics = state.device_metrics.load_full(); let input = input_status_snapshot(&state); - let snapshot = build_diagnose_snapshot( + #[allow( + unused_mut, + reason = "macOS parity attaches its report only in the feature-gated build" + )] + let mut snapshot = build_diagnose_snapshot( input, &performance, render_elapsed_ms, @@ -416,6 +422,49 @@ pub async fn run_diagnostics( })); } } + "macos_screen_parity" => { + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + match super::macos_screen_parity::run_macos_screen_parity(&state).await { + Ok(report) => { + let detail = report.detail(); + match serde_json::to_value(report) { + Ok(report) => { + snapshot.macos_screen_parity = Some(report); + checks.push(DiagnoseCheck { + category: "input".to_owned(), + name: "macos_screen_parity".to_owned(), + status: "pass".to_owned(), + detail, + }); + } + Err(_) => checks.push(DiagnoseCheck { + category: "input".to_owned(), + name: "macos_screen_parity".to_owned(), + status: "fail".to_owned(), + detail: "the parity report could not be serialized".to_owned(), + }), + } + } + Err(error) => checks.push(DiagnoseCheck { + category: "input".to_owned(), + name: "macos_screen_parity".to_owned(), + status: error.status().to_owned(), + detail: error.detail().to_owned(), + }), + } + + #[cfg(not(all( + target_os = "macos", + feature = "wgpu", + feature = "screen-capture" + )))] + checks.push(DiagnoseCheck { + category: "input".to_owned(), + name: "macos_screen_parity".to_owned(), + status: "warning".to_owned(), + detail: "macOS screen parity is unavailable in this build".to_owned(), + }); + } other => { checks.push(DiagnoseCheck { category: "custom".to_owned(), @@ -539,6 +588,7 @@ fn build_diagnose_snapshot( usb: build_usb_actor_snapshot(usb_actor_metrics), display_output: build_display_output_snapshot(display_output_metrics), device_output: build_device_output_snapshot(device_metrics), + macos_screen_parity: None, } } diff --git a/crates/hypercolor-daemon/src/api/macos_screen_parity.rs b/crates/hypercolor-daemon/src/api/macos_screen_parity.rs new file mode 100644 index 000000000..0c528877c --- /dev/null +++ b/crates/hypercolor-daemon/src/api/macos_screen_parity.rs @@ -0,0 +1,904 @@ +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hypercolor_core::input::screen::{ + CaptureColorSpace, CaptureDynamicRange, CapturePixelFormat, CaptureTransferFunction, + ScreenBranchPublication, +}; +use hypercolor_core::spatial::SpatialEngine; +use hypercolor_core::types::canvas::Canvas; +use hypercolor_macos_capture::{ + MacosCaptureError, MacosScreenshotPixelCopy, MacosScreenshotPreferredDynamicRange, + MacosScreenshotReferenceCapture, MacosScreenshotReferenceSet, +}; +use hypercolor_types::event::ZoneColors; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::AppState; +use crate::render_thread::sparkleflinger::{CompositionLayer, CompositionPlan, SparkleFlinger}; +use crate::render_thread::{ + MacosScreenParityDiagnosticHandle, MacosScreenParityLiveSnapshot, + MacosScreenParitySnapshotError, +}; + +const DIAGNOSTIC_TIMEOUT: Duration = Duration::from_secs(15); +const REFERENCE_WHITE_MIN_CODE_VALUE: u8 = 224; +const REFERENCE_WHITE_MAX_CHANNEL_SPREAD: u8 = 4; +const GAMUT_MIN_CODE_VALUE: u8 = 64; +const GAMUT_MIN_CHANNEL_SPREAD: u8 = 48; +const HIGHLIGHT_MIN_CODE_VALUE: u8 = 192; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiagnosticDisposition { + Unsupported, + Retry, + Failed, +} + +#[derive(Debug)] +pub(crate) struct MacosScreenParityDiagnosticError { + disposition: DiagnosticDisposition, + detail: String, +} + +impl MacosScreenParityDiagnosticError { + fn unsupported(detail: impl Into) -> Self { + Self { + disposition: DiagnosticDisposition::Unsupported, + detail: detail.into(), + } + } + + fn retry(detail: impl Into) -> Self { + Self { + disposition: DiagnosticDisposition::Retry, + detail: detail.into(), + } + } + + fn failed(detail: impl Into) -> Self { + Self { + disposition: DiagnosticDisposition::Failed, + detail: detail.into(), + } + } + + pub(crate) const fn status(&self) -> &'static str { + match self.disposition { + DiagnosticDisposition::Unsupported | DiagnosticDisposition::Retry => "warning", + DiagnosticDisposition::Failed => "fail", + } + } + + pub(crate) fn detail(&self) -> &str { + &self.detail + } +} + +impl fmt::Display for MacosScreenParityDiagnosticError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl std::error::Error for MacosScreenParityDiagnosticError {} + +#[derive(Debug, Serialize)] +pub(crate) struct MacosScreenParityReport { + status: &'static str, + selection_range: &'static str, + compared_reference_range: &'static str, + unsupported: Vec<&'static str>, + live_pipeline: MacosScreenParityPipelineIdentity, + layout: MacosScreenParityLayoutIdentity, + stability: RgbDeltaMetrics, + surface: MacosScreenParitySurfaceReport, + final_zone_colors: RgbDeltaMetrics, + highlight_rolloff: Option, +} + +impl MacosScreenParityReport { + pub(crate) fn detail(&self) -> String { + format!( + "measured {} pixels and {} LEDs; max surface delta {}, max zone delta {}", + self.surface.all_pixels.samples, + self.final_zone_colors.samples, + self.surface.all_pixels.max_absolute_error, + self.final_zone_colors.max_absolute_error, + ) + } +} + +#[derive(Debug, Serialize)] +struct MacosScreenParityPipelineIdentity { + source_id_sha256: String, + topology_generation: u64, + capture_session_generation: u64, + publication_plan_generation: u64, + descriptor_identity: u64, + first_native_sequence: u64, + second_native_sequence: u64, + source_pixel_format: &'static str, + source_color_space: &'static str, + source_transfer_function: &'static str, + source_dynamic_range: &'static str, + output_pixel_format: &'static str, + processing_algorithm_revision: u32, + tone_map_calibration: MacosScreenParityCalibration, +} + +#[derive(Debug, Serialize)] +struct MacosScreenParityCalibration { + target_white_x: f32, + target_white_y: f32, + target_reference_white_nits: f32, + target_peak_nits: f32, + exposure_ev: f32, +} + +#[derive(Debug, Serialize)] +struct MacosScreenParityLayoutIdentity { + sha256: String, + plan_generation: u64, + canvas_width: u32, + canvas_height: u32, + zones: usize, + leds: usize, +} + +#[derive(Debug, Serialize)] +struct MacosScreenParitySurfaceReport { + width: u32, + height: u32, + all_pixels: RgbDeltaMetrics, + reference_white: ClassifiedRgbDeltaMetrics, + gamut: ClassifiedRgbDeltaMetrics, +} + +#[derive(Debug, Serialize)] +struct ClassifiedRgbDeltaMetrics { + selector: &'static str, + metrics: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct RgbDeltaMetrics { + samples: u64, + mean_absolute_error: f64, + root_mean_square_error: f64, + max_absolute_error: u8, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct HighlightRolloffMetrics { + samples: u64, + mean_standard_luma: f64, + mean_high_luma: f64, + mean_high_minus_standard_luma: f64, + mean_absolute_luma_difference: f64, + max_absolute_luma_difference: f64, +} + +struct RenderedReferences { + selection_range: &'static str, + compared_reference_range: &'static str, + unsupported: Vec<&'static str>, + reference: MacosScreenshotPixelCopy, + highlight_rolloff: Option, +} + +fn reference_zones( + reference: &MacosScreenshotPixelCopy, + canvas_width: u32, + canvas_height: u32, + spatial_engine: &SpatialEngine, +) -> anyhow::Result> { + let canvas = Canvas::try_from_rgba( + &reference.rgba8, + reference.extent.width, + reference.extent.height, + )?; + let composed = SparkleFlinger::cpu().compose( + CompositionPlan::single( + canvas_width, + canvas_height, + CompositionLayer::replace_canvas(canvas), + ) + .with_cpu_replay_cacheable(false), + ); + let canvas = composed + .sampling_canvas + .ok_or_else(|| anyhow::anyhow!("the CPU reference compositor produced no canvas"))?; + Ok(spatial_engine.try_sample(&canvas)?) +} + +pub(crate) async fn run_macos_screen_parity( + state: &Arc, +) -> Result { + let deadline = Instant::now() + DIAGNOSTIC_TIMEOUT; + let diagnostics = state + .macos_screen_parity_diagnostics + .clone() + .ok_or_else(|| { + MacosScreenParityDiagnosticError::unsupported( + "macOS screen parity requires the active Metal render thread", + ) + })?; + let screenshot_action = { + let input = state.input_manager.lock().await; + input.macos_screenshot_reference_action() + }; + let screenshot_action = screenshot_action.ok_or_else(|| { + MacosScreenParityDiagnosticError::unsupported( + "no active macOS screen capture exposes screenshot references", + ) + })?; + + let first = capture_live_snapshot(&diagnostics, deadline).await?; + let first_layout = first.spatial_engine.layout(); + let first_layout_generation = first.spatial_engine.plan_generation(); + if first.spatial_engine.sampling_plan().is_empty() { + return Err(MacosScreenParityDiagnosticError::unsupported( + "the active spatial layout has no LED sampling plan", + )); + } + let layout_hash = layout_sha256(first_layout.as_ref())?; + + let screenshot_rx = screenshot_action().map_err(|error| map_screenshot_action_error(&error))?; + let screenshot_capture = receive_screenshot_capture(screenshot_rx, deadline).await?; + validate_capture_identity(&first.publication, &screenshot_capture)?; + + let second = capture_live_snapshot(&diagnostics, deadline).await?; + validate_live_identity(&first, &second)?; + if second.spatial_engine.plan_generation() != first_layout_generation + || layout_sha256(second.spatial_engine.layout().as_ref())? != layout_hash + { + return Err(MacosScreenParityDiagnosticError::retry( + "the active spatial layout changed during the parity transaction; retry", + )); + } + let stability = require_static_live_content(&first, &second)?; + + let current_spatial = state.spatial_engine.read().await; + let current_layout = current_spatial.layout(); + if current_spatial.plan_generation() != first_layout_generation + || !Arc::ptr_eq(¤t_layout, &first_layout) + { + return Err(MacosScreenParityDiagnosticError::retry( + "the spatial layout changed during the parity transaction; retry", + )); + } + build_report(first, second, screenshot_capture, layout_hash, stability) +} + +async fn capture_live_snapshot( + diagnostics: &MacosScreenParityDiagnosticHandle, + deadline: Instant, +) -> Result { + tokio::time::timeout(remaining(deadline)?, diagnostics.snapshot()) + .await + .map_err(|_| { + MacosScreenParityDiagnosticError::retry( + "the active render thread did not service the parity request; retry", + ) + })? + .map_err(map_snapshot_error) +} + +async fn receive_screenshot_capture( + receiver: std::sync::mpsc::Receiver>, + deadline: Instant, +) -> Result { + let timeout = remaining(deadline)?; + tokio::task::spawn_blocking(move || receiver.recv_timeout(timeout)) + .await + .map_err(|_| { + MacosScreenParityDiagnosticError::failed( + "the screenshot reference receiver task failed", + ) + })? + .map_err(|_| { + MacosScreenParityDiagnosticError::retry( + "the screenshot reference transaction timed out; retry", + ) + })? + .map_err(|error| map_screenshot_error(&error)) +} + +fn map_snapshot_error(error: MacosScreenParitySnapshotError) -> MacosScreenParityDiagnosticError { + match error { + MacosScreenParitySnapshotError::NoActiveScreenPublication => { + MacosScreenParityDiagnosticError::unsupported( + "the active renderer has no live screen publication", + ) + } + MacosScreenParitySnapshotError::PublicationIdentityChanged => { + MacosScreenParityDiagnosticError::retry( + "the active publication identity changed during the parity request; retry", + ) + } + MacosScreenParitySnapshotError::SamplingUnavailable => { + MacosScreenParityDiagnosticError::retry( + "the active GPU sampler could not accept the parity request; retry", + ) + } + MacosScreenParitySnapshotError::RendererStopped => { + MacosScreenParityDiagnosticError::failed( + "the active render thread stopped during the parity transaction", + ) + } + MacosScreenParitySnapshotError::UnsupportedOutputFormat => { + MacosScreenParityDiagnosticError::unsupported( + "the active screen branch does not publish RGBA8 output", + ) + } + MacosScreenParitySnapshotError::NativeReductionFailed + | MacosScreenParitySnapshotError::SurfaceReadbackFailed + | MacosScreenParitySnapshotError::SpatialSamplingFailed => { + MacosScreenParityDiagnosticError::failed(error.to_string()) + } + } +} + +fn map_screenshot_action_error(error: &anyhow::Error) -> MacosScreenParityDiagnosticError { + if let Some(error) = error.downcast_ref::() { + return map_screenshot_error(error); + } + MacosScreenParityDiagnosticError::failed( + "the screenshot reference transaction could not be started", + ) +} + +fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| { + MacosScreenParityDiagnosticError::retry("the parity transaction timed out; retry") + }) +} + +fn layout_sha256( + layout: &hypercolor_types::spatial::SpatialLayout, +) -> Result { + serde_json::to_vec(layout) + .map(|bytes| sha256_hex(&bytes)) + .map_err(|_| { + MacosScreenParityDiagnosticError::failed( + "the active spatial layout could not be serialized", + ) + }) +} + +fn map_screenshot_error(error: &MacosCaptureError) -> MacosScreenParityDiagnosticError { + match error { + MacosCaptureError::ScreenshotCapabilityPending => MacosScreenParityDiagnosticError::retry( + "Tahoe screenshot capability is pending the first complete frame; retry", + ), + MacosCaptureError::ScreenshotSelectionChanged => MacosScreenParityDiagnosticError::retry( + "the selected screen source changed during the parity transaction; retry", + ), + MacosCaptureError::ScreenCapturePermissionRequired => { + MacosScreenParityDiagnosticError::unsupported( + "Screen Recording permission requires an explicit user action", + ) + } + MacosCaptureError::TahoePlatformDefect(_) => MacosScreenParityDiagnosticError::unsupported( + "the active Tahoe runtime lacks required screenshot reference facilities", + ), + _ => MacosScreenParityDiagnosticError::failed( + "the native screenshot reference transaction failed", + ), + } +} + +fn validate_capture_identity( + publication: &ScreenBranchPublication, + capture: &MacosScreenshotReferenceCapture, +) -> Result<(), MacosScreenParityDiagnosticError> { + let epoch = publication.source_epoch(); + if capture.source_id() != epoch.source_id.as_str() + || capture.capture_session_generation() != epoch.session_generation + { + return Err(MacosScreenParityDiagnosticError::retry( + "the screenshot and live publication came from different capture identities; retry", + )); + } + Ok(()) +} + +fn validate_live_identity( + first: &MacosScreenParityLiveSnapshot, + second: &MacosScreenParityLiveSnapshot, +) -> Result<(), MacosScreenParityDiagnosticError> { + if second.publication.native_sequence() <= first.publication.native_sequence() + || second.publication.plan_generation() != first.publication.plan_generation() + || second.publication.descriptor_identity() != first.publication.descriptor_identity() + || second.publication.source_epoch() != first.publication.source_epoch() + || second.descriptor != first.descriptor + { + return Err(MacosScreenParityDiagnosticError::retry( + "the live publication identity changed or did not advance during the parity transaction; retry", + )); + } + Ok(()) +} + +fn require_static_live_content( + first: &MacosScreenParityLiveSnapshot, + second: &MacosScreenParityLiveSnapshot, +) -> Result { + if first.width != second.width + || first.height != second.height + || first.rgba8 != second.rgba8 + || first.zones != second.zones + { + return Err(MacosScreenParityDiagnosticError::retry( + "screen content changed during the parity transaction; show a static calibration image and retry", + )); + } + rgb_delta_metrics(&first.rgba8, &second.rgba8, |_| true) + .map_err(MacosScreenParityDiagnosticError::failed)? + .ok_or_else(|| MacosScreenParityDiagnosticError::failed("the live surface was empty")) +} + +fn build_report( + first: MacosScreenParityLiveSnapshot, + live: MacosScreenParityLiveSnapshot, + screenshot_capture: MacosScreenshotReferenceCapture, + layout_sha256: String, + stability: RgbDeltaMetrics, +) -> Result { + let first_publication = &first.publication; + let second_publication = &live.publication; + let live_descriptor = &first.descriptor; + if live_descriptor.source_epoch() != first_publication.source_epoch() + || live_descriptor.processing_profile().target_pixel_format() != CapturePixelFormat::Rgba8 + { + return Err(MacosScreenParityDiagnosticError::retry( + "the diagnostic branch descriptor no longer matches the live publication; retry", + )); + } + let resolved = first.spatial_engine.layout(); + let live_dynamic_range = live_descriptor + .source_colorimetry() + .dynamic_range() + .ok_or_else(|| { + MacosScreenParityDiagnosticError::failed( + "the live publication omitted its dynamic range", + ) + })?; + let references = render_references(screenshot_capture, live_dynamic_range)?; + ensure_matching_extent(&live, &references.reference)?; + let reference_zones = reference_zones( + &references.reference, + resolved.canvas_width, + resolved.canvas_height, + &first.spatial_engine, + ) + .map_err(|_| { + MacosScreenParityDiagnosticError::failed( + "the Core Graphics reference could not be sampled into final zone colors", + ) + })?; + let final_zone_colors = zone_delta_metrics(&reference_zones, &live.zones) + .map_err(MacosScreenParityDiagnosticError::failed)?; + let all_pixels = rgb_delta_metrics(&references.reference.rgba8, &live.rgba8, |_| true) + .map_err(MacosScreenParityDiagnosticError::failed)? + .ok_or_else(|| MacosScreenParityDiagnosticError::failed("the parity surface was empty"))?; + let reference_white = rgb_delta_metrics(&references.reference.rgba8, &live.rgba8, |rgb| { + rgb.iter().copied().max().unwrap_or(0) >= REFERENCE_WHITE_MIN_CODE_VALUE + && channel_spread(rgb) <= REFERENCE_WHITE_MAX_CHANNEL_SPREAD + }) + .map_err(MacosScreenParityDiagnosticError::failed)?; + let gamut = rgb_delta_metrics(&references.reference.rgba8, &live.rgba8, |rgb| { + rgb.iter().copied().max().unwrap_or(0) >= GAMUT_MIN_CODE_VALUE + && channel_spread(rgb) >= GAMUT_MIN_CHANNEL_SPREAD + }) + .map_err(MacosScreenParityDiagnosticError::failed)?; + let calibration = live_descriptor.processing_profile().led_tone_map(); + let live_epoch = first_publication.source_epoch(); + let leds = live.zones.iter().map(|zone| zone.colors.len()).sum(); + + Ok(MacosScreenParityReport { + status: "measured", + selection_range: references.selection_range, + compared_reference_range: references.compared_reference_range, + unsupported: references.unsupported, + live_pipeline: MacosScreenParityPipelineIdentity { + source_id_sha256: sha256_hex(live_epoch.source_id.as_str().as_bytes()), + topology_generation: live_epoch.topology_generation, + capture_session_generation: live_epoch.session_generation, + publication_plan_generation: first_publication.plan_generation().get(), + descriptor_identity: first_publication.descriptor_identity().get(), + first_native_sequence: first_publication.native_sequence().get(), + second_native_sequence: second_publication.native_sequence().get(), + source_pixel_format: pixel_format_name(live_descriptor.source_pixel_format()), + source_color_space: color_space_name( + live_descriptor.source_colorimetry().color_space(), + ), + source_transfer_function: transfer_function_name( + live_descriptor.source_colorimetry().transfer_function(), + ), + source_dynamic_range: dynamic_range_name(live_dynamic_range), + output_pixel_format: pixel_format_name( + live_descriptor.processing_profile().target_pixel_format(), + ), + processing_algorithm_revision: live_descriptor + .processing_profile() + .algorithm_revision() + .get(), + tone_map_calibration: MacosScreenParityCalibration { + target_white_x: calibration.target_white_x(), + target_white_y: calibration.target_white_y(), + target_reference_white_nits: calibration.target_reference_white_nits(), + target_peak_nits: calibration.target_peak_nits(), + exposure_ev: calibration.exposure_ev(), + }, + }, + layout: MacosScreenParityLayoutIdentity { + sha256: layout_sha256, + plan_generation: first.spatial_engine.plan_generation(), + canvas_width: resolved.canvas_width, + canvas_height: resolved.canvas_height, + zones: live.zones.len(), + leds, + }, + stability, + surface: MacosScreenParitySurfaceReport { + width: live.width, + height: live.height, + all_pixels, + reference_white: ClassifiedRgbDeltaMetrics { + selector: "reference RGB max >= 224 and channel spread <= 4", + metrics: reference_white, + }, + gamut: ClassifiedRgbDeltaMetrics { + selector: "reference RGB max >= 64 and channel spread >= 48", + metrics: gamut, + }, + }, + final_zone_colors, + highlight_rolloff: references.highlight_rolloff, + }) +} + +fn render_references( + capture: MacosScreenshotReferenceCapture, + live_dynamic_range: CaptureDynamicRange, +) -> Result { + match capture.into_references() { + MacosScreenshotReferenceSet::Sdr { image } => { + if live_dynamic_range != CaptureDynamicRange::Standard { + return Err(MacosScreenParityDiagnosticError::retry( + "an SDR-only screenshot selection produced an HDR live publication; retry after capture reconfiguration", + )); + } + let reference = image + .copy_reference_rgba8(MacosScreenshotPreferredDynamicRange::Standard) + .map_err(|error| map_screenshot_error(&error))?; + Ok(RenderedReferences { + selection_range: "sdr_only", + compared_reference_range: "standard", + unsupported: vec!["hdr_reference", "paired_highlight_rolloff"], + reference, + highlight_rolloff: None, + }) + } + MacosScreenshotReferenceSet::Paired { sdr, hdr } => { + let standard = sdr + .copy_reference_rgba8(MacosScreenshotPreferredDynamicRange::Standard) + .map_err(|error| map_screenshot_error(&error))?; + let high = hdr + .copy_reference_rgba8(MacosScreenshotPreferredDynamicRange::High) + .map_err(|error| map_screenshot_error(&error))?; + ensure_reference_pair_extent(&standard, &high)?; + let highlight_rolloff = highlight_rolloff_metrics(&standard.rgba8, &high.rgba8) + .map_err(MacosScreenParityDiagnosticError::failed)?; + match live_dynamic_range { + CaptureDynamicRange::Standard => Ok(RenderedReferences { + selection_range: "paired_sdr_hdr", + compared_reference_range: "standard", + unsupported: Vec::new(), + reference: standard, + highlight_rolloff, + }), + CaptureDynamicRange::High => Ok(RenderedReferences { + selection_range: "paired_sdr_hdr", + compared_reference_range: "high", + unsupported: Vec::new(), + reference: high, + highlight_rolloff, + }), + } + } + } +} + +fn ensure_matching_extent( + live: &MacosScreenParityLiveSnapshot, + reference: &MacosScreenshotPixelCopy, +) -> Result<(), MacosScreenParityDiagnosticError> { + if live.width != reference.extent.width || live.height != reference.extent.height { + return Err(MacosScreenParityDiagnosticError::retry( + "the live publication and Core Graphics reference extents differ; retry after capture settles", + )); + } + Ok(()) +} + +fn ensure_reference_pair_extent( + standard: &MacosScreenshotPixelCopy, + high: &MacosScreenshotPixelCopy, +) -> Result<(), MacosScreenParityDiagnosticError> { + if standard.extent != high.extent { + return Err(MacosScreenParityDiagnosticError::retry( + "the paired Core Graphics reference extents differ; retry", + )); + } + Ok(()) +} + +fn rgb_delta_metrics( + reference: &[u8], + actual: &[u8], + mut include: impl FnMut([u8; 3]) -> bool, +) -> Result, &'static str> { + if reference.len() != actual.len() || !reference.len().is_multiple_of(4) { + return Err("RGBA parity buffers have incompatible lengths"); + } + let mut samples = 0_u64; + let mut absolute_sum = 0_u64; + let mut square_sum = 0_u64; + let mut maximum = 0_u8; + for (reference, actual) in reference.chunks_exact(4).zip(actual.chunks_exact(4)) { + let rgb = [reference[0], reference[1], reference[2]]; + if !include(rgb) { + continue; + } + samples = samples.saturating_add(1); + for channel in 0..3 { + let delta = reference[channel].abs_diff(actual[channel]); + absolute_sum = absolute_sum.saturating_add(u64::from(delta)); + square_sum = square_sum.saturating_add(u64::from(delta) * u64::from(delta)); + maximum = maximum.max(delta); + } + } + if samples == 0 { + return Ok(None); + } + let channel_samples = (samples as f64) * 3.0; + Ok(Some(RgbDeltaMetrics { + samples, + mean_absolute_error: (absolute_sum as f64) / channel_samples, + root_mean_square_error: ((square_sum as f64) / channel_samples).sqrt(), + max_absolute_error: maximum, + })) +} + +fn zone_delta_metrics( + reference: &[ZoneColors], + actual: &[ZoneColors], +) -> Result { + if reference.len() != actual.len() { + return Err("reference and live zone counts differ"); + } + let mut samples = 0_u64; + let mut absolute_sum = 0_u64; + let mut square_sum = 0_u64; + let mut maximum = 0_u8; + for (reference, actual) in reference.iter().zip(actual) { + if reference.zone_id != actual.zone_id || reference.colors.len() != actual.colors.len() { + return Err("reference and live zone identities differ"); + } + for (reference, actual) in reference.colors.iter().zip(&actual.colors) { + samples = samples.saturating_add(1); + for channel in 0..3 { + let delta = reference[channel].abs_diff(actual[channel]); + absolute_sum = absolute_sum.saturating_add(u64::from(delta)); + square_sum = square_sum.saturating_add(u64::from(delta) * u64::from(delta)); + maximum = maximum.max(delta); + } + } + } + if samples == 0 { + return Err("the active layout produced no final LED colors"); + } + let channel_samples = (samples as f64) * 3.0; + Ok(RgbDeltaMetrics { + samples, + mean_absolute_error: (absolute_sum as f64) / channel_samples, + root_mean_square_error: ((square_sum as f64) / channel_samples).sqrt(), + max_absolute_error: maximum, + }) +} + +fn highlight_rolloff_metrics( + standard: &[u8], + high: &[u8], +) -> Result, &'static str> { + if standard.len() != high.len() || !standard.len().is_multiple_of(4) { + return Err("paired reference buffers have incompatible lengths"); + } + let mut samples = 0_u64; + let mut standard_sum = 0.0; + let mut high_sum = 0.0; + let mut signed_delta_sum = 0.0; + let mut absolute_delta_sum = 0.0; + let mut maximum_delta = 0.0_f64; + for (standard, high) in standard.chunks_exact(4).zip(high.chunks_exact(4)) { + if standard[..3] + .iter() + .chain(&high[..3]) + .copied() + .max() + .unwrap_or(0) + < HIGHLIGHT_MIN_CODE_VALUE + { + continue; + } + let standard_luma = encoded_luma(standard); + let high_luma = encoded_luma(high); + let delta = high_luma - standard_luma; + samples = samples.saturating_add(1); + standard_sum += standard_luma; + high_sum += high_luma; + signed_delta_sum += delta; + absolute_delta_sum += delta.abs(); + maximum_delta = maximum_delta.max(delta.abs()); + } + if samples == 0 { + return Ok(None); + } + let samples_f64 = samples as f64; + Ok(Some(HighlightRolloffMetrics { + samples, + mean_standard_luma: standard_sum / samples_f64, + mean_high_luma: high_sum / samples_f64, + mean_high_minus_standard_luma: signed_delta_sum / samples_f64, + mean_absolute_luma_difference: absolute_delta_sum / samples_f64, + max_absolute_luma_difference: maximum_delta, + })) +} + +fn encoded_luma(rgba: &[u8]) -> f64 { + (0.2126 * f64::from(rgba[0]) + 0.7152 * f64::from(rgba[1]) + 0.0722 * f64::from(rgba[2])) + / 255.0 +} + +fn channel_spread(rgb: [u8; 3]) -> u8 { + rgb.iter().copied().max().unwrap_or(0) - rgb.iter().copied().min().unwrap_or(0) +} + +fn sha256_hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut output, "{byte:02x}").expect("writing into a String cannot fail"); + } + output +} + +const fn pixel_format_name(format: CapturePixelFormat) -> &'static str { + match format { + CapturePixelFormat::Rgba8 => "rgba8_unorm", + CapturePixelFormat::Bgra8 => "bgra8_unorm", + CapturePixelFormat::Argb2101010 => "argb2101010", + CapturePixelFormat::Rgba16Float => "rgba16_float", + CapturePixelFormat::Yuv420VideoRange => "yuv420_video_range", + CapturePixelFormat::Yuv420FullRange => "yuv420_full_range", + CapturePixelFormat::Yuv44410BiPlanar => "yuv44410_biplanar", + } +} + +const fn color_space_name(color_space: CaptureColorSpace) -> &'static str { + match color_space { + CaptureColorSpace::Srgb => "srgb", + CaptureColorSpace::DisplayP3 => "display_p3", + CaptureColorSpace::Rec2020 => "rec2020", + CaptureColorSpace::Unknown => "unknown", + } +} + +const fn transfer_function_name(transfer: CaptureTransferFunction) -> &'static str { + match transfer { + CaptureTransferFunction::Srgb => "srgb", + CaptureTransferFunction::Linear => "linear", + CaptureTransferFunction::Rec709 => "rec709", + CaptureTransferFunction::Rec2020 => "rec2020", + CaptureTransferFunction::Pq => "pq", + CaptureTransferFunction::Hlg => "hlg", + CaptureTransferFunction::Unknown => "unknown", + } +} + +const fn dynamic_range_name(range: CaptureDynamicRange) -> &'static str { + match range { + CaptureDynamicRange::Standard => "standard", + CaptureDynamicRange::High => "high", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_metrics_select_reference_white_and_gamut_independently() { + let reference = [240, 240, 240, 255, 240, 32, 16, 255, 24, 24, 24, 255]; + let actual = [238, 241, 240, 255, 230, 34, 20, 255, 24, 24, 24, 255]; + + let white = rgb_delta_metrics(&reference, &actual, |rgb| { + rgb.iter().copied().max().unwrap_or(0) >= REFERENCE_WHITE_MIN_CODE_VALUE + && channel_spread(rgb) <= REFERENCE_WHITE_MAX_CHANNEL_SPREAD + }) + .expect("white metrics") + .expect("one white sample"); + let gamut = rgb_delta_metrics(&reference, &actual, |rgb| { + rgb.iter().copied().max().unwrap_or(0) >= GAMUT_MIN_CODE_VALUE + && channel_spread(rgb) >= GAMUT_MIN_CHANNEL_SPREAD + }) + .expect("gamut metrics") + .expect("one gamut sample"); + + assert_eq!(white.samples, 1); + assert_eq!(white.max_absolute_error, 2); + assert_eq!(gamut.samples, 1); + assert_eq!(gamut.max_absolute_error, 10); + } + + #[test] + fn zone_metrics_reject_identity_drift() { + let reference = [ZoneColors { + zone_id: "zone-a".to_owned(), + colors: vec![[1, 2, 3]], + }]; + let actual = [ZoneColors { + zone_id: "zone-b".to_owned(), + colors: vec![[1, 2, 3]], + }]; + + assert_eq!( + zone_delta_metrics(&reference, &actual), + Err("reference and live zone identities differ") + ); + } + + #[test] + fn paired_highlight_metrics_keep_signed_rolloff_direction() { + let standard = [192, 192, 192, 255, 32, 32, 32, 255]; + let high = [224, 224, 224, 255, 32, 32, 32, 255]; + + let metrics = highlight_rolloff_metrics(&standard, &high) + .expect("highlight metrics") + .expect("one highlight sample"); + + assert_eq!(metrics.samples, 1); + assert!(metrics.mean_high_minus_standard_luma > 0.0); + assert_eq!( + metrics.mean_absolute_luma_difference, + metrics.mean_high_minus_standard_luma + ); + } + + #[test] + fn pending_screenshot_action_remains_retryable() { + let error = anyhow::Error::new(MacosCaptureError::ScreenshotCapabilityPending); + + let mapped = map_screenshot_action_error(&error); + + assert_eq!(mapped.status(), "warning"); + assert!(mapped.detail().contains("pending the first complete frame")); + } + + #[test] + fn saturated_live_sampler_remains_retryable() { + let mapped = map_snapshot_error(MacosScreenParitySnapshotError::SamplingUnavailable); + + assert_eq!(mapped.status(), "warning"); + assert!(mapped.detail().contains("retry")); + } +} diff --git a/crates/hypercolor-daemon/src/api/mod.rs b/crates/hypercolor-daemon/src/api/mod.rs index 185c84be9..909ce54ef 100644 --- a/crates/hypercolor-daemon/src/api/mod.rs +++ b/crates/hypercolor-daemon/src/api/mod.rs @@ -21,6 +21,8 @@ pub mod layers; pub mod layouts; pub mod library; pub mod local; +#[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] +mod macos_screen_parity; pub mod openapi; pub mod output; pub mod preview; @@ -199,6 +201,11 @@ pub struct AppState { /// Aggregate typed input demand shared with render and connection consumers. pub input_publication_demands: InputPublicationDemandHandle, + /// Active-renderer mailbox for explicit macOS screen parity snapshots. + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) macos_screen_parity_diagnostics: + Option, + /// Lock-free latest-value health for the live input graph. pub input_status: SourceStatusRegistry, @@ -594,6 +601,8 @@ impl AppState { input_manager, screen_capacity_status, input_publication_demands: InputPublicationDemandHandle::new(), + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_diagnostics: None, input_status, browser_input, interaction_routing, @@ -681,6 +690,8 @@ impl AppState { input_publication_demands: daemon .input_publication_demands() .expect("live API state requires a running input publication pump"), + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_diagnostics: daemon.macos_screen_parity_diagnostics(), input_status: daemon.input_status.clone(), browser_input: daemon.browser_input.clone(), interaction_routing: daemon.interaction_routing.clone(), diff --git a/crates/hypercolor-daemon/src/render_thread.rs b/crates/hypercolor-daemon/src/render_thread.rs index a08146207..abff4868e 100644 --- a/crates/hypercolor-daemon/src/render_thread.rs +++ b/crates/hypercolor-daemon/src/render_thread.rs @@ -39,6 +39,8 @@ pub mod gpu_device; mod input_publication; mod layer_runtime; mod lighting_feed; +#[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] +mod macos_screen_diagnostics; mod pipeline_driver; mod pipeline_runtime; mod producer_queue; @@ -67,6 +69,11 @@ pub use self::input_publication::{ InputPublicationDemandRegistration, InputPublicationStatus, InputScreenBranchDemand, }; use self::input_publication::{InputPublicationMonitor, InputPublicationPump}; +#[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] +pub(crate) use self::macos_screen_diagnostics::{ + MacosScreenParityDiagnosticHandle, MacosScreenParityLiveSnapshot, + MacosScreenParitySnapshotError, +}; use self::pipeline_driver::run_pipeline; pub(crate) use self::producer_queue::ProducerFrame; pub(crate) use self::render_groups::{RenderSceneContext, ZoneFrameInputs}; @@ -239,6 +246,8 @@ pub struct RenderThread { cancel: CancellationToken, input_publication_demands: InputPublicationDemandHandle, input_publication_monitor: InputPublicationMonitor, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_diagnostics: MacosScreenParityDiagnosticHandle, } /// All shared state the render thread needs. @@ -361,6 +370,9 @@ impl RenderThread { let cancel = CancellationToken::new(); let worker_cancel = cancel.clone(); let (ready_tx, ready_rx) = mpsc::sync_channel::>(1); + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + let (macos_screen_parity_diagnostics, macos_screen_parity_mailbox) = + macos_screen_diagnostics::macos_screen_parity_diagnostic_channel(); let join_handle = std::thread::Builder::new() .name("hypercolor-render".to_owned()) .spawn(move || -> Result<()> { @@ -387,6 +399,12 @@ impl RenderThread { &state, input_pump.reader(), pipeline_demands, + #[cfg(all( + target_os = "macos", + feature = "wgpu", + feature = "screen-capture" + ))] + macos_screen_parity_mailbox, )); match pipeline { Ok(runtime_state) => { @@ -437,6 +455,8 @@ impl RenderThread { cancel, input_publication_demands, input_publication_monitor, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_diagnostics, }) } @@ -450,6 +470,11 @@ impl RenderThread { self.input_publication_monitor.status() } + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) fn macos_screen_parity_diagnostics(&self) -> MacosScreenParityDiagnosticHandle { + self.macos_screen_parity_diagnostics.clone() + } + /// Wait for the render thread to exit. /// /// The caller must stop the render loop first — this method diff --git a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs index cbe0cba88..8257e86b5 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs @@ -431,6 +431,15 @@ pub(crate) async fn execute_frame( ®istry, )) }; + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + if let Some(render_device) = state.render_gpu_device.as_ref() { + render.service_macos_screen_parity( + render_device, + inputs.screen_publication.as_ref(), + inputs.screen_descriptor.as_ref(), + &scene_snapshot.spatial_engine, + ); + } let input_done_at = Instant::now(); let input_us = micros_between(input_start, input_done_at); let input_done_us = micros_between(frame_start, input_done_at); diff --git a/crates/hypercolor-daemon/src/render_thread/macos_screen_diagnostics.rs b/crates/hypercolor-daemon/src/render_thread/macos_screen_diagnostics.rs new file mode 100644 index 000000000..4065770c7 --- /dev/null +++ b/crates/hypercolor-daemon/src/render_thread/macos_screen_diagnostics.rs @@ -0,0 +1,249 @@ +use std::sync::{Arc, mpsc}; + +use anyhow::{Context, Result, anyhow}; +use hypercolor_core::input::screen::{ + CapturePixelFormat, ResolvedScreenPublicationDescriptor, ScreenBranchPublication, + ScreenPublicationFreshness, ScreenPublicationHealth, +}; +use hypercolor_core::spatial::SpatialEngine; +use hypercolor_types::event::ZoneColors; +use thiserror::Error; +use tokio::sync::{mpsc as tokio_mpsc, oneshot}; + +use super::gpu_device::GpuRenderDevice; +use super::producer_queue::GpuTextureFrame; +use super::sparkleflinger::SparkleFlinger; + +const REQUEST_CAPACITY: usize = 1; + +#[derive(Clone)] +pub(crate) struct MacosScreenParityDiagnosticHandle { + sender: tokio_mpsc::Sender, +} + +pub(crate) struct MacosScreenParityDiagnosticMailbox { + receiver: tokio_mpsc::Receiver, +} + +struct MacosScreenParityRequest { + response: oneshot::Sender< + std::result::Result, + >, +} + +pub(crate) struct MacosScreenParityLiveSnapshot { + pub(crate) publication: Arc, + pub(crate) descriptor: ResolvedScreenPublicationDescriptor, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) rgba8: Vec, + pub(crate) zones: Vec, + pub(crate) spatial_engine: SpatialEngine, +} + +#[derive(Debug, Error)] +pub(crate) enum MacosScreenParitySnapshotError { + #[error("the active renderer stopped before servicing the parity request")] + RendererStopped, + #[error("the active renderer has no live screen publication")] + NoActiveScreenPublication, + #[error("the active publication and descriptor identities do not match")] + PublicationIdentityChanged, + #[error("the active screen branch does not publish RGBA8 output")] + UnsupportedOutputFormat, + #[error("the active native screen reduction could not be copied")] + NativeReductionFailed, + #[error("the active native screen surface could not be read back")] + SurfaceReadbackFailed, + #[error("the active GPU sampler could not accept the diagnostic output")] + SamplingUnavailable, + #[error("the active spatial sampler could not produce final zone colors")] + SpatialSamplingFailed, +} + +pub(crate) fn macos_screen_parity_diagnostic_channel() -> ( + MacosScreenParityDiagnosticHandle, + MacosScreenParityDiagnosticMailbox, +) { + let (sender, receiver) = tokio_mpsc::channel(REQUEST_CAPACITY); + ( + MacosScreenParityDiagnosticHandle { sender }, + MacosScreenParityDiagnosticMailbox { receiver }, + ) +} + +impl MacosScreenParityDiagnosticHandle { + pub(crate) async fn snapshot( + &self, + ) -> std::result::Result { + let (response, receiver) = oneshot::channel(); + self.sender + .send(MacosScreenParityRequest { response }) + .await + .map_err(|_| MacosScreenParitySnapshotError::RendererStopped)?; + receiver + .await + .map_err(|_| MacosScreenParitySnapshotError::RendererStopped)? + } +} + +impl MacosScreenParityDiagnosticMailbox { + pub(crate) fn service( + &mut self, + render_device: &GpuRenderDevice, + sparkleflinger: &mut SparkleFlinger, + publication: Option<&Arc>, + descriptor: Option<&ResolvedScreenPublicationDescriptor>, + spatial_engine: &SpatialEngine, + ) { + let Ok(request) = self.receiver.try_recv() else { + return; + }; + let result = capture_active_snapshot( + render_device, + sparkleflinger, + publication, + descriptor, + spatial_engine, + ); + let _ = request.response.send(result); + } +} + +fn capture_active_snapshot( + render_device: &GpuRenderDevice, + sparkleflinger: &mut SparkleFlinger, + publication: Option<&Arc>, + descriptor: Option<&ResolvedScreenPublicationDescriptor>, + spatial_engine: &SpatialEngine, +) -> std::result::Result { + let publication = publication + .cloned() + .ok_or(MacosScreenParitySnapshotError::NoActiveScreenPublication)?; + let descriptor = descriptor + .cloned() + .ok_or(MacosScreenParitySnapshotError::NoActiveScreenPublication)?; + if descriptor.source_epoch() != publication.source_epoch() { + return Err(MacosScreenParitySnapshotError::PublicationIdentityChanged); + } + if descriptor.processing_profile().target_pixel_format() != CapturePixelFormat::Rgba8 { + return Err(MacosScreenParitySnapshotError::UnsupportedOutputFormat); + } + if publication.freshness_at(std::time::Instant::now()) != ScreenPublicationFreshness::Fresh + || publication.health() == ScreenPublicationHealth::Failed + { + return Err(MacosScreenParitySnapshotError::NoActiveScreenPublication); + } + let frame = sparkleflinger + .copy_screen_publication(&publication) + .map_err(|_| MacosScreenParitySnapshotError::NativeReductionFailed)? + .ok_or(MacosScreenParitySnapshotError::NativeReductionFailed)?; + let rgba8 = read_rgba8(render_device, &frame) + .map_err(|_| MacosScreenParitySnapshotError::SurfaceReadbackFailed)?; + let zones = sparkleflinger + .sample_texture_zone_plan(&frame, spatial_engine.sampling_plan().as_ref()) + .map_err(|_| MacosScreenParitySnapshotError::SpatialSamplingFailed)? + .ok_or(MacosScreenParitySnapshotError::SamplingUnavailable)?; + Ok(MacosScreenParityLiveSnapshot { + publication, + descriptor, + width: rgba8.width, + height: rgba8.height, + rgba8: rgba8.rgba8, + zones, + spatial_engine: spatial_engine.clone(), + }) +} + +struct Rgba8Readback { + width: u32, + height: u32, + rgba8: Vec, +} + +fn read_rgba8(render_device: &GpuRenderDevice, frame: &GpuTextureFrame) -> Result { + anyhow::ensure!( + frame.texture.format() == wgpu::TextureFormat::Rgba8Unorm, + "the parity diagnostic requires an RGBA8 live target" + ); + let row_bytes = frame + .width + .checked_mul(4) + .context("live parity row length overflowed")?; + let padded_row_bytes = row_bytes + .div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + .checked_mul(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + .context("live parity row alignment overflowed")?; + let buffer_bytes = u64::from(padded_row_bytes) + .checked_mul(u64::from(frame.height)) + .context("live parity readback length overflowed")?; + let device = render_device.device(); + let queue = render_device.queue(); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Hypercolor macOS screen parity readback"), + size: buffer_bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Hypercolor macOS screen parity readback"), + }); + encoder.copy_texture_to_buffer( + frame.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_row_bytes), + rows_per_image: Some(frame.height), + }, + }, + wgpu::Extent3d { + width: frame.width, + height: frame.height, + depth_or_array_layers: 1, + }, + ); + let submission = queue.submit(Some(encoder.finish())); + let slice = buffer.slice(..); + let (completion_tx, completion_rx) = mpsc::sync_channel(1); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = completion_tx.send(result); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| anyhow!("live parity GPU wait failed: {error}"))?; + completion_rx + .recv() + .context("live parity map callback was dropped")? + .map_err(|error| anyhow!("live parity buffer map failed: {error}"))?; + let mapped = slice.get_mapped_range(); + let output_bytes = usize::try_from(row_bytes) + .ok() + .and_then(|row| row.checked_mul(usize::try_from(frame.height).ok()?)) + .context("live parity output length overflowed")?; + let mut rgba8 = Vec::new(); + rgba8 + .try_reserve_exact(output_bytes) + .map_err(|_| anyhow!("live parity output allocation failed"))?; + let row_bytes = usize::try_from(row_bytes).context("live parity row is not addressable")?; + let padded_row_bytes = + usize::try_from(padded_row_bytes).context("live parity row pitch is not addressable")?; + for row in mapped.chunks_exact(padded_row_bytes) { + rgba8.extend_from_slice(&row[..row_bytes]); + } + drop(mapped); + buffer.unmap(); + anyhow::ensure!( + rgba8.len() == output_bytes, + "live parity readback returned an incomplete surface" + ); + Ok(Rgba8Readback { + width: frame.width, + height: frame.height, + rgba8, + }) +} diff --git a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs index f9f11096e..912636bb4 100644 --- a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs +++ b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs @@ -16,8 +16,9 @@ use hypercolor_core::input::routing::{ InteractionRouteSourceClass, InteractionRouter, RoutedInteraction, SourceIncarnation, }; use hypercolor_core::input::screen::{ - PixelExtent, ScreenBranchLease, ScreenBranchPublication, ScreenNativeExecutionTarget, - ScreenNativeExecutionTargetId, ScreenPlanGeneration, ScreenPublicationExecutorRequest, + PixelExtent, ResolvedScreenPublicationDescriptor, ScreenBranchLease, ScreenBranchPublication, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenPlanGeneration, + ScreenPublicationExecutorRequest, }; use hypercolor_core::input::{ InputData, InputGraphSnapshot, InputSourceSlot, InteractionData, MotionAggregate, PointerMode, @@ -52,6 +53,8 @@ use super::input_publication::{ InputPublicationConsumer, InputPublicationDemand, InputPublicationDemandHandle, InputPublicationReader, OwnedInputPublicationDemand, }; +#[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] +use super::macos_screen_diagnostics::MacosScreenParityDiagnosticMailbox; use super::producer_queue::ProducerQueue; use super::render_groups::{ PreparedZoneReconcile, RenderSceneContext, ZoneFrameInputs, ZoneResult, ZoneRuntime, @@ -89,6 +92,7 @@ pub(crate) struct FrameInputs { pub(crate) interaction: hypercolor_core::input::InteractionData, pub(crate) screen_data: Option, pub(crate) screen_publication: Option>, + pub(crate) screen_descriptor: Option, pub(crate) sensors: Arc, pub(crate) input_availability: InputSourceAvailability, empty_sensors: Arc, @@ -165,6 +169,7 @@ impl InputReuseState { .expect("render canvas dimensions are non-empty"); let (generation, publication) = self.routes.read_screen(screen_target, screen_extent); self.cached_inputs.screen_publication = publication; + self.cached_inputs.screen_descriptor = self.routes.screen_descriptor().cloned(); generation } @@ -308,6 +313,13 @@ impl InputRouteCache { (plan_generation, publication) } + fn screen_descriptor(&self) -> Option<&ResolvedScreenPublicationDescriptor> { + self.screen_publication_route + .as_ref() + .and_then(|route| route.lease.as_ref()) + .map(ScreenBranchLease::descriptor) + } + fn route_interaction_into( &mut self, event_bus: &HypercolorBus, @@ -642,6 +654,7 @@ impl FrameInputs { interaction: InteractionData::default(), screen_data: None, screen_publication: None, + screen_descriptor: None, sensors: Arc::clone(&empty_sensors), input_availability: InputSourceAvailability::default(), empty_sensors, @@ -1361,6 +1374,8 @@ pub(crate) struct RenderCaches { pub(crate) screen_queue: ProducerQueue, pub(crate) composition_planner: CompositionPlanner, pub(crate) sparkleflinger: SparkleFlinger, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) macos_screen_parity_mailbox: MacosScreenParityDiagnosticMailbox, #[cfg(feature = "wgpu")] pub(crate) display_sparkleflinger: SparkleFlinger, #[cfg(feature = "wgpu")] @@ -1848,6 +1863,23 @@ impl ZoneTransitionPlanner { } impl RenderCaches { + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) fn service_macos_screen_parity( + &mut self, + render_device: &GpuRenderDevice, + publication: Option<&Arc>, + descriptor: Option<&ResolvedScreenPublicationDescriptor>, + spatial_engine: &SpatialEngine, + ) { + self.macos_screen_parity_mailbox.service( + render_device, + &mut self.sparkleflinger, + publication, + descriptor, + spatial_engine, + ); + } + pub(crate) fn clear_inactive_groups(&mut self) { #[cfg(feature = "wgpu")] for pending in self.display_finalize_runtime.drain() { @@ -2095,6 +2127,8 @@ impl PipelineRuntime { state: &RenderThreadState, input_reader: InputPublicationReader, input_demands: InputPublicationDemandHandle, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_mailbox: MacosScreenParityDiagnosticMailbox, ) -> Result { let initial_spatial_engine = state.spatial_engine.read().await.clone(); let pipeline = Self::new_with_gpu_device( @@ -2105,6 +2139,8 @@ impl PipelineRuntime { state.render_acceleration_mode, #[cfg(feature = "wgpu")] state.render_gpu_device.clone(), + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_mailbox, Some(Arc::clone(&state.asset_library)), state.configured_max_fps_tier.get(), input_reader, @@ -2132,6 +2168,9 @@ impl PipelineRuntime { configured_max_fps_tier: FpsTier, ) -> Result { let input_demands = InputPublicationDemandHandle::new(); + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + let (_, macos_screen_parity_mailbox) = + super::macos_screen_diagnostics::macos_screen_parity_diagnostic_channel(); Self::new_with_gpu_device( canvas_width, canvas_height, @@ -2140,6 +2179,8 @@ impl PipelineRuntime { render_acceleration_mode, #[cfg(feature = "wgpu")] None, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_mailbox, None, configured_max_fps_tier, InputPublicationReader::empty(), @@ -2155,6 +2196,8 @@ impl PipelineRuntime { screen_capture_configured: bool, render_acceleration_mode: RenderAccelerationMode, #[cfg(feature = "wgpu")] render_gpu_device: Option, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_mailbox: MacosScreenParityDiagnosticMailbox, asset_library: Option>>, configured_max_fps_tier: FpsTier, input_reader: InputPublicationReader, @@ -2216,6 +2259,8 @@ impl PipelineRuntime { screen_queue: ProducerQueue::new(), composition_planner: CompositionPlanner::new(), sparkleflinger, + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + macos_screen_parity_mailbox, #[cfg(feature = "wgpu")] display_sparkleflinger, #[cfg(feature = "wgpu")] diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index cc91e2b00..8b08ee57d 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -64,6 +64,7 @@ use hypercolor_macos_gpu_interop::{ MacosScreenBridge as MacosInteropScreenBridge, MacosScreenStorageIdentity, probe_macos_metal4_capabilities, }; +use hypercolor_types::event::ZoneColors; use hypercolor_types::scene::ZoneId; #[cfg(target_os = "windows")] use hypercolor_windows_capture::{ @@ -91,7 +92,7 @@ use crate::render_thread::producer_queue::{ GpuTextureFrame, GpuTextureFrameLease, GpuTextureFrameOrigin, ProducerFrame, }; use crate::render_thread::sparkleflinger::gpu_sampling::{ - GpuSamplingPlan, GpuSamplingPreparation, GpuSpatialSampler, + GpuSampleSource, GpuSamplingPlan, GpuSamplingPreparation, GpuSpatialSampler, }; mod compositor; @@ -2314,6 +2315,41 @@ impl GpuSparkleFlinger { })) } + pub(crate) fn sample_texture_zone_plan( + &mut self, + frame: &GpuTextureFrame, + prepared_zones: &[PreparedZonePlan], + ) -> Result>> { + self.spatial_sampler.clear_bind_groups(); + let result = (|| { + let mut zones = Vec::new(); + let dispatch = self.spatial_sampler.sample_texture_into( + &self.device, + &self.queue, + GpuSampleSource::Diagnostic, + &frame.view, + frame.width, + frame.height, + prepared_zones, + &mut zones, + None, + )?; + if dispatch.queue_saturated || !dispatch.sampled { + if let Some(pending) = dispatch.pending_readback { + self.spatial_sampler.discard_pending_readback(pending); + } + return Ok(None); + } + if let Some(pending) = dispatch.pending_readback { + self.spatial_sampler + .finish_pending_readback(&self.device, pending, &mut zones)?; + } + Ok(Some(zones)) + })(); + self.spatial_sampler.clear_bind_groups(); + result + } + fn prepare_empty_projected_bind_groups( &self, canvas_preparation: Option<&GpuCanvasPreparation>, diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests/sampler/spatial.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests/sampler/spatial.rs index bb7b4d392..8ee4a757a 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests/sampler/spatial.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests/sampler/spatial.rs @@ -1,5 +1,40 @@ use super::super::*; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +#[test] +fn gpu_sampler_reads_diagnostic_texture_without_replacing_live_output() { + let Some(mut compositor) = gpu_test_compositor() else { + return; + }; + let engine = SpatialEngine::new(sampling_layout(SamplingMode::Bilinear)); + compositor + .compose( + &CompositionPlan::single( + 4, + 4, + CompositionLayer::replace(ProducerFrame::Canvas(patterned_canvas(7))), + ), + false, + None, + ) + .expect("live output should compose"); + let output_generation = compositor.output_generation; + let output_surface = compositor.current_output; + let diagnostic_canvas = patterned_canvas(29); + let diagnostic_frame = compositor + .upload_canvas_frame(&diagnostic_canvas) + .expect("diagnostic texture should upload"); + + let sampled = compositor + .sample_texture_zone_plan(&diagnostic_frame, engine.sampling_plan().as_ref()) + .expect("diagnostic texture sampling should succeed") + .expect("diagnostic texture sampling should be admitted"); + + assert_zone_colors_within(&sampled, &engine.sample(&diagnostic_canvas), 1); + assert_eq!(compositor.output_generation, output_generation); + assert_eq!(compositor.current_output, output_surface); +} + #[test] fn gpu_sampler_matches_cpu_spatial_sampling_for_bilinear_plans() { let Some(mut compositor) = gpu_test_compositor() else { diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_area_sat.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_area_sat.rs index bed921f87..0e7dcee28 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_area_sat.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_area_sat.rs @@ -27,7 +27,7 @@ pub(super) struct GpuAreaResources { horizontal_sums: GpuAreaHierarchy, vertical_sums: GpuAreaHierarchy, params: wgpu::Buffer, - bind_groups: [Option; 2], + bind_groups: [Option; 3], } struct GpuAreaHierarchy { diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_sampling.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_sampling.rs index 85295cbfe..e08a4f78f 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_sampling.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu_sampling.rs @@ -324,6 +324,7 @@ impl GpuSamplingPreparation { pub(super) enum GpuSampleSource { Front, Back, + Diagnostic, } impl GpuSampleSource { @@ -331,6 +332,7 @@ impl GpuSampleSource { match self { Self::Front => 0, Self::Back => 1, + Self::Diagnostic => 2, } } } @@ -613,7 +615,7 @@ impl GpuSpatialSampler { buffer_generation: 0, cached_plan: None, uploaded_plan: None, - cached_bind_groups: Vec::with_capacity(2), + cached_bind_groups: Vec::with_capacity(3), last_readback_wait_blocked: false, #[cfg(test)] sample_dispatch_count: 0, diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs index 8510db3bf..fea895685 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs @@ -1586,6 +1586,20 @@ impl SparkleFlinger { } } + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) fn sample_texture_zone_plan( + &mut self, + frame: &GpuTextureFrame, + prepared_zones: &[PreparedZonePlan], + ) -> Result>> { + match &mut self.backend { + SparkleFlingerBackend::Cpu(_) => Ok(None), + SparkleFlingerBackend::Gpu { gpu, .. } => { + gpu.sample_texture_zone_plan(frame, prepared_zones) + } + } + } + #[allow( clippy::unnecessary_wraps, reason = "the wrapper preserves the fallible GPU snapshot contract in CPU-only builds" diff --git a/crates/hypercolor-daemon/src/startup/mod.rs b/crates/hypercolor-daemon/src/startup/mod.rs index a8be36d49..ba1ea8a80 100644 --- a/crates/hypercolor-daemon/src/startup/mod.rs +++ b/crates/hypercolor-daemon/src/startup/mod.rs @@ -302,6 +302,15 @@ impl DaemonState { .map(RenderThread::input_publication_demands) } + #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] + pub(crate) fn macos_screen_parity_diagnostics( + &self, + ) -> Option { + self.render_thread + .as_ref() + .map(RenderThread::macos_screen_parity_diagnostics) + } + pub(super) fn discovery_runtime(&self) -> discovery::DiscoveryRuntime { self.driver_host.discovery_runtime() } From 1ec050f5bd8a36a8c1399de6e44d868edb60fc59 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 08:10:22 -0700 Subject: [PATCH 088/144] docs(macos): explain capture ownership and support Document the four local daemon topologies, durable owner selection, and exact offline or standalone remedies. Describe the Apple Silicon HDR and Intel SDR boundary plus Tahoe paired-reference behavior. Add explicit protected-access commands and event-driven status watch to the CLI guide. Permission and picker actions stay deliberate and discoverable. Co-Authored-By: Nova (OpenAI GPT-5.6 Codex) --- crates/hypercolor-cli/README.md | 27 +++++++++- docs/content/guide/choose-your-install.md | 16 ++++++ docs/content/guide/installation.md | 65 +++++++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/hypercolor-cli/README.md b/crates/hypercolor-cli/README.md index 365eaf1e8..5779ad678 100644 --- a/crates/hypercolor-cli/README.md +++ b/crates/hypercolor-cli/README.md @@ -29,12 +29,13 @@ on hypercolor-tui (feature-gated). Nothing in the workspace depends on this crat | `devices` | Show connected devices | | `layouts` | Manage spatial layouts | | `audio` | Audio input configuration | +| `access` | Explicit protected input and screen-capture actions | | `library` | Manage favorite effects | | `profiles` | Save and load profiles | | `server` | Daemon connection settings | | `servers` | Multi-server management | -| `service` | Daemon lifecycle (start/stop/status) | -| `status` | Quick daemon status | +| `service` | Daemon lifecycle and macOS owner selection | +| `status` | Quick daemon status or event-driven watch | | `controls` | Adjust live effect controls | | `config` | CLI configuration | | `drivers` | Driver diagnostics | @@ -55,10 +56,32 @@ hypercolor effects list # List available effects hypercolor effects activate # Activate an effect by name hypercolor scenes activate # Activate a scene hypercolor brightness set 80 # Set global brightness to 80% +hypercolor status --watch # Refresh status from ownership/input events +hypercolor access authorize-input-monitoring +hypercolor access authorize-screen-recording +hypercolor access choose-screen-source +hypercolor service choose-owner app-sidecar +hypercolor service choose-owner direct-launchd +hypercolor service choose-owner homebrew hypercolor tui # Launch the full-screen terminal UI hypercolor completions zsh # Generate zsh completions ``` +Protected access commands never prompt during daemon startup. On macOS they +ask the active protected-capability owner to perform one explicit action. A +headless owner that cannot present the system picker returns a typed app-UI +remedy. + +The macOS owner command coordinates the desktop app sidecar, direct launchd +service, and Homebrew service through one durable local handoff. A standalone +daemon is reported with a stop remedy rather than terminated remotely. Only one +topology can hold the per-user daemon guard. + +Apple Silicon supports the native HDR capture path. Intel Macs use SDR and +report HDR as unsupported. On macOS 26 Tahoe, compatible selections can expose +paired SDR and HDR reference diagnostics; SDR-only selections remain explicitly +single-range. + --- Part of [Hypercolor](https://github.com/hyperb1iss/hypercolor) — open-source RGB lighting diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 77ad5f2fe..7e0ad6b96 100644 --- a/docs/content/guide/choose-your-install.md +++ b/docs/content/guide/choose-your-install.md @@ -107,6 +107,18 @@ system picker. Screen Recording permission is requested only after an explicit capture action. Audio-reactive effects also work; system audio needs a loopback device as described in [Audio setup](@/guide/audio-setup.md). +Apple Silicon supports the native HDR screen pipeline. Intel Macs use the SDR +pipeline; HDR capture is reported as unsupported instead of silently falling +back. On macOS 26 Tahoe, a compatible selection can provide paired SDR and HDR +reference diagnostics. Other selections use the single SDR reference path. + +The desktop app normally owns the daemon as an app sidecar. Direct launchd, +Homebrew service, and standalone daemon topologies are also supported, but only +one can own the per-user daemon guard at a time. Use the Settings session panel +or `hypercolor service choose-owner` to switch the persistent owner. Hypercolor +reports an owner conflict or an offline selected service with the exact local +remedy instead of starting a second daemon. + ### Homebrew {#homebrew} The tap carries both a cask and a formula, and CI updates both automatically on each tagged release: @@ -121,6 +133,10 @@ brew install hyperb1iss/tap/hypercolor The formula covers macOS arm64 plus Linux amd64 and arm64; the cask is the full desktop app for either Mac architecture. +The formula selects the Homebrew service topology when managed with +`brew services`. Install the cask when protected macOS permissions or the +system screen picker require the app UI. + --- ## AUR (Arch Linux) {#aur} diff --git a/docs/content/guide/installation.md b/docs/content/guide/installation.md index d40dbc8a7..092a56c3a 100644 --- a/docs/content/guide/installation.md +++ b/docs/content/guide/installation.md @@ -130,6 +130,31 @@ Homebrew users can install the desktop app as a cask a formula (`brew install hyperb1iss/tap/hypercolor`, with `brew services` support). Both update automatically on every tagged release. +### macOS screen capture support + +Screen capture is off until an explicit authorization or source-selection +action. Hypercolor uses Input Monitoring for keyboard and pointer capture and +Screen Recording for ScreenCaptureKit. The settings page links directly to the +matching System Settings privacy pane when manual remediation is needed. + +Apple Silicon supports the native HDR screen pipeline. Intel Macs use SDR and +report HDR as unsupported. On macOS 26 Tahoe, compatible selections can expose +paired SDR and HDR reference diagnostics. An SDR-only selection uses one SDR +reference image and is never relabeled as paired HDR. + +The CLI exposes the same explicit actions when the active process topology can +perform them: + +```bash +hypercolor access authorize-input-monitoring +hypercolor access authorize-screen-recording +hypercolor access choose-screen-source +hypercolor status --watch +``` + +Picker presentation can require `Hypercolor.app`. A headless installation +returns a typed app-UI remedy instead of attempting private presentation APIs. + --- ## The desktop app and autostart @@ -171,6 +196,46 @@ The unit file lives at `~/.config/systemd/user/hypercolor.service` and uses `%h/ The macOS app install registers a LaunchAgent (`tech.hyperbliss.hypercolor`) in `~/Library/LaunchAgents`. The same `hypercolor service` subcommands work on macOS, wrapping `launchctl`. +### Choose the macOS daemon owner + +Hypercolor supports four local daemon topologies: + +- **App sidecar:** the desktop app supervises its bundled daemon. This is the + default for the DMG and cask. +- **Direct launchd:** Hypercolor's per-user LaunchAgent supervises the daemon. +- **Homebrew service:** `brew services` supervises the formula daemon. +- **Standalone:** a daemon started directly from a terminal. This topology can + be observed and stopped, but it is not selected for autostart. + +Only one topology can hold the per-user daemon guard. Select a persistent owner +with one of these local commands: + +```bash +hypercolor service choose-owner app-sidecar +hypercolor service choose-owner direct-launchd +hypercolor service choose-owner homebrew +``` + +Owner changes are journaled across stop, guard handoff, autostart changes, and +startup. A failed handoff rolls back to the prior owner. If a standalone daemon +owns the guard, the command reports its process ID and asks you to stop it +before repeating the selection. + +When a selected external owner is offline, use the remedy named by Settings or +status output: + +```bash +# Direct launchd owner +hypercolor service start + +# Homebrew owner +brew services start hypercolor +``` + +Open `Hypercolor.app` to restore the app-sidecar owner. An ownership conflict is +not a daemon crash; the losing managed contender exits without entering a +restart loop. + --- ## Verify the daemon is running From 36fb1499e87790ab6f3ee649758d74bc9905295e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 08:47:58 -0700 Subject: [PATCH 089/144] fix(macos): open privacy panes through native shell Route Input Monitoring and Screen Recording remedies through a native Tauri command that allowlists the exact System Settings deep links. Keep Intel HDR capability text truthful for SDR-only selections. Co-Authored-By: Nova (Codex) --- crates/hypercolor-app/src/main.rs | 3 +- crates/hypercolor-app/src/window.rs | 42 +++++ crates/hypercolor-app/tests/window_tests.rs | 27 ++- .../src/components/settings_sections.rs | 25 ++- .../src/components/settings_sections/input.rs | 174 ++++++++++++++++-- crates/hypercolor-ui/src/tauri_bridge.rs | 58 +++++- 6 files changed, 297 insertions(+), 32 deletions(-) diff --git a/crates/hypercolor-app/src/main.rs b/crates/hypercolor-app/src/main.rs index 3e5ee7832..004c8926e 100644 --- a/crates/hypercolor-app/src/main.rs +++ b/crates/hypercolor-app/src/main.rs @@ -56,7 +56,8 @@ fn main() -> anyhow::Result<()> { hypercolor_app::support::detect_windows_daemon_service, hypercolor_app::support::launch_pawnio_helper, hypercolor_app::support::repair_smbus_service, - hypercolor_app::window::open_external_url + hypercolor_app::window::open_external_url, + hypercolor_app::window::open_macos_system_settings ]) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { let forwarded = hypercolor_app::cli::AppArgs::parse(args); diff --git a/crates/hypercolor-app/src/window.rs b/crates/hypercolor-app/src/window.rs index ce22d395c..d2f9942f6 100644 --- a/crates/hypercolor-app/src/window.rs +++ b/crates/hypercolor-app/src/window.rs @@ -18,6 +18,11 @@ pub const WINDOW_VISIBILITY_GLOBAL: &str = "__HYPERCOLOR_TAURI_WINDOW_VISIBLE"; /// Web UI route for the settings page. pub const SETTINGS_ROUTE: &str = "/settings"; +const INPUT_MONITORING_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent"; +const SCREEN_RECORDING_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture"; + /// Return true when a webview new-window request should open in the system browser. #[must_use] pub fn should_open_in_system_browser(url: &Url) -> bool { @@ -38,6 +43,20 @@ pub fn system_browser_url(raw: &str) -> Result { } } +/// Resolve a permitted macOS privacy pane to its System Settings deep link. +/// +/// # Errors +/// +/// Returns an error unless `pane` names one of Hypercolor's two supported +/// privacy remedies. +pub fn macos_system_settings_url(pane: &str) -> Result<&'static str, String> { + match pane { + "input_monitoring" => Ok(INPUT_MONITORING_SETTINGS_URL), + "screen_recording" => Ok(SCREEN_RECORDING_SETTINGS_URL), + _ => Err("unsupported macOS System Settings pane".to_owned()), + } +} + /// Open a URL in the system browser for the embedded web UI. /// /// # Errors @@ -50,6 +69,29 @@ pub fn open_external_url(url: String) -> Result<(), String> { open::that_detached(url.as_str()).map_err(|error| format!("failed to open URL: {error}")) } +/// Open one of Hypercolor's macOS privacy remedies through the native shell. +/// +/// # Errors +/// +/// Returns an error when the pane is not allowlisted, the app is not running +/// on macOS, or the operating system rejects the handoff. +#[tauri::command] +pub fn open_macos_system_settings(pane: String) -> Result<(), String> { + let url = macos_system_settings_url(&pane)?; + + #[cfg(target_os = "macos")] + { + open::that_detached(url) + .map_err(|error| format!("failed to open macOS System Settings: {error}")) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = url; + Err("macOS System Settings are unavailable on this platform".to_owned()) + } +} + /// Open a new-window request in the system browser instead of spawning a Tauri webview. #[must_use] pub fn open_new_window_in_system_browser( diff --git a/crates/hypercolor-app/tests/window_tests.rs b/crates/hypercolor-app/tests/window_tests.rs index fdad78a08..cbf5254a8 100644 --- a/crates/hypercolor-app/tests/window_tests.rs +++ b/crates/hypercolor-app/tests/window_tests.rs @@ -1,6 +1,7 @@ use hypercolor_app::window::{ - SETTINGS_ROUTE, WINDOW_VISIBILITY_EVENT, WINDOW_VISIBILITY_GLOBAL, route_navigation_script, - should_open_in_system_browser, system_browser_url, visibility_state_script, + SETTINGS_ROUTE, WINDOW_VISIBILITY_EVENT, WINDOW_VISIBILITY_GLOBAL, macos_system_settings_url, + route_navigation_script, should_open_in_system_browser, system_browser_url, + visibility_state_script, }; #[test] @@ -39,5 +40,27 @@ fn system_browser_handoff_allows_only_web_urls() { fn system_browser_url_rejects_malformed_and_non_web_urls() { assert!(system_browser_url("https://github.com/sponsors/hyperb1iss").is_ok()); assert!(system_browser_url("file:///tmp/hypercolor").is_err()); + assert!(system_browser_url("x-apple.systempreferences:Privacy_ListenEvent").is_err()); assert!(system_browser_url("not a url").is_err()); } + +#[test] +fn macos_system_settings_allowlist_has_only_the_two_privacy_remedies() { + assert_eq!( + macos_system_settings_url("input_monitoring"), + Ok( + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent" + ) + ); + assert_eq!( + macos_system_settings_url("screen_recording"), + Ok( + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture" + ) + ); + assert!(macos_system_settings_url("privacy_security").is_err()); + assert!(macos_system_settings_url( + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent" + ) + .is_err()); +} diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index cda329545..9188e3d39 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -25,6 +25,8 @@ mod discovery; mod input; mod session; +use input::MacosSystemSettingsButton; + pub use about::AboutSection; pub use audio::AudioSection; pub use developer::DeveloperSection; @@ -263,17 +265,22 @@ pub fn CaptureSection(
"Screen Recording"
- "Authorize the active macOS capture owner before choosing content." + "Open Screen Recording in System Settings, enable Hypercolor, then return here."
- +
+ + +
{move || capture_status diff --git a/crates/hypercolor-ui/src/components/settings_sections/input.rs b/crates/hypercolor-ui/src/components/settings_sections/input.rs index 5d8d90d24..1bab78fce 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/input.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/input.rs @@ -13,6 +13,63 @@ use crate::input_access::{ primary_input_source_issue, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct MacosSystemSettingsRemedy { + label: &'static str, + pane: crate::tauri_bridge::MacosSystemSettingsPane, +} + +pub(super) const fn macos_system_settings_remedy( + pane: crate::tauri_bridge::MacosSystemSettingsPane, +) -> MacosSystemSettingsRemedy { + match pane { + crate::tauri_bridge::MacosSystemSettingsPane::InputMonitoring => { + MacosSystemSettingsRemedy { + label: "Open Input Monitoring", + pane, + } + } + crate::tauri_bridge::MacosSystemSettingsPane::ScreenRecording => { + MacosSystemSettingsRemedy { + label: "Open Screen Recording", + pane, + } + } + } +} + +#[component] +pub(super) fn MacosSystemSettingsButton( + pane: crate::tauri_bridge::MacosSystemSettingsPane, +) -> impl IntoView { + let remedy = macos_system_settings_remedy(pane); + let native_available = crate::tauri_bridge::is_tauri_available(); + let open_settings = move |_| { + leptos::task::spawn_local(async move { + match crate::tauri_bridge::open_macos_system_settings(remedy.pane).await { + Ok(true) => {} + Ok(false) => { + leptos::logging::warn!("macOS System Settings opener is unavailable"); + } + Err(error) => { + leptos::logging::warn!("macOS System Settings opener failed: {error}"); + } + } + }); + }; + + view! { + + } +} + #[component] pub fn InputSection( #[prop(into)] config: Signal>, @@ -127,17 +184,22 @@ pub fn InputSection(
"Input Monitoring"
- "Keyboard effects need this macOS permission. Pointer-only effects do not." + "Open Input Monitoring in System Settings, enable Hypercolor, then return here."
- +
+ + +
{move || input_status @@ -357,12 +419,7 @@ pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl .as_ref() .map(screen_selection_label) .unwrap_or_else(|| "Unknown selection".to_owned()); - let range = tahoe_selection - .and_then(|capabilities| capabilities.hdr_capture) - .map_or( - "Dynamic range pending", - |hdr| if hdr { "HDR" } else { "SDR" }, - ); + let range = tahoe_dynamic_range_label(tahoe.as_ref(), tahoe_selection.as_ref()); let host = tahoe.as_ref().map(tahoe_host_label); view! {
@@ -447,6 +504,21 @@ fn tahoe_host_label(capabilities: &crate::api::MacosTahoeStatus) -> String { ) } +fn tahoe_dynamic_range_label( + host: Option<&crate::api::MacosTahoeStatus>, + selection: Option<&crate::api::MacosTahoeSelectionStatus>, +) -> &'static str { + let intel_host = + host.and_then(|capabilities| capabilities.host_architecture.as_deref()) == Some("intel"); + + match selection.and_then(|capabilities| capabilities.hdr_capture) { + Some(_) if intel_host => "HDR unsupported on Intel", + Some(true) => "HDR", + Some(false) => "SDR", + None => "Dynamic range pending", + } +} + pub(super) fn macos_keyboard_needs_authorization(status: &InputStatus) -> bool { status.sources.iter().any(|source| { if source.retired { @@ -521,11 +593,12 @@ pub(super) fn humanize(value: &str) -> String { mod tests { use crate::api::{ InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, - MacosTahoeStatus, SystemStatus, + MacosTahoeSelectionStatus, MacosTahoeStatus, SystemStatus, }; use super::{ - macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates, tahoe_host_label, + macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates, + macos_system_settings_remedy, tahoe_dynamic_range_label, tahoe_host_label, }; fn system_status( @@ -635,6 +708,73 @@ mod tests { ); } + #[test] + fn macos_permission_remedies_keep_exact_labels_and_deep_links() { + let input = macos_system_settings_remedy( + crate::tauri_bridge::MacosSystemSettingsPane::InputMonitoring, + ); + assert_eq!(input.label, "Open Input Monitoring"); + assert_eq!( + input.pane, + crate::tauri_bridge::MacosSystemSettingsPane::InputMonitoring + ); + + let screen = macos_system_settings_remedy( + crate::tauri_bridge::MacosSystemSettingsPane::ScreenRecording, + ); + assert_eq!(screen.label, "Open Screen Recording"); + assert_eq!( + screen.pane, + crate::tauri_bridge::MacosSystemSettingsPane::ScreenRecording + ); + } + + #[test] + fn tahoe_dynamic_range_label_marks_intel_hdr_unsupported_and_tolerates_absence() { + let intel = MacosTahoeStatus { + host_architecture: Some("intel".to_owned()), + ..MacosTahoeStatus::default() + }; + let apple_silicon = MacosTahoeStatus { + host_architecture: Some("apple_silicon".to_owned()), + ..MacosTahoeStatus::default() + }; + let hdr = MacosTahoeSelectionStatus { + hdr_capture: Some(true), + ..MacosTahoeSelectionStatus::default() + }; + + assert_eq!( + tahoe_dynamic_range_label(Some(&intel), Some(&hdr)), + "HDR unsupported on Intel" + ); + let sdr = MacosTahoeSelectionStatus { + hdr_capture: Some(false), + ..MacosTahoeSelectionStatus::default() + }; + assert_eq!( + tahoe_dynamic_range_label(Some(&intel), Some(&sdr)), + "HDR unsupported on Intel" + ); + assert_eq!( + tahoe_dynamic_range_label(Some(&apple_silicon), Some(&hdr)), + "HDR" + ); + assert_eq!( + tahoe_dynamic_range_label(Some(&apple_silicon), Some(&sdr)), + "SDR" + ); + assert_eq!(tahoe_dynamic_range_label(None, Some(&hdr)), "HDR"); + assert_eq!( + tahoe_dynamic_range_label(Some(&apple_silicon), None), + "Dynamic range pending" + ); + assert_eq!( + tahoe_dynamic_range_label(None, None), + "Dynamic range pending" + ); + } + #[test] fn restart_coordinates_require_exact_state_owner_and_epoch() { let mut status = system_status( diff --git a/crates/hypercolor-ui/src/tauri_bridge.rs b/crates/hypercolor-ui/src/tauri_bridge.rs index de3696433..f31cab0a8 100644 --- a/crates/hypercolor-ui/src/tauri_bridge.rs +++ b/crates/hypercolor-ui/src/tauri_bridge.rs @@ -9,6 +9,26 @@ use wasm_bindgen_futures::JsFuture; #[cfg(target_arch = "wasm32")] use hypercolor_leptos_ext::events::window as browser_window; +/// macOS privacy remedy that the native app may open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosSystemSettingsPane { + InputMonitoring, + ScreenRecording, +} + +impl MacosSystemSettingsPane { + #[must_use] + pub const fn invoke_value(self) -> &'static str { + match self { + Self::InputMonitoring => "input_monitoring", + Self::ScreenRecording => "screen_recording", + } + } +} + +#[cfg(any(target_arch = "wasm32", test))] +const MACOS_SYSTEM_SETTINGS_COMMAND: &str = "open_macos_system_settings"; + /// Status for a native Windows service. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -534,6 +554,25 @@ pub async fn open_external_url(_url: &str) -> Result { Ok(false) } +/// Open a limited macOS privacy remedy through the native app bridge. +/// +/// Returns `Ok(false)` when the UI is not running inside the native app. +#[cfg(target_arch = "wasm32")] +pub async fn open_macos_system_settings(pane: MacosSystemSettingsPane) -> Result { + let Some(invoke) = tauri_invoke() else { + return Ok(false); + }; + + let args = string_arg_to_js("pane", pane.invoke_value())?; + let _ = invoke_command(&invoke, MACOS_SYSTEM_SETTINGS_COMMAND, Some(args)).await?; + Ok(true) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn open_macos_system_settings(_pane: MacosSystemSettingsPane) -> Result { + Ok(false) +} + #[cfg(target_arch = "wasm32")] async fn invoke_command( invoke: &js_sys::Function, @@ -658,11 +697,24 @@ fn js_error_string(value: JsValue) -> String { #[cfg(test)] mod tests { use super::{ - MacosOwnerCoordinatorOutcome, MacosOwnerRemedy, PawnIoModuleStatus, PawnIoSupportStatus, - ServiceSupportStatus, bundled_payload_ready, smbus_support_ready, - windows_daemon_service_conflict, + MACOS_SYSTEM_SETTINGS_COMMAND, MacosOwnerCoordinatorOutcome, MacosOwnerRemedy, + MacosSystemSettingsPane, PawnIoModuleStatus, PawnIoSupportStatus, ServiceSupportStatus, + bundled_payload_ready, smbus_support_ready, windows_daemon_service_conflict, }; + #[test] + fn macos_system_settings_panes_route_to_the_scoped_native_command() { + assert_eq!(MACOS_SYSTEM_SETTINGS_COMMAND, "open_macos_system_settings"); + assert_eq!( + MacosSystemSettingsPane::InputMonitoring.invoke_value(), + "input_monitoring" + ); + assert_eq!( + MacosSystemSettingsPane::ScreenRecording.invoke_value(), + "screen_recording" + ); + } + #[test] fn bundled_payload_ready_requires_installer_and_all_modules() { let mut status = status(); From d25547f6e928a2230a260d6f3cd8226ae27771ab Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 08:51:28 -0700 Subject: [PATCH 090/144] ci(macos): qualify native capture contracts Exercise macOS host input, ownership, capture, deployment, and interop contracts in CI. Add Intel-only direct and Core Video import proof, pin launchd loser behavior, and reject unguarded Tahoe symbols. Co-Authored-By: Nova (Codex) --- .github/workflows/ci.yml | 66 +++++++++++++-- .../hypercolor-app/tests/packaging_tests.rs | 8 ++ .../hypercolor-macos-gpu-interop/src/macos.rs | 30 ++++++- .../src/screen_capture.rs | 61 ++++++++++++++ .../tests/screen_capture_bridge_tests.rs | 84 +++++++++++++++++++ 5 files changed, 239 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8665e980..39bb9ecb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,6 +284,14 @@ jobs: with: tool: cargo-nextest + - name: Qualify Intel Metal fixture + if: matrix.expected-arch == 'x86_64' + run: >- + ./scripts/cargo-cache-build.sh + cargo nextest run --locked -p hypercolor-macos-gpu-interop + --features screen-capture --test screen_capture_bridge_tests + -E 'test(intel_runner_qualification_requires_native_device_and_both_import_candidates)' + - name: Check macOS workspace run: >- ./scripts/cargo-cache-build.sh @@ -292,7 +300,8 @@ jobs: - name: Clippy macOS interop run: >- ./scripts/cargo-cache-build.sh - cargo clippy --locked -p hypercolor-macos-gpu-interop --all-targets + cargo clippy --locked -p hypercolor-macos-gpu-interop --features screen-capture + --all-targets -- -D warnings - name: Clippy macOS capture fixtures @@ -311,6 +320,7 @@ jobs: run: >- ./scripts/cargo-cache-build.sh cargo nextest run --locked -p hypercolor-macos-gpu-interop + --features screen-capture - name: Run macOS capture fixtures run: | @@ -323,6 +333,31 @@ jobs: -p hypercolor-core --features macos-capture-fixtures \ --test macos_screen_capture_tests + - name: Run macOS host input and ownership fixtures + run: | + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-macos-input \ + --test input_contract_tests \ + --test process_identity_tests + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-macos-owner \ + --test coordinator_tests + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-core --features macos-native-fixtures \ + --test macos_host_input_tests + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-daemon --no-default-features \ + --test macos_owner_tests + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-daemon --no-default-features \ + --bin hypercolor-daemon \ + -E 'test(/(launchd_managed_contenders_exit_zero_without_respawn|held_guard_applies_topology_policy_without_an_owner_record|malformed_diagnostics_never_override_held_guard_policy)/)' + - name: Run macOS status API fixtures run: >- ./scripts/cargo-cache-build.sh @@ -330,15 +365,30 @@ jobs: -p hypercolor-daemon --no-default-features --features wgpu -E 'test(/api::system::tests::(input_source_status|macos_)/)' - - name: Build deployment target fixture - run: >- - ./scripts/cargo-cache-build.sh - cargo build --locked -p hypercolor-cli --bin hypercolor + - name: Build deployment and Sequoia availability fixtures + run: | + ./scripts/cargo-cache-build.sh \ + cargo build --locked -p hypercolor-cli --bin hypercolor + ./scripts/cargo-cache-build.sh \ + cargo build --locked -p hypercolor-daemon --no-default-features \ + --features wgpu,screen-capture --bin hypercolor-daemon - name: Verify deployment target - run: >- - ./scripts/verify-macos-deployment-target.sh - "${CARGO_TARGET_DIR}/debug/hypercolor" + run: | + ./scripts/verify-macos-deployment-target.sh \ + "${CARGO_TARGET_DIR}/debug/hypercolor" \ + "${CARGO_TARGET_DIR}/debug/hypercolor-daemon" + + - name: Reject unguarded Tahoe symbols in the Sequoia artifact + run: | + set -euo pipefail + artifact="${CARGO_TARGET_DIR}/debug/hypercolor-daemon" + tahoe_symbols='SCScreenshot(Configuration|Manager)|CG(Context(Get|Set)ContentToneMappingInfo|ImageGetContentAverageLightLevel)|kCG(PreferredDynamicRange|DynamicRange(Standard|Constrained|High)|ContentAverageLightLevel)' + if xcrun nm -u "${artifact}" | grep -E "${tahoe_symbols}"; then + echo "unguarded Tahoe-only symbol found in ${artifact}" >&2 + exit 1 + fi + echo "Sequoia availability scan passed: Tahoe-only APIs are runtime-resolved" # ── Generated Effects Artifact ──────────────────────────────── generated-effects: diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index eee8be5a4..77705226f 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -135,6 +135,14 @@ fn macos_launchers_identify_their_daemon_topology() { assert!(HOMEBREW_FORMULA.contains(r#""--macos-owner", "homebrew""#)); } +#[test] +fn macos_launchd_conflict_exit_does_not_restart_the_losing_daemon() { + assert!(MACOS_LAUNCHD_PLIST.contains( + "KeepAlive\n \n SuccessfulExit\n " + )); + assert!(CI_WORKFLOW.contains("launchd_managed_contenders_exit_zero_without_respawn")); +} + #[test] fn app_sidecar_identity_matches_tauri_and_signing_artifacts() { let config: serde_json::Value = diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index e9132aa42..eb4a8172e 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -21,8 +21,8 @@ use objc2_io_surface::{ kIOSurfaceHeight, kIOSurfacePixelFormat, kIOSurfaceWidth, }; use objc2_metal::{ - MTLDevice, MTLGPUFamily, MTLPixelFormat, MTLResource, MTLStorageMode, MTLTexture, - MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, + MTLCreateSystemDefaultDevice, MTLDevice, MTLGPUFamily, MTLPixelFormat, MTLResource, + MTLStorageMode, MTLTexture, MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, }; use thiserror::Error; @@ -62,6 +62,17 @@ pub struct MacosMetal4CapabilityProbe { pub residency_set: bool, } +/// Identity and family facts for the system-default Metal device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacosSystemMetalDeviceQualification { + /// Human-readable device name emitted by native runner qualification. + pub device_name: String, + /// Global IORegistry identity of the system-default device. + pub registry_id: u64, + /// Whether the system-default device reports an Apple GPU family. + pub apple_family: bool, +} + impl MacosMetal4CapabilityProbe { /// Whether every facility required by the prototype is callable. #[must_use] @@ -126,10 +137,25 @@ pub fn probe_macos_metal4_capabilities( }) } +/// Query the system-default Metal device for native runner qualification. +pub fn qualify_macos_system_default_metal_device() -> Result { + let device = + MTLCreateSystemDefaultDevice().ok_or(MacosGpuInteropError::MissingSystemMetalDevice)?; + Ok(MacosSystemMetalDeviceQualification { + device_name: device.name().to_string(), + registry_id: device.registryID(), + apple_family: device.supportsFamily(MTLGPUFamily::Apple1), + }) +} + /// Errors raised while preparing or importing macOS GPU surfaces. #[derive(Debug, Error, PartialEq, Eq)] #[non_exhaustive] pub enum MacosGpuInteropError { + /// `MTLCreateSystemDefaultDevice` did not return a usable device. + #[error("MTLCreateSystemDefaultDevice returned no Metal device")] + MissingSystemMetalDevice, + /// The active wgpu device is not backed by Metal. #[error("wgpu device is not backed by the Metal HAL")] MissingWgpuMetalDevice, diff --git a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs index da57e0eef..0550822f2 100644 --- a/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -445,6 +445,67 @@ impl MacosScreenBridge { }) } + /// Import one frame through an explicit native candidate for fixture tests. + #[doc(hidden)] + pub fn import_frame_via_candidate_for_test( + &self, + candidate: MacosScreenImporterCandidate, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result { + self.import_frame_via_candidate(candidate, device, resource_generation, frame) + } + + fn import_frame_via_candidate( + &self, + candidate: MacosScreenImporterCandidate, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result { + let device_contract = metal_device_import_contract(device)?; + validate_import_device_contract( + self.metal_registry_id, + self.storage_mode, + device_contract, + )?; + let plane_descriptors = validate_frame(&frame, resource_generation)?; + let source_pixel_format = frame + .pixel_format + .fourcc(frame.color.range) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("invalid source color range"))?; + let imported_planes = frame + .surface + .with_native_surface(|lease| { + // SAFETY: the opaque lease was created from this exact + // retained IOSurface and cannot outlive this closure. + let iosurface = unsafe { lease.iosurface_ptr().cast::().as_ref() }; + // SAFETY: the opaque lease was created from this exact + // retained pixel buffer and cannot outlive this closure. + let pixel_buffer = + unsafe { lease.pixel_buffer_ptr().cast::().as_ref() }; + validate_native_surface(iosurface, &frame)?; + self.import_candidate( + candidate, + device, + iosurface, + pixel_buffer, + &frame, + resource_generation, + source_pixel_format, + &plane_descriptors, + ) + }) + .map_err(|error| MacosScreenBridgeError::SurfaceHandoff(error.to_string()))??; + + Ok(ImportedMacosScreenFrame { + content_sequence: frame.sequence, + capture: frame, + planes: imported_planes.into(), + }) + } + #[allow(clippy::too_many_arguments)] fn import_candidate( &self, diff --git a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs index 84cf19295..8dc8fdd68 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -8,6 +8,12 @@ use hypercolor_macos_capture::{ MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, MacosYuvMatrix, }; +#[cfg(target_arch = "x86_64")] +use hypercolor_macos_gpu_interop::{ + ImportedFrameFormat, MacosIosurfaceImportDescriptor, MacosIosurfaceImporter, + MacosScreenImporterCandidate, create_bgra_iosurface, qualify_macos_system_default_metal_device, + write_bgra_pixels, +}; use hypercolor_macos_gpu_interop::{ MacosMetalStorageMode, MacosNativeLetterboxFill, MacosNativeReducer, MacosNativeReductionDescriptor, MacosNativeReductionError, MacosNativeReductionFilter, @@ -96,6 +102,84 @@ fn bridge_imports_and_caches_complete_capture_storage_identity() -> Result<(), S Ok(()) } +#[cfg(target_arch = "x86_64")] +#[test] +fn intel_runner_qualification_requires_native_device_and_both_import_candidates() +-> Result<(), String> { + let qualification = + qualify_macos_system_default_metal_device().map_err(|error| error.to_string())?; + println!( + "Intel Metal qualification: device={} registry_id={} apple_family={}", + qualification.device_name, qualification.registry_id, qualification.apple_family + ); + if qualification.apple_family { + return Err( + "Intel runner qualification requires a non-Apple-family Metal device".to_owned(), + ); + } + + let wgpu = WgpuFixture::new()?; + let pixels = fixture_pixels(); + let iosurface = create_bgra_iosurface(WIDTH, HEIGHT).map_err(|error| error.to_string())?; + write_bgra_pixels(&iosurface, WIDTH, HEIGHT, &pixels).map_err(|error| error.to_string())?; + let descriptor = + MacosIosurfaceImportDescriptor::new(WIDTH, HEIGHT, ImportedFrameFormat::Bgra8Unorm) + .map_err(|error| error.to_string())?; + let mut importer = + MacosIosurfaceImporter::new(&wgpu.device, descriptor).map_err(|error| error.to_string())?; + if importer.metal_registry_id() != qualification.registry_id { + return Err(format!( + "wgpu Metal device {} does not match system-default device {}", + importer.metal_registry_id(), + qualification.registry_id + )); + } + let imported = importer + .import_iosurface_for_test(&wgpu.device, &iosurface) + .map_err(|error| error.to_string())?; + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, &imported.texture, WIDTH, HEIGHT)?, + pixels + ); + + let frame = Arc::new(capture_frame()?); + let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let direct = bridge + .import_frame_via_candidate_for_test( + MacosScreenImporterCandidate::DirectIosurface, + &wgpu.device, + 31, + Arc::clone(&frame), + ) + .map_err(|error| error.to_string())?; + assert!(!direct.planes()[0].uses_core_video_texture_cache()); + let direct_texture = direct + .texture() + .expect("BGRA direct import has a wgpu texture"); + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, direct_texture, WIDTH, HEIGHT)?, + fixture_pixels() + ); + + let core_video = bridge + .import_frame_via_candidate_for_test( + MacosScreenImporterCandidate::CoreVideoTextureCache, + &wgpu.device, + 32, + frame, + ) + .map_err(|error| error.to_string())?; + assert!(core_video.planes()[0].uses_core_video_texture_cache()); + let core_video_texture = core_video + .texture() + .expect("BGRA Core Video import has a wgpu texture"); + assert_eq!( + read_texture_pixels(&wgpu.device, &wgpu.queue, core_video_texture, WIDTH, HEIGHT)?, + fixture_pixels() + ); + Ok(()) +} + #[test] fn native_reducer_compiles_and_reads_back_spatially_reduced_rgba() -> Result<(), String> { let wgpu = WgpuFixture::new()?; From b887267383fdd24ba41ec2663e002df667a9d584 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 09:37:25 -0700 Subject: [PATCH 091/144] feat(macos): publish native input diagnostics Expose bounded macOS host-input and screen-capture telemetry from the native callback boundary through core status, REST, and generated clients. Record renderer import and submitted reduction timing at the actual GPU consumption boundary, and keep ownership identity path-free. Reject stale frames before every publication path and report malformed frames separately so diagnostics preserve the exact failure class. Co-Authored-By: Nova (GPT-5.5 Codex) --- crates/hypercolor-core/src/input/macos.rs | 94 ++- crates/hypercolor-core/src/input/mod.rs | 12 +- .../hypercolor-core/src/input/screen/frame.rs | 29 +- .../hypercolor-core/src/input/screen/macos.rs | 661 ++++++++++++++++-- .../hypercolor-core/src/input/screen/mod.rs | 4 +- crates/hypercolor-core/src/input/status.rs | 114 +++ crates/hypercolor-core/src/input/traits.rs | 1 + .../tests/capture_frame_tests.rs | 51 +- crates/hypercolor-core/tests/input_tests.rs | 52 +- .../tests/macos_host_input_tests.rs | 23 + .../tests/macos_screen_capture_tests.rs | 69 ++ crates/hypercolor-daemon/src/api/system.rs | 374 ++++++++-- .../src/render_thread/sparkleflinger/gpu.rs | 20 +- .../src/startup/macos_owner_watch.rs | 76 +- .../hypercolor-daemon/src/startup/services.rs | 26 +- .../src/diagnostics.rs | 120 ++++ crates/hypercolor-macos-capture/src/native.rs | 13 +- crates/hypercolor-macos-input/src/macos.rs | 3 +- crates/hypercolor-macos-input/src/queue.rs | 73 +- crates/hypercolor-macos-input/src/shared.rs | 8 + crates/hypercolor-macos-input/src/stubs.rs | 7 +- .../hypercolor/_generated/models/__init__.py | 6 + .../input_source_platform_status_type_0.py | 12 + .../input_source_platform_status_type_1.py | 12 + .../models/macos_frame_drop_api_status.py | 69 ++ .../macos_input_telemetry_api_status.py | 408 +++++++++++ .../macos_screen_telemetry_api_status.py | 632 +++++++++++++++++ 27 files changed, 2816 insertions(+), 153 deletions(-) create mode 100644 python/src/hypercolor/_generated/models/macos_frame_drop_api_status.py create mode 100644 python/src/hypercolor/_generated/models/macos_input_telemetry_api_status.py create mode 100644 python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index c77605c30..adc6398b2 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeSet, VecDeque}; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Instant; use hypercolor_macos_input::{ MacosInputBatch, MacosInputConfig, MacosInputError, MacosInputEvent, MacosInputGapReason, @@ -32,6 +33,36 @@ const AUTHORIZATION_NONE: u8 = 0; const AUTHORIZATION_GRANTED: u8 = 1; const AUTHORIZATION_DENIED: u8 = 2; +fn native_process_architecture() -> ( + Option, + crate::input::MacosArchitecture, + Option, +) { + let executable = if cfg!(target_arch = "aarch64") { + crate::input::MacosArchitecture::AppleSilicon + } else { + crate::input::MacosArchitecture::Intel + }; + #[cfg(target_os = "macos")] + { + let capabilities = hypercolor_macos_capture::MacosScreenCaptureSession::capabilities().ok(); + let host = capabilities.map(|capabilities| match capabilities.host_architecture { + hypercolor_macos_capture::MacosHostArchitecture::AppleSilicon => { + crate::input::MacosArchitecture::AppleSilicon + } + hypercolor_macos_capture::MacosHostArchitecture::Intel => { + crate::input::MacosArchitecture::Intel + } + }); + let translated = capabilities.map(|capabilities| capabilities.translated_process); + (host, executable, translated) + } + #[cfg(not(target_os = "macos"))] + { + (None, executable, None) + } +} + type HeldStateKey = (Vec, Vec, i32, i32, i32, i32, bool); #[derive(Debug, Clone, Copy, PartialEq)] @@ -101,8 +132,13 @@ pub struct MacosHostInput { status: SourceStatusReporter, status_session: SourceSessionSlot, keyboard_tcc: MacosAuthorizationState, + authorization_last_transition_at: Option, owner: MacosCapabilityOwner, owner_conflict: Option>, + owner_designated_requirement_hash: Option>, + host_architecture: Option, + executable_architecture: crate::input::MacosArchitecture, + translated_process: Option, authorization_result: Arc, #[cfg(feature = "macos-native-fixtures")] fixture: Option>, @@ -238,6 +274,8 @@ impl MacosHostInput { } else { MacosAuthorizationState::NotDetermined }; + let (host_architecture, executable_architecture, translated_process) = + native_process_architecture(); let mut source = Self { name: "MacosHostInput".to_owned(), running: false, @@ -260,8 +298,13 @@ impl MacosHostInput { ), status_session: SourceSessionSlot::new(), keyboard_tcc, + authorization_last_transition_at: None, owner: MacosCapabilityOwner::Standalone, owner_conflict: None, + owner_designated_requirement_hash: None, + host_architecture, + executable_architecture, + translated_process, authorization_result: Arc::new(AtomicU8::new(AUTHORIZATION_NONE)), #[cfg(feature = "macos-native-fixtures")] fixture: None, @@ -328,9 +371,11 @@ impl MacosHostInput { &mut self, owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, ) -> anyhow::Result<()> { self.owner = owner; self.owner_conflict = conflict.map(Arc::new); + self.owner_designated_requirement_hash = designated_requirement_hash; self.refresh_platform_status() } @@ -488,6 +533,18 @@ impl MacosHostInput { } else { MacosProtectedSourceState::Failed }; + let native = self.session.as_ref().map(MacosInputSession::diagnostics); + let (capture_session_generation, topology_generation, folded_state_gaps) = self + .shared + .lock() + .map(|state| { + ( + self.capture_session_active().then_some(state.epoch), + state.topology_generation, + state.diagnostics.state_gaps, + ) + }) + .unwrap_or((None, None, 0)); self.status .set_platform(Some(SourcePlatformStatus::MacosInput( MacosInputPlatformStatus { @@ -497,11 +554,40 @@ impl MacosHostInput { keyboard_owner: self.owner, pointer_owner: self.owner, owner_conflict: self.owner_conflict.clone(), + authorization_last_transition_at: self.authorization_last_transition_at, + owner_designated_requirement_hash: self + .owner_designated_requirement_hash + .clone(), + host_architecture: self.host_architecture, + executable_architecture: self.executable_architecture, + translated_process: self.translated_process, + capture_session_generation, + topology_generation, + queue_capacity: native.map(|diagnostics| diagnostics.queue_capacity), + queue_depth: native.map(|diagnostics| diagnostics.queue_depth), + input_events_received: native.map(|diagnostics| diagnostics.events_received), + input_events_published: native.map(|diagnostics| diagnostics.events_published), + input_events_dropped: native.map(|diagnostics| diagnostics.dropped_events), + tap_disabled_timeout: native + .map(|diagnostics| diagnostics.tap_disabled_timeout), + tap_disabled_user_input: native + .map(|diagnostics| diagnostics.tap_disabled_user_input), + tap_reenabled: native.map(|diagnostics| diagnostics.tap_reenabled), + state_gaps: native + .map(|diagnostics| diagnostics.state_gaps) + .or((folded_state_gaps > 0).then_some(folded_state_gaps)), }, )))?; Ok(()) } + fn set_keyboard_tcc(&mut self, state: MacosAuthorizationState) { + if self.keyboard_tcc != state { + self.keyboard_tcc = state; + self.authorization_last_transition_at = Some(Instant::now()); + } + } + fn apply_pending_authorization(&mut self) -> anyhow::Result<()> { match self .authorization_result @@ -509,7 +595,7 @@ impl MacosHostInput { { AUTHORIZATION_NONE => return Ok(()), AUTHORIZATION_GRANTED => { - self.keyboard_tcc = MacosAuthorizationState::Authorized; + self.set_keyboard_tcc(MacosAuthorizationState::Authorized); if matches!( self.degraded, Some(InteractionDegradation::InputMonitoringPermissionDenied) @@ -522,7 +608,7 @@ impl MacosHostInput { } } AUTHORIZATION_DENIED => { - self.keyboard_tcc = MacosAuthorizationState::Denied; + self.set_keyboard_tcc(MacosAuthorizationState::Denied); if self.capture_active { self.degraded = Some(InteractionDegradation::InputMonitoringPermissionDenied); } @@ -714,6 +800,7 @@ impl MacosHostInput { } } MacosWorkerState::PermissionRevoked => { + self.set_keyboard_tcc(MacosAuthorizationState::Denied); self.degraded = Some(InteractionDegradation::InputMonitoringPermissionRevoked); if let Some(status) = self.status.session() { status.unavailable(permission_revoked_issue()); @@ -742,8 +829,9 @@ impl InputSource for MacosHostInput { &mut self, owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, ) -> anyhow::Result<()> { - self.set_daemon_ownership(owner, conflict) + self.set_daemon_ownership(owner, conflict, designated_requirement_hash) } fn start(&mut self) -> anyhow::Result<()> { diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 990fbd693..37e9ef527 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -317,6 +317,7 @@ pub struct InputManager { audio_capture_active: Option, macos_capability_owner: MacosCapabilityOwner, macos_owner_conflict: Option, + macos_owner_designated_requirement_hash: Option>, macos_metal4: bool, screen_capture_demand: Option, screen_publication_demand: Option, @@ -575,6 +576,7 @@ impl InputManager { audio_capture_active: None, macos_capability_owner: MacosCapabilityOwner::Standalone, macos_owner_conflict: None, + macos_owner_designated_requirement_hash: None, macos_metal4: false, screen_capture_demand: None, screen_publication_demand: None, @@ -1957,11 +1959,18 @@ impl InputManager { &mut self, owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, ) -> anyhow::Result<()> { self.macos_capability_owner = owner; self.macos_owner_conflict.clone_from(&conflict); + self.macos_owner_designated_requirement_hash + .clone_from(&designated_requirement_hash); for source in &mut self.sources { - source.set_macos_daemon_ownership(owner, conflict.clone())?; + source.set_macos_daemon_ownership( + owner, + conflict.clone(), + designated_requirement_hash.clone(), + )?; } self.publish_source_status_registry(); Ok(()) @@ -2070,6 +2079,7 @@ impl InputManager { .set_macos_daemon_ownership( self.macos_capability_owner, self.macos_owner_conflict.clone(), + self.macos_owner_designated_requirement_hash.clone(), ) .expect("new source accepts retained macOS ownership status"); source diff --git a/crates/hypercolor-core/src/input/screen/frame.rs b/crates/hypercolor-core/src/input/screen/frame.rs index 246641314..b7c826763 100644 --- a/crates/hypercolor-core/src/input/screen/frame.rs +++ b/crates/hypercolor-core/src/input/screen/frame.rs @@ -7,7 +7,7 @@ use std::num::{NonZeroU32, NonZeroU64}; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, Weak}; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; @@ -1273,6 +1273,15 @@ pub enum PlatformGpuApi { Other(Arc), } +/// Timing observer attached to one native GPU publication. +pub trait PlatformGpuSurfaceTimingSink: Send + Sync { + /// Record a completed native import attempt. + fn record_import(&self, elapsed: Duration); + + /// Record a completed native reduction command submission. + fn record_native_reduction_submission(&self, elapsed: Duration); +} + /// Opaque, lifetime-owning GPU surface descriptor. #[derive(Clone)] pub struct PlatformGpuSurface { @@ -1285,6 +1294,7 @@ pub struct PlatformGpuSurface { target_resource_lifetime: Option, shared_target_resource_lifetime: Option, capture_resource_lifetime: Option, + timing_sink: Option>, } /// Typed access to one GPU owner paired with every attached resource lifetime. @@ -1356,9 +1366,20 @@ impl PlatformGpuSurface { target_resource_lifetime: None, shared_target_resource_lifetime: None, capture_resource_lifetime: None, + timing_sink: None, }) } + /// Attach a backend timing observer without exposing platform types. + #[must_use] + pub fn with_timing_sink(mut self, timing_sink: Arc) -> Self + where + T: PlatformGpuSurfaceTimingSink + 'static, + { + self.timing_sink = Some(timing_sink); + self + } + pub(crate) fn with_native_target_owners( mut self, retained_owner: Arc, @@ -1456,6 +1477,12 @@ impl PlatformGpuSurface { pub const fn capture_resource_lifetime(&self) -> Option<&ScreenResourceLifetime> { self.capture_resource_lifetime.as_ref() } + + /// Backend timing observer retained with this publication. + #[must_use] + pub fn timing_sink(&self) -> Option<&Arc> { + self.timing_sink.as_ref() + } } impl fmt::Debug for PlatformGpuSurface { diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index b90bcfca0..10be1274d 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -1,15 +1,16 @@ use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, mpsc}; use std::thread; use std::time::{Duration, Instant}; use anyhow::anyhow; use hypercolor_macos_capture::{ - MacosCaptureCapabilities as NativeCaptureCapabilities, MacosCaptureContentStyle, - MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, - MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, MacosFrameEvent, MacosFrameMailbox, - MacosFrameStatus, MacosHostArchitecture as NativeHostArchitecture, + MacosCaptureCallbackDiagnostics, MacosCaptureCapabilities as NativeCaptureCapabilities, + MacosCaptureContentStyle, MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCaptureSelection, MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, + MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosHostArchitecture as NativeHostArchitecture, MacosProtectedSourceState as NativeProtectedSourceState, MacosTahoeSelectionCapabilities as NativeTahoeSelectionCapabilities, MacosTransferFunction, }; @@ -31,19 +32,20 @@ use super::{ CaptureRotation, CaptureSourceId, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, CpuExactReductionWorkPlan, CpuPublicationFanoutError, CpuReductionExecutor, CpuSamplingError, CpuScalarSource, LedToneMapCalibration, PixelExtent, PixelRect, PlatformGpuApi, - PlatformGpuSurface, PreparedCpuPublicationFanout, PreparedCpuPublicationFanoutCandidate, - RawCaptureSurface, RegisteredScreenBranchDemand, ResolvedScreenBranchDemand, - ResolvedScreenPublicationDescriptor, ResolvedScreenSource, ResolvedScreenSourceConfig, - ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, - ScreenBackendResourceIdentity, ScreenBranchPayload, ScreenBranchPublisher, - ScreenByteAdmissionCoordinator, ScreenCaptureBackend, ScreenCaptureDemand, ScreenCaptureInput, - ScreenCursorCapabilities, ScreenExecutorColorCapabilities, ScreenGpuSurfacePayload, - ScreenNativePreparationPayload, ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, - ScreenPreparedWorkerToken, ScreenPublicationColorimetry, ScreenPublicationExecutor, - ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationMetadata, ScreenPublicationRequest, - ScreenRequiredResourceMinimum, ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, - ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, + PlatformGpuSurface, PlatformGpuSurfaceTimingSink, PreparedCpuPublicationFanout, + PreparedCpuPublicationFanoutCandidate, RawCaptureSurface, RegisteredScreenBranchDemand, + ResolvedScreenBranchDemand, ResolvedScreenPublicationDescriptor, ResolvedScreenSource, + ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, + ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, ScreenBranchPayload, + ScreenBranchPublisher, ScreenByteAdmissionCoordinator, ScreenCaptureBackend, + ScreenCaptureDemand, ScreenCaptureInput, ScreenCursorCapabilities, + ScreenExecutorColorCapabilities, ScreenGpuSurfacePayload, ScreenNativePreparationPayload, + ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPreparedWorkerToken, + ScreenPublicationColorimetry, ScreenPublicationExecutor, ScreenPublicationExecutorRequest, + ScreenPublicationHealth, ScreenPublicationHub, ScreenPublicationHubError, + ScreenPublicationMetadata, ScreenPublicationRequest, ScreenRequiredResourceMinimum, + ScreenResourceApi, ScreenResourceKind, ScreenResourceLifetime, ScreenSourceReflection, + ScreenSourceSelector, ScreenWorkerBinding, ScreenWorkerBindingState, ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; @@ -64,17 +66,154 @@ use crate::input::{ const WORKER_WAIT: Duration = Duration::from_millis(100); +const PUBLICATION_PATH_UNKNOWN: u8 = 0; +const PUBLICATION_PATH_CPU: u8 = 1; +const PUBLICATION_PATH_NATIVE: u8 = 2; +const PUBLICATION_PATH_CPU_FALLBACK: u8 = 3; + +#[derive(Debug, Default)] +struct MacosScreenRuntimeTelemetry { + publication_path: AtomicU8, + fallback_reason: Mutex>>, + publication_plan_generation: AtomicU64, + stale_frames: AtomicU64, + cpu_reduction_total_ns: AtomicU64, + cpu_reduction_max_ns: AtomicU64, + native_import_total_ns: AtomicU64, + native_import_max_ns: AtomicU64, + native_reduction_submit_total_ns: AtomicU64, + native_reduction_submit_max_ns: AtomicU64, + admitted_native_bytes: AtomicU64, + pinned_generations: AtomicUsize, +} + +impl PlatformGpuSurfaceTimingSink for MacosScreenRuntimeTelemetry { + fn record_import(&self, elapsed: Duration) { + record_timing( + &self.native_import_total_ns, + &self.native_import_max_ns, + elapsed, + ); + } + + fn record_native_reduction_submission(&self, elapsed: Duration) { + record_timing( + &self.native_reduction_submit_total_ns, + &self.native_reduction_submit_max_ns, + elapsed, + ); + } +} + +impl MacosScreenRuntimeTelemetry { + fn set_cpu(&self) { + self.publication_path + .store(PUBLICATION_PATH_CPU, Ordering::Release); + *lock(&self.fallback_reason) = None; + } + + fn set_native(&self) { + self.publication_path + .store(PUBLICATION_PATH_NATIVE, Ordering::Release); + *lock(&self.fallback_reason) = None; + } + + fn set_cpu_fallback(&self, reason: &'static str) { + self.publication_path + .store(PUBLICATION_PATH_CPU_FALLBACK, Ordering::Release); + *lock(&self.fallback_reason) = Some(Arc::from(reason)); + } + + fn publication_path(&self) -> Option> { + match self.publication_path.load(Ordering::Acquire) { + PUBLICATION_PATH_CPU => Some(Arc::from("cpu")), + PUBLICATION_PATH_NATIVE => Some(Arc::from("native")), + PUBLICATION_PATH_CPU_FALLBACK => Some(Arc::from("cpu_fallback")), + PUBLICATION_PATH_UNKNOWN => None, + _ => None, + } + } + + fn record_cpu_reduction(&self, elapsed: Duration) { + record_timing( + &self.cpu_reduction_total_ns, + &self.cpu_reduction_max_ns, + elapsed, + ); + } +} + +fn record_timing(total: &AtomicU64, maximum: &AtomicU64, elapsed: Duration) { + let nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let _ = total.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + Some(current.saturating_add(nanos)) + }); + maximum.fetch_max(nanos, Ordering::Relaxed); +} + #[cfg(target_os = "macos")] struct MacosCapturePoolAdmission { - lease: Arc, + lifetime: Arc, metadata_bytes: u64, observed: Vec<(u32, u64)>, } +#[cfg(target_os = "macos")] +struct MacosCaptureAdmissionLifetime { + lease: Arc, + telemetry: Arc, + bytes: AtomicU64, +} + +#[cfg(target_os = "macos")] +impl MacosCaptureAdmissionLifetime { + fn new(lease: Arc, telemetry: Arc) -> Self { + let bytes = lease.bytes(); + telemetry + .admitted_native_bytes + .fetch_add(bytes, Ordering::AcqRel); + Self { + lease, + telemetry, + bytes: AtomicU64::new(bytes), + } + } + + fn reconcile(&self, exact_bytes: u64) -> Result<(), ScreenByteAdmissionError> { + self.lease.try_reconcile_exact(exact_bytes)?; + let previous = self.bytes.swap(exact_bytes, Ordering::AcqRel); + if exact_bytes >= previous { + self.telemetry + .admitted_native_bytes + .fetch_add(exact_bytes - previous, Ordering::AcqRel); + } else { + self.telemetry + .admitted_native_bytes + .fetch_sub(previous - exact_bytes, Ordering::AcqRel); + } + Ok(()) + } + + #[cfg(test)] + fn bytes(&self) -> u64 { + self.lease.bytes() + } +} + +#[cfg(target_os = "macos")] +impl Drop for MacosCaptureAdmissionLifetime { + fn drop(&mut self) { + self.telemetry + .admitted_native_bytes + .fetch_sub(self.bytes.load(Ordering::Acquire), Ordering::AcqRel); + } +} + #[cfg(target_os = "macos")] impl MacosCapturePoolAdmission { fn reserve( coordinator: &ScreenByteAdmissionCoordinator, + telemetry: Arc, conservative_surface_bytes: u64, native_metadata_bytes: u64, ) -> Result { @@ -115,7 +254,10 @@ impl MacosCapturePoolAdmission { }, )?; Ok(Self { - lease: Arc::new(reservation.freeze()), + lifetime: Arc::new(MacosCaptureAdmissionLifetime::new( + Arc::new(reservation.freeze()), + telemetry, + )), metadata_bytes, observed, }) @@ -125,7 +267,8 @@ impl MacosCapturePoolAdmission { &mut self, iosurface_id: u32, allocation_bytes: u64, - ) -> Result, hypercolor_macos_capture::MacosCaptureError> { + ) -> Result, hypercolor_macos_capture::MacosCaptureError> + { if iosurface_id == 0 || allocation_bytes == 0 { return Err(hypercolor_macos_capture::MacosCaptureError::InvalidSurface); } @@ -174,15 +317,15 @@ impl MacosCapturePoolAdmission { .checked_add(observed_sum) .and_then(|bytes| bytes.checked_add(projected_unseen)) .ok_or(hypercolor_macos_capture::MacosCaptureError::ArithmeticOverflow)?; - self.lease - .try_reconcile_exact(exact_bytes) + self.lifetime + .reconcile(exact_bytes) .map_err(map_macos_pool_admission_error)?; if let Some(index) = existing { self.observed[index].1 = allocation_bytes; } else { self.observed.push((iosurface_id, allocation_bytes)); } - Ok(Arc::clone(&self.lease)) + Ok(Arc::clone(&self.lifetime)) } #[cfg(test)] @@ -197,7 +340,8 @@ impl MacosCapturePoolAdmission { #[cfg(test)] fn reservation_variance(&self) -> u64 { - self.lease + self.lifetime + .lease .bytes() .saturating_sub(self.metadata_bytes) .saturating_sub(self.exact_observed_pool_bytes()) @@ -288,6 +432,7 @@ trait MacosCaptureControl: Send + Sync { fn tahoe_selection_capabilities(&self) -> Option; fn host_capabilities(&self) -> NativeCaptureCapabilities; fn authorization(&self) -> MacosAuthorizationState; + fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics; fn captured_at(&self, display_time: u64) -> anyhow::Result; #[cfg(target_os = "macos")] @@ -353,6 +498,10 @@ impl MacosCaptureControl for NativeCaptureControl { } } + fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + self.session.diagnostics() + } + fn captured_at(&self, display_time: u64) -> anyhow::Result { self.clock .timestamp(display_time) @@ -390,6 +539,7 @@ struct MacosPublicationSource { pixel_format: MacosCapturePixelFormat, resource_generation: u64, allocation_bytes: u64, + display_scale_bits: u64, cursor_composed: bool, } @@ -430,6 +580,7 @@ impl MacosPublicationSource { pixel_format: frame.pixel_format, resource_generation, allocation_bytes: frame.surface.allocation_bytes, + display_scale_bits: frame.geometry.display_scale_factor.get().to_bits(), cursor_composed: frame.cursor_composed, }) } @@ -672,6 +823,7 @@ pub struct MacosScreenCaptureInput { admission: ScreenByteAdmissionCoordinator, publication: Arc>, exact: Arc, + telemetry: Arc, worker: Option, worker_generation: u64, demand: ScreenCaptureDemand, @@ -680,6 +832,9 @@ pub struct MacosScreenCaptureInput { status_session: SourceSessionSlot, owner: MacosCapabilityOwner, owner_conflict: Option>, + owner_designated_requirement_hash: Option>, + authorization: MacosAuthorizationState, + authorization_last_transition_at: Option, metal4: bool, } @@ -696,12 +851,15 @@ impl MacosScreenCaptureInput { let selector = MacosCaptureSelector::parse(&config.source)?; let host_capabilities = MacosScreenCaptureSession::capabilities()?; let pool_coordinator = admission.clone(); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool_telemetry = Arc::clone(&telemetry); let session = MacosScreenCaptureSession::new_with_pool_admission( request, selector, move |conservative_surface_bytes, native_metadata_bytes| { let pool = Arc::new(Mutex::new(MacosCapturePoolAdmission::reserve( &pool_coordinator, + Arc::clone(&pool_telemetry), conservative_surface_bytes, native_metadata_bytes, )?)); @@ -712,7 +870,7 @@ impl MacosScreenCaptureInput { }, )?; let clock = MacosDisplayClock::system()?; - Ok(Self::with_control( + Ok(Self::with_control_and_telemetry( config, admission, Arc::new(NativeCaptureControl { @@ -720,21 +878,39 @@ impl MacosScreenCaptureInput { clock, host_capabilities, }), + telemetry, )) } + #[cfg(feature = "macos-capture-fixtures")] fn with_control( config: CaptureConfig, admission: ScreenByteAdmissionCoordinator, control: Arc, + ) -> Self { + Self::with_control_and_telemetry( + config, + admission, + control, + Arc::new(MacosScreenRuntimeTelemetry::default()), + ) + } + + fn with_control_and_telemetry( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + control: Arc, + telemetry: Arc, ) -> Self { let consented = control.authorization() == MacosAuthorizationState::Authorized; + let authorization = control.authorization(); let mut source = Self { config, control, admission, publication: Arc::new(Mutex::new(MacosPublication::default())), exact: Arc::new(MacosExactPublicationShared::default()), + telemetry, worker: None, worker_generation: 0, demand: ScreenCaptureDemand::Inactive, @@ -750,6 +926,9 @@ impl MacosScreenCaptureInput { status_session: SourceSessionSlot::new(), owner: MacosCapabilityOwner::Standalone, owner_conflict: None, + owner_designated_requirement_hash: None, + authorization, + authorization_last_transition_at: None, metal4: false, }; source @@ -782,19 +961,119 @@ impl MacosScreenCaptureInput { fn refresh_platform_status(&mut self) -> anyhow::Result<()> { let state = self.control.status(); + let authorization = self.control.authorization(); + if authorization != self.authorization { + self.authorization = authorization; + self.authorization_last_transition_at = Some(Instant::now()); + } + let diagnostics = self.control.diagnostics(); + let source = self.exact.source(); self.status .set_platform(Some(SourcePlatformStatus::MacosScreen( MacosScreenPlatformStatus { state: map_protected_state(state), - tcc: self.control.authorization(), + tcc: authorization, owner: self.owner, selection: map_selection(self.control.selection()), + selection_diagnostic_label: selection_diagnostic_label( + self.control.selection(), + ), tahoe: map_tahoe_capabilities(self.control.host_capabilities(), self.metal4), tahoe_selection: self .control .tahoe_selection_capabilities() .map(map_tahoe_selection_capabilities), owner_conflict: self.owner_conflict.clone(), + authorization_last_transition_at: self.authorization_last_transition_at, + owner_designated_requirement_hash: self + .owner_designated_requirement_hash + .clone(), + executable_architecture: executable_architecture(), + stream_state: Arc::from(stream_state_name(state)), + capture_session_generation: source + .as_ref() + .map(|source| source.epoch.session_generation), + topology_generation: source + .as_ref() + .map(|source| source.epoch.topology_generation), + resource_generation: source.as_ref().map(|source| source.resource_generation), + publication_plan_generation: nonzero_telemetry( + self.telemetry + .publication_plan_generation + .load(Ordering::Acquire), + ), + pixel_format: source + .as_ref() + .map(|source| Arc::from(pixel_format_name(source.pixel_format))), + dynamic_range: source.as_ref().and_then(|source| { + source + .colorimetry + .dynamic_range() + .map(|range| Arc::from(dynamic_range_name(range))) + }), + color_space: source.as_ref().map(|source| { + Arc::from(color_space_name(source.colorimetry.color_space())) + }), + transfer_function: source.as_ref().map(|source| { + Arc::from(transfer_function_name( + source.colorimetry.transfer_function(), + )) + }), + display_scale_bits: source.as_ref().map(|source| source.display_scale_bits), + native_width: source + .as_ref() + .map(|source| source.geometry.native_extent().width()), + native_height: source + .as_ref() + .map(|source| source.geometry.native_extent().height()), + queue_depth: hypercolor_macos_capture::MACOS_STREAM_QUEUE_DEPTH, + admitted_native_bytes: self + .telemetry + .admitted_native_bytes + .load(Ordering::Acquire), + pinned_generations: Some( + self.telemetry.pinned_generations.load(Ordering::Acquire), + ), + frames_received: diagnostics.frames_received, + frames_published: diagnostics.frames_published, + frames_superseded: diagnostics.superseded_deliveries, + frames_malformed: diagnostics.malformed_frames, + frames_dropped: frame_drop_counters(diagnostics), + frames_stale: self.telemetry.stale_frames.load(Ordering::Acquire), + publication_path: self.telemetry.publication_path(), + fallback_reason: lock(&self.telemetry.fallback_reason).clone(), + callback_total_ns: diagnostics.callback_total_ns, + callback_max_ns: diagnostics.callback_max_ns, + retain_total_ns: diagnostics.retain_total_ns, + retain_max_ns: diagnostics.retain_max_ns, + conversion_total_ns: diagnostics.conversion_total_ns, + conversion_max_ns: diagnostics.conversion_max_ns, + cpu_reduction_total_ns: self + .telemetry + .cpu_reduction_total_ns + .load(Ordering::Acquire), + cpu_reduction_max_ns: self + .telemetry + .cpu_reduction_max_ns + .load(Ordering::Acquire), + native_import_total_ns: self + .telemetry + .native_import_total_ns + .load(Ordering::Acquire), + native_import_max_ns: self + .telemetry + .native_import_max_ns + .load(Ordering::Acquire), + native_reduction_submit_total_ns: self + .telemetry + .native_reduction_submit_total_ns + .load(Ordering::Acquire), + native_reduction_submit_max_ns: self + .telemetry + .native_reduction_submit_max_ns + .load(Ordering::Acquire), + publication_total_ns: diagnostics.publication_total_ns, + publication_max_ns: diagnostics.publication_max_ns, }, )))?; Ok(()) @@ -835,6 +1114,7 @@ impl MacosScreenCaptureInput { let control = Arc::clone(&self.control); let publication = Arc::clone(&self.publication); let exact = Arc::clone(&self.exact); + let telemetry = Arc::clone(&self.telemetry); let status_session = self.status_session.clone(); let target_fps = prepared.target_fps; let stop = Arc::new(AtomicBool::new(false)); @@ -857,6 +1137,7 @@ impl MacosScreenCaptureInput { mailbox, publication, exact, + telemetry, worker_generation, target_fps, status_session, @@ -939,9 +1220,11 @@ impl InputSource for MacosScreenCaptureInput { &mut self, owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, ) -> anyhow::Result<()> { self.owner = owner; self.owner_conflict = conflict.map(Arc::new); + self.owner_designated_requirement_hash = designated_requirement_hash; self.refresh_platform_status() } @@ -1148,7 +1431,7 @@ impl InputSource for MacosScreenCaptureInput { ), demand.requested_hz(), ); - resolve_macos_publication_branch(&source, &calibrated) + resolve_macos_publication_branch_with_telemetry(&source, &calibrated, &self.telemetry) } fn owns_screen_publication_source(&self, source_id: &CaptureSourceId) -> bool { @@ -1293,9 +1576,19 @@ impl InputSource for MacosScreenCaptureInput { } } +#[cfg(all(test, feature = "macos-capture-fixtures"))] fn resolve_macos_publication_branch( source: &MacosPublicationSource, demand: &RegisteredScreenBranchDemand, +) -> anyhow::Result> { + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + resolve_macos_publication_branch_with_telemetry(source, demand, &telemetry) +} + +fn resolve_macos_publication_branch_with_telemetry( + source: &MacosPublicationSource, + demand: &RegisteredScreenBranchDemand, + telemetry: &Arc, ) -> anyhow::Result> { let selector = demand.request().selector(); if !source.matches_selector(selector) { @@ -1307,6 +1600,7 @@ fn resolve_macos_publication_branch( demand.request().executor(), ScreenPublicationExecutorRequest::Cpu ) { + telemetry.set_cpu(); return Ok(Some(demand.resolve_with_color_capabilities( &source.cpu_source(selector), capabilities, @@ -1316,20 +1610,29 @@ fn resolve_macos_publication_branch( let ScreenPublicationExecutorRequest::SourceNative(target) = demand.request().executor() else { unreachable!("screen publication executor requests are exhaustive"); }; - if target.accepted_api() == &PlatformGpuApi::Metal - && let Ok(native_source) = - source.gpu_source(selector.clone(), target.physical_gpu_device().clone()) - && let Ok(resolved) = demand.resolve_with_executor_capabilities( + if target.accepted_api() != &PlatformGpuApi::Metal { + telemetry.set_cpu_fallback("target_api_not_metal"); + } else if let Ok(native_source) = + source.gpu_source(selector.clone(), target.physical_gpu_device().clone()) + { + if let Ok(resolved) = demand.resolve_with_executor_capabilities( &native_source, ScreenExecutorColorCapabilities::new(capabilities, target.color_capabilities()), - ) - && matches!( - resolved.descriptor().executor(), - ScreenPublicationExecutor::SourceNative(_) - ) - && MacosNativeTargetManifest::new(resolved.descriptor()).is_ok() - { - return Ok(Some(resolved)); + ) { + if matches!( + resolved.descriptor().executor(), + ScreenPublicationExecutor::SourceNative(_) + ) && MacosNativeTargetManifest::new(resolved.descriptor()).is_ok() + { + telemetry.set_native(); + return Ok(Some(resolved)); + } + telemetry.set_cpu_fallback("native_contract_unavailable"); + } else { + telemetry.set_cpu_fallback("native_descriptor_incompatible"); + } + } else { + telemetry.set_cpu_fallback("metal_device_mismatch"); } Ok(Some(demand.resolve_with_color_capabilities( @@ -1777,11 +2080,32 @@ fn handle_exact_commands( } } +fn update_pinned_generations( + runtimes: &[MacosExactRuntime], + telemetry: &MacosScreenRuntimeTelemetry, +) { + let current = runtimes + .iter() + .map(|runtime| runtime.source.resource_generation) + .max(); + let mut retained = runtimes + .iter() + .filter(|runtime| Some(runtime.source.resource_generation) != current) + .map(|runtime| runtime.source.resource_generation) + .collect::>(); + retained.sort_unstable(); + retained.dedup(); + telemetry + .pinned_generations + .store(retained.len(), Ordering::Release); +} + fn run_worker( mut prepared: PreparedWorker, mailbox: MacosFrameMailbox, publication: Arc>, exact: Arc, + telemetry: Arc, worker_generation: u64, target_fps: u32, status_session: SourceSessionSlot, @@ -1794,6 +2118,7 @@ fn run_worker( let mut exact_runtimes = Vec::new(); while !stop.load(Ordering::Acquire) { handle_exact_commands(&command_rx, &mut exact_runtimes, &exact); + update_pinned_generations(&exact_runtimes, &telemetry); let Some(delivery) = mailbox.wait_latest_while(WORKER_WAIT, || !stop.load(Ordering::Acquire)) else { @@ -1809,6 +2134,7 @@ fn run_worker( &mut resources, &publication, &exact, + &telemetry, &mut exact_runtimes, worker_generation, target_fps, @@ -1827,6 +2153,7 @@ fn run_worker( exact.replace_source(None); exact.clear_owned_sources(); exact_runtimes.clear(); + telemetry.pinned_generations.store(0, Ordering::Release); prepared.analyzer.stop(); Ok(()) } @@ -1840,6 +2167,7 @@ fn publish_frame( resources: &mut ResourceState, publication: &Mutex, exact: &MacosExactPublicationShared, + telemetry: &Arc, exact_runtimes: &mut [MacosExactRuntime], worker_generation: u64, target_fps: u32, @@ -1853,6 +2181,10 @@ fn publish_frame( 2_000_000_000_u64.div_ceil(u64::from(target_fps)), )) .ok_or_else(|| anyhow!("macOS capture freshness deadline overflow"))?; + if Instant::now() > fresh_until { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } let topology_generation = topology.observe(&frame)?; let resource_generation = resources.observe(&frame)?; let source = MacosPublicationSource::from_frame( @@ -1862,18 +2194,28 @@ fn publish_frame( &frame, )?; exact.replace_source(Some(source.clone())); - let exact_delivery = publish_macos_native_exact( + let exact_delivery = publish_macos_native_exact_with_telemetry( &frame, captured_at, fresh_until, &source, exact, exact_runtimes, + telemetry, )?; + if exact_delivery.stale { + return Ok(()); + } if exact_delivery.cpu { + let reduction_started = Instant::now(); let capture = native_cpu_capture_frame(&frame, captured_at, fresh_until, &source, source_id.clone())?; - publish_macos_scalar_exact(&frame, &capture, &source, exact, exact_runtimes)?; + if Instant::now() > fresh_until { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + publish_macos_scalar_exact(&frame, &capture, &source, exact, exact_runtimes, telemetry)?; + telemetry.record_cpu_reduction(reduction_started.elapsed()); } if exact_delivery.native && !exact_delivery.cpu { if lock(publication).worker_generation == worker_generation { @@ -1955,10 +2297,21 @@ fn publish_frame( )), damage, )?; + if Instant::now() > fresh_until { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } if !exact_delivery.cpu { - publish_macos_cpu_exact(&capture, &source, exact, exact_runtimes)?; + publish_macos_cpu_exact(&capture, &source, exact, exact_runtimes, telemetry)?; + } + let reduction_started = Instant::now(); + let snapshot = analyze_screen_frame(&mut prepared.analyzer, capture); + telemetry.record_cpu_reduction(reduction_started.elapsed()); + let snapshot = snapshot?; + if Instant::now() > fresh_until { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(()); } - let snapshot = analyze_screen_frame(&mut prepared.analyzer, capture)?; if snapshot.geometry_frame().metadata().topology_generation != topology_generation { return Err(anyhow!("macOS analysis changed topology generation")); } @@ -2043,8 +2396,10 @@ fn native_cpu_capture_frame( struct MacosExactDelivery { native: bool, cpu: bool, + stale: bool, } +#[cfg(all(test, feature = "macos-capture-fixtures"))] fn publish_macos_native_exact( frame: &Arc, captured_at: Instant, @@ -2052,6 +2407,27 @@ fn publish_macos_native_exact( source: &MacosPublicationSource, exact: &MacosExactPublicationShared, runtimes: &mut [MacosExactRuntime], +) -> anyhow::Result { + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + publish_macos_native_exact_with_telemetry( + frame, + captured_at, + fresh_until, + source, + exact, + runtimes, + &telemetry, + ) +} + +fn publish_macos_native_exact_with_telemetry( + frame: &Arc, + captured_at: Instant, + fresh_until: Instant, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + telemetry: &Arc, ) -> anyhow::Result { let Some(hub) = exact.hub() else { return Ok(MacosExactDelivery::default()); @@ -2063,10 +2439,15 @@ fn publish_macos_native_exact( let delivery = MacosExactDelivery { native: !runtime.native_routes.is_empty(), cpu: runtime.fanout.is_some(), + stale: false, }; let published_at = Instant::now(); if published_at > fresh_until { - return Ok(delivery); + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(MacosExactDelivery { + stale: true, + ..delivery + }); } let native_sequence = frame .sequence @@ -2091,7 +2472,8 @@ fn publish_macos_native_exact( source.geometry.storage_extent(), route.descriptor.source_pixel_format(), Arc::clone(frame), - )?; + )? + .with_timing_sink(Arc::clone(telemetry)); let surface = route .target .retain_on_surface_with_capture_allocation(surface, route.capture_lifetime.clone())?; @@ -2119,6 +2501,9 @@ fn publish_macos_native_exact( }; match hub.publish(publisher, payload, &metadata) { Ok(_) => { + telemetry + .publication_plan_generation + .store(publisher.plan_generation().get(), Ordering::Release); route.last_accepted_sequence = Some(frame.sequence); route.next_publish_at = route .pacer @@ -2136,6 +2521,7 @@ fn publish_macos_cpu_exact( source: &MacosPublicationSource, exact: &MacosExactPublicationShared, runtimes: &mut [MacosExactRuntime], + telemetry: &MacosScreenRuntimeTelemetry, ) -> anyhow::Result<()> { let Some(hub) = exact.hub() else { return Ok(()); @@ -2146,6 +2532,9 @@ fn publish_macos_cpu_exact( return Ok(()); }; if let Some(fanout) = runtime.fanout.as_mut() { + telemetry + .publication_plan_generation + .store(fanout.plan_generation().get(), Ordering::Release); fanout.publish_due( &hub, Some(frame), @@ -2162,6 +2551,7 @@ fn publish_macos_scalar_exact( source: &MacosPublicationSource, exact: &MacosExactPublicationShared, runtimes: &mut [MacosExactRuntime], + telemetry: &MacosScreenRuntimeTelemetry, ) -> anyhow::Result<()> { let Some(hub) = exact.hub() else { return Ok(()); @@ -2172,6 +2562,9 @@ fn publish_macos_scalar_exact( return Ok(()); }; if let Some(fanout) = runtime.fanout.as_mut() { + telemetry + .publication_plan_generation + .store(fanout.plan_generation().get(), Ordering::Release); fanout.publish_due_scalar( &hub, frame, @@ -2455,6 +2848,22 @@ fn map_selection(selection: MacosCaptureSelection) -> MacosSelectionState { } } +fn selection_diagnostic_label(selection: MacosCaptureSelection) -> Option> { + match selection { + MacosCaptureSelection::None => None, + MacosCaptureSelection::Display { .. } => Some(Arc::from("display")), + MacosCaptureSelection::SessionScoped { content_style } => { + Some(Arc::from(match content_style { + MacosCaptureContentStyle::Window => "window", + MacosCaptureContentStyle::MultipleWindows => "multiple_windows", + MacosCaptureContentStyle::Application => "application", + MacosCaptureContentStyle::MultipleApplications => "multiple_applications", + MacosCaptureContentStyle::Mixed => "mixed", + })) + } + } +} + fn map_tahoe_selection_capabilities( capabilities: NativeTahoeSelectionCapabilities, ) -> MacosTahoeSelectionCapabilities { @@ -2481,6 +2890,96 @@ fn map_tahoe_capabilities( } } +const fn executable_architecture() -> MacosArchitecture { + #[cfg(target_arch = "aarch64")] + { + MacosArchitecture::AppleSilicon + } + #[cfg(not(target_arch = "aarch64"))] + { + MacosArchitecture::Intel + } +} + +const fn stream_state_name(state: NativeProtectedSourceState) -> &'static str { + match state { + NativeProtectedSourceState::Starting | NativeProtectedSourceState::Live => "active", + NativeProtectedSourceState::Interrupted + | NativeProtectedSourceState::Revoked + | NativeProtectedSourceState::Failed => "stopped", + NativeProtectedSourceState::Disabled + | NativeProtectedSourceState::NeedsUserAction + | NativeProtectedSourceState::PermissionDenied + | NativeProtectedSourceState::NeedsProcessRestart + | NativeProtectedSourceState::NeedsSelection + | NativeProtectedSourceState::ReadyIdle => "inactive", + } +} + +const fn nonzero_telemetry(value: u64) -> Option { + if value == 0 { None } else { Some(value) } +} + +const fn pixel_format_name(format: MacosCapturePixelFormat) -> &'static str { + match format { + MacosCapturePixelFormat::Bgra8 => "bgra8", + MacosCapturePixelFormat::Argb2101010 => "argb2101010", + MacosCapturePixelFormat::Rgba16Float => "rgba16_float", + MacosCapturePixelFormat::Yuv420VideoRange => "yuv420_video_range", + MacosCapturePixelFormat::Yuv420FullRange => "yuv420_full_range", + MacosCapturePixelFormat::Yuv44410BiPlanar => "yuv44410_biplanar", + } +} + +const fn dynamic_range_name(range: CaptureDynamicRange) -> &'static str { + match range { + CaptureDynamicRange::Standard => "standard", + CaptureDynamicRange::High => "high", + } +} + +const fn color_space_name(space: CaptureColorSpace) -> &'static str { + match space { + CaptureColorSpace::Srgb => "srgb", + CaptureColorSpace::DisplayP3 => "display_p3", + CaptureColorSpace::Rec2020 => "rec2020", + CaptureColorSpace::Unknown => "unknown", + } +} + +const fn transfer_function_name(function: CaptureTransferFunction) -> &'static str { + match function { + CaptureTransferFunction::Srgb => "srgb", + CaptureTransferFunction::Linear => "linear", + CaptureTransferFunction::Rec709 => "rec709", + CaptureTransferFunction::Rec2020 => "rec2020", + CaptureTransferFunction::Pq => "pq", + CaptureTransferFunction::Hlg => "hlg", + CaptureTransferFunction::Unknown => "unknown", + } +} + +fn frame_drop_counters(diagnostics: MacosCaptureCallbackDiagnostics) -> Arc<[(Arc, u64)]> { + MacosFrameDropReason::ALL + .into_iter() + .map(|reason| { + let name = match reason { + MacosFrameDropReason::InvalidSample => "invalid_sample", + MacosFrameDropReason::DataNotReady => "data_not_ready", + MacosFrameDropReason::UnexpectedOutput => "unexpected_output", + MacosFrameDropReason::Attachment => "attachment", + MacosFrameDropReason::UnsupportedFormat => "unsupported_format", + MacosFrameDropReason::ColorMetadata => "color_metadata", + MacosFrameDropReason::Surface => "surface", + MacosFrameDropReason::Validation => "validation", + MacosFrameDropReason::Resource => "resource", + }; + (Arc::from(name), diagnostics.dropped(reason)) + }) + .collect::>() + .into() +} + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() @@ -2497,6 +2996,7 @@ struct FixtureControl { tahoe_selection: Mutex>, host_capabilities: Mutex, captured_at: Mutex>, + diagnostics: Mutex, } #[cfg(feature = "macos-capture-fixtures")] @@ -2520,6 +3020,7 @@ impl Default for FixtureControl { }, )), captured_at: Mutex::new(None), + diagnostics: Mutex::new(MacosCaptureCallbackDiagnostics::default()), } } } @@ -2579,6 +3080,10 @@ impl MacosCaptureControl for FixtureControl { } } + fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + *lock(&self.diagnostics) + } + fn captured_at(&self, _display_time: u64) -> anyhow::Result { Ok(lock(&self.captured_at).take().unwrap_or_else(Instant::now)) } @@ -2605,6 +3110,10 @@ impl MacosScreenCaptureFixture { pub fn publish(&self, frame: MacosCaptureFrame) { *lock(&self.control.status) = NativeProtectedSourceState::Live; + let mut diagnostics = lock(&self.control.diagnostics); + diagnostics.frames_received = diagnostics.frames_received.saturating_add(1); + diagnostics.frames_published = diagnostics.frames_published.saturating_add(1); + drop(diagnostics); self.control .mailbox .publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); @@ -2674,9 +3183,14 @@ mod tests { fn capture_pool_rebases_before_exposing_an_observed_surface() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); - let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) - .expect("conservative queue quote should fit"); - let initial = pool.lease.bytes(); + let mut pool = MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 100, + 32, + ) + .expect("conservative queue quote should fit"); + let initial = pool.lifetime.bytes(); assert!(initial >= 8 * 100 + 32); let first = pool @@ -2693,8 +3207,13 @@ mod tests { fn capture_pool_collapses_to_exact_sum_after_all_slots_are_observed() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); - let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 128, 64) - .expect("conservative queue quote should fit"); + let mut pool = MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 128, + 64, + ) + .expect("conservative queue quote should fit"); let allocations = [112_u64, 128, 144, 160, 176, 192, 208, 224]; for (index, allocation) in allocations.into_iter().enumerate() { pool.observe( @@ -2706,7 +3225,7 @@ mod tests { let exact_sum: u64 = allocations.into_iter().sum(); assert_eq!(pool.exact_observed_pool_bytes(), exact_sum); assert_eq!(pool.reservation_variance(), 0); - assert_eq!(pool.lease.bytes(), pool.metadata_bytes() + exact_sum); + assert_eq!(pool.lifetime.bytes(), pool.metadata_bytes() + exact_sum); } #[cfg(target_os = "macos")] @@ -2714,8 +3233,13 @@ mod tests { fn capture_pool_rejects_larger_surface_without_recording_or_rebasing() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_200, 1_200)); - let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) - .expect("conservative queue quote should fit"); + let mut pool = MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 100, + 32, + ) + .expect("conservative queue quote should fit"); let reserved_before = coordinator.snapshot().reserved_bytes(); assert!(matches!( @@ -2723,7 +3247,7 @@ mod tests { Err(hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { .. }) )); assert_eq!(pool.exact_observed_pool_bytes(), 0); - assert_eq!(pool.lease.bytes(), reserved_before); + assert_eq!(pool.lifetime.bytes(), reserved_before); assert_eq!(coordinator.snapshot().reserved_bytes(), reserved_before); } @@ -2732,8 +3256,13 @@ mod tests { fn retained_surface_lifetime_keeps_the_pool_admitted_after_stream_drop() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); - let mut pool = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) - .expect("conservative queue quote should fit"); + let mut pool = MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 100, + 32, + ) + .expect("conservative queue quote should fit"); let retained = pool .observe(1, 120) .expect("first exact pool observation should fit"); @@ -2750,15 +3279,25 @@ mod tests { fn candidate_pool_reserves_alongside_a_pinned_old_generation() { let coordinator = ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(2_000, 2_000)); - let mut old = MacosCapturePoolAdmission::reserve(&coordinator, 100, 32) - .expect("old stream quote should fit"); + let mut old = MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 100, + 32, + ) + .expect("old stream quote should fit"); let pinned = old .observe(1, 120) .expect("old stream observation should fit"); drop(old); assert!(matches!( - MacosCapturePoolAdmission::reserve(&coordinator, 100, 32), + MacosCapturePoolAdmission::reserve( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + 100, + 32, + ), Err(hypercolor_macos_capture::MacosCaptureError::ScreenResourceExhausted { .. }) )); assert_eq!(coordinator.snapshot().reserved_bytes(), pinned.bytes()); @@ -4124,6 +4663,7 @@ mod tests { assert_eq!(surface.extent(), source.geometry.storage_extent()); assert_eq!(payload.colorimetry().value(), source.colorimetry); assert!(surface.owner::().is_some()); + assert!(surface.timing_sink().is_some()); assert!(surface.retained_owner::().is_some()); assert!(surface.resource_lifetime().is_some()); assert!(surface.capture_resource_lifetime().is_some()); @@ -4317,6 +4857,7 @@ mod tests { &native_source, &exact, &mut runtimes, + &MacosScreenRuntimeTelemetry::default(), ) .is_err() ); diff --git a/crates/hypercolor-core/src/input/screen/mod.rs b/crates/hypercolor-core/src/input/screen/mod.rs index c707ce0e2..1caf321cd 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -76,8 +76,8 @@ pub use frame::{ CapturePlanePool, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureStageKind, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, GeometryNormalizedCaptureSurface, KnownCaptureColorimetry, MoveRegion, PhysicalOrigin, PixelExtent, PixelRect, PlatformGpuApi, - PlatformGpuSurface, PlatformGpuSurfaceOwner, PooledCapturePlane, RawCaptureSurface, - SourceScale, + PlatformGpuSurface, PlatformGpuSurfaceOwner, PlatformGpuSurfaceTimingSink, PooledCapturePlane, + RawCaptureSurface, SourceScale, }; pub use hub::{ PreparedScreenPublication, ScreenBranchDeliveryLifecycle, ScreenBranchDeliveryState, diff --git a/crates/hypercolor-core/src/input/status.rs b/crates/hypercolor-core/src/input/status.rs index 9af013aa2..183e92f4c 100644 --- a/crates/hypercolor-core/src/input/status.rs +++ b/crates/hypercolor-core/src/input/status.rs @@ -236,6 +236,38 @@ pub struct MacosInputPlatformStatus { pub pointer_owner: MacosCapabilityOwner, /// Latest daemon-owner conflict, when one exists. pub owner_conflict: Option>, + /// Age anchor for the latest observed Input Monitoring transition. + pub authorization_last_transition_at: Option, + /// Designated-requirement hash when the owning process exposes one. + pub owner_designated_requirement_hash: Option>, + /// Native host architecture when the process-stable probe succeeded. + pub host_architecture: Option, + /// Architecture of the running executable slice. + pub executable_architecture: MacosArchitecture, + /// Whether the process runs under Rosetta when the probe succeeded. + pub translated_process: Option, + /// Native capture epoch, absent before a session starts. + pub capture_session_generation: Option, + /// Display topology generation observed by pointer capture. + pub topology_generation: Option, + /// Fixed native event-queue capacity for the active session. + pub queue_capacity: Option, + /// Current number of native events awaiting delivery. + pub queue_depth: Option, + /// Native input events offered to the bounded queue. + pub input_events_received: Option, + /// Native input events and ordered gaps delivered to core. + pub input_events_published: Option, + /// Native input events rejected by queue pressure. + pub input_events_dropped: Option, + /// Event-tap disables caused by callback timeout. + pub tap_disabled_timeout: Option, + /// Event-tap disables caused by user input. + pub tap_disabled_user_input: Option, + /// Successful event-tap reenable attempts. + pub tap_reenabled: Option, + /// Ordered state gaps observed across native and core folding. + pub state_gaps: Option, } /// Platform detail for the macOS screen-capture adapter. @@ -249,12 +281,94 @@ pub struct MacosScreenPlatformStatus { pub owner: MacosCapabilityOwner, /// Current system-picker selection. pub selection: MacosSelectionState, + /// Privacy-safe bounded label for the selected content style. + pub selection_diagnostic_label: Option>, /// Process-stable Tahoe host and active Metal-device capabilities. pub tahoe: MacosTahoeCapabilities, /// Tahoe capabilities for the active selected stream. pub tahoe_selection: Option, /// Latest daemon-owner conflict, when one exists. pub owner_conflict: Option>, + /// Age anchor for the latest observed Screen Recording transition. + pub authorization_last_transition_at: Option, + /// Designated-requirement hash when the owning process exposes one. + pub owner_designated_requirement_hash: Option>, + /// Architecture of the running executable slice. + pub executable_architecture: MacosArchitecture, + /// Bounded native stream state. + pub stream_state: Arc, + /// ScreenCaptureKit stream generation from the latest accepted frame. + pub capture_session_generation: Option, + /// Geometry generation from the latest accepted frame. + pub topology_generation: Option, + /// Native resource generation from the latest accepted frame. + pub resource_generation: Option, + /// Publication plan generation used by the latest exact path. + pub publication_plan_generation: Option, + /// Bounded native pixel-format name. + pub pixel_format: Option>, + /// Bounded dynamic-range name. + pub dynamic_range: Option>, + /// Bounded color-space name. + pub color_space: Option>, + /// Bounded transfer-function name. + pub transfer_function: Option>, + /// Exact display scale encoded as `f64::to_bits`. + pub display_scale_bits: Option, + /// Exact native surface width. + pub native_width: Option, + /// Exact native surface height. + pub native_height: Option, + /// Configured ScreenCaptureKit queue depth. + pub queue_depth: usize, + /// Bytes currently admitted by the shared screen resource fence. + pub admitted_native_bytes: u64, + /// Retained old resource generations, when the backend can distinguish them. + pub pinned_generations: Option, + /// Native callback frames received. + pub frames_received: u64, + /// Native callback frames published after validation. + pub frames_published: u64, + /// Latest-value deliveries superseded before consumption. + pub frames_superseded: u64, + /// Native frames rejected for malformed attachment data. + pub frames_malformed: u64, + /// Malformed or rejected native frames grouped by bounded reason. + pub frames_dropped: Arc<[(Arc, u64)]>, + /// Frames rejected after their freshness deadline. + pub frames_stale: u64, + /// Active bounded publication path after a route has resolved. + pub publication_path: Option>, + /// Exact bounded reason for falling back from native publication. + pub fallback_reason: Option>, + /// Total callback execution time measured by the native boundary. + pub callback_total_ns: u64, + /// Maximum callback execution time measured by the native boundary. + pub callback_max_ns: u64, + /// Total native surface validation and retain time. + pub retain_total_ns: u64, + /// Maximum native surface validation and retain time. + pub retain_max_ns: u64, + /// Total native frame conversion time. + pub conversion_total_ns: u64, + /// Maximum native frame conversion time. + pub conversion_max_ns: u64, + /// Total CPU reduction time measured by core. + pub cpu_reduction_total_ns: u64, + /// Maximum CPU reduction time measured by core. + pub cpu_reduction_max_ns: u64, + /// Total native IOSurface import time measured by the renderer. + pub native_import_total_ns: u64, + /// Maximum native IOSurface import time measured by the renderer. + pub native_import_max_ns: u64, + /// Total native reduction encode and queue-submission time. + pub native_reduction_submit_total_ns: u64, + /// Maximum native reduction encode and queue-submission time. + pub native_reduction_submit_max_ns: u64, + /// Total decoded-frame publication time measured by the native boundary. + pub publication_total_ns: u64, + /// Maximum decoded-frame publication time measured by the native boundary. + pub publication_max_ns: u64, } /// Platform-specific detail attached to a generic input-source status. diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index aa4280f7d..7a72e94d0 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -910,6 +910,7 @@ pub trait InputSource: Send { &mut self, _owner: crate::input::MacosCapabilityOwner, _conflict: Option, + _designated_requirement_hash: Option>, ) -> anyhow::Result<()> { Ok(()) } diff --git a/crates/hypercolor-core/tests/capture_frame_tests.rs b/crates/hypercolor-core/tests/capture_frame_tests.rs index bebe749f0..5bdc771ed 100644 --- a/crates/hypercolor-core/tests/capture_frame_tests.rs +++ b/crates/hypercolor-core/tests/capture_frame_tests.rs @@ -1,6 +1,6 @@ //! Contract tests for the backend-neutral capture frame envelope. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; @@ -10,8 +10,8 @@ use hypercolor_core::input::screen::{ CaptureFrameError, CaptureFrameMetadata, CaptureGeometry, CapturePixelFormat, CapturePlanePool, CaptureRotation, CaptureSourceId, CaptureStageKind, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, KnownCaptureColorimetry, MoveRegion, PhysicalOrigin, PixelExtent, PixelRect, - PlatformGpuApi, PlatformGpuSurface, RawCaptureSurface, ScreenAdmissionCapacity, - ScreenByteAdmissionCoordinator, SourceScale, + PlatformGpuApi, PlatformGpuSurface, PlatformGpuSurfaceTimingSink, RawCaptureSurface, + ScreenAdmissionCapacity, ScreenByteAdmissionCoordinator, SourceScale, }; fn extent(width: u32, height: u32) -> PixelExtent { @@ -405,6 +405,51 @@ impl Drop for GpuLifetimeProbe { } } +#[derive(Default)] +struct GpuTimingProbe { + import_ns: AtomicU64, + reduction_ns: AtomicU64, +} + +impl PlatformGpuSurfaceTimingSink for GpuTimingProbe { + fn record_import(&self, elapsed: Duration) { + self.import_ns.store( + u64::try_from(elapsed.as_nanos()).expect("fixture duration fits u64"), + Ordering::Release, + ); + } + + fn record_native_reduction_submission(&self, elapsed: Duration) { + self.reduction_ns.store( + u64::try_from(elapsed.as_nanos()).expect("fixture duration fits u64"), + Ordering::Release, + ); + } +} + +#[test] +fn gpu_surface_retains_and_forwards_backend_timing_observations() { + let timing = Arc::new(GpuTimingProbe::default()); + let surface = PlatformGpuSurface::new( + PlatformGpuApi::Metal, + 42, + extent(4, 3), + CapturePixelFormat::Bgra8, + Arc::new(()), + ) + .expect("non-zero opaque handle is valid") + .with_timing_sink(Arc::clone(&timing)); + let sink = surface + .timing_sink() + .expect("attached timing sink remains observable"); + + sink.record_import(Duration::from_nanos(17)); + sink.record_native_reduction_submission(Duration::from_nanos(23)); + + assert_eq!(timing.import_ns.load(Ordering::Acquire), 17); + assert_eq!(timing.reduction_ns.load(Ordering::Acquire), 23); +} + #[test] fn gpu_surface_erases_platform_type_but_retains_owner_lifetime() { let dropped = Arc::new(AtomicBool::new(false)); diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index 7df865a44..b5d76ab52 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -51,6 +51,7 @@ struct StatusAwareScreenSource { struct RetainedMacosState { owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, metal4: bool, } @@ -85,10 +86,12 @@ impl InputSource for MacosStateAwareSource { &mut self, owner: MacosCapabilityOwner, conflict: Option, + designated_requirement_hash: Option>, ) -> anyhow::Result<()> { let mut state = self.state.lock().expect("macOS state lock"); state.owner = owner; state.conflict = conflict; + state.designated_requirement_hash = designated_requirement_hash; Ok(()) } @@ -1054,11 +1057,16 @@ fn late_source_inherits_retained_macos_process_state() { let state = Arc::new(Mutex::new(RetainedMacosState { owner: MacosCapabilityOwner::Standalone, conflict: None, + designated_requirement_hash: None, metal4: false, })); let mut manager = InputManager::new(); manager - .set_macos_daemon_ownership(MacosCapabilityOwner::LaunchdService, Some(conflict.clone())) + .set_macos_daemon_ownership( + MacosCapabilityOwner::LaunchdService, + Some(conflict.clone()), + Some(Arc::from("designated-launchd")), + ) .expect("manager retains macOS ownership before registration"); manager .set_macos_metal4_capability(true) @@ -1074,6 +1082,7 @@ fn late_source_inherits_retained_macos_process_state() { RetainedMacosState { owner: MacosCapabilityOwner::LaunchdService, conflict: Some(conflict), + designated_requirement_hash: Some(Arc::from("designated-launchd")), metal4: true, } ); @@ -3365,6 +3374,7 @@ fn source_platform_updates_preserve_lifecycle_and_deduplicate() { tcc: MacosAuthorizationState::Authorized, owner: MacosCapabilityOwner::AppSidecar, selection: MacosSelectionState::None, + selection_diagnostic_label: None, tahoe: MacosTahoeCapabilities { host_architecture: MacosArchitecture::AppleSilicon, translated_process: false, @@ -3373,6 +3383,46 @@ fn source_platform_updates_preserve_lifecycle_and_deduplicate() { }, tahoe_selection: None, owner_conflict: None, + authorization_last_transition_at: None, + owner_designated_requirement_hash: None, + executable_architecture: MacosArchitecture::AppleSilicon, + stream_state: Arc::from("inactive"), + capture_session_generation: None, + topology_generation: None, + resource_generation: None, + publication_plan_generation: None, + pixel_format: None, + dynamic_range: None, + color_space: None, + transfer_function: None, + display_scale_bits: None, + native_width: None, + native_height: None, + queue_depth: 8, + admitted_native_bytes: 0, + pinned_generations: None, + frames_received: 0, + frames_published: 0, + frames_superseded: 0, + frames_malformed: 0, + frames_dropped: Arc::from([]), + frames_stale: 0, + publication_path: Some(Arc::from("cpu")), + fallback_reason: None, + callback_total_ns: 0, + callback_max_ns: 0, + retain_total_ns: 0, + retain_max_ns: 0, + conversion_total_ns: 0, + conversion_max_ns: 0, + cpu_reduction_total_ns: 0, + cpu_reduction_max_ns: 0, + native_import_total_ns: 0, + native_import_max_ns: 0, + native_reduction_submit_total_ns: 0, + native_reduction_submit_max_ns: 0, + publication_total_ns: 0, + publication_max_ns: 0, }); writer diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs index b2c585d59..324b26a31 100644 --- a/crates/hypercolor-core/tests/macos_host_input_tests.rs +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -332,6 +332,8 @@ fn state_gap_synthesizes_releases_and_stale_epoch_is_inert() { #[cfg(feature = "macos-native-fixtures")] mod fixtures { + use std::sync::Arc; + use hypercolor_core::input::{ InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, MacosHostInput, MacosInputFixtureBackend, @@ -497,6 +499,7 @@ mod fixtures { contender: MacosCapabilityOwner::HomebrewService, observed_at_ms: 42, }), + Some(Arc::from("designated-app-sidecar")), ) .expect("owner update should publish"); @@ -514,6 +517,10 @@ mod fixtures { observed_at_ms: 42, }) ); + assert_eq!( + platform.owner_designated_requirement_hash.as_deref(), + Some("designated-app-sidecar") + ); } #[test] @@ -552,5 +559,21 @@ mod fixtures { }; assert_eq!(platform.keyboard_tcc, MacosAuthorizationState::Authorized); assert_eq!(platform.keyboard, MacosProtectedSourceState::ReadyIdle); + assert!(platform.authorization_last_transition_at.is_some()); + assert_eq!( + platform.executable_architecture, + if cfg!(target_arch = "aarch64") { + hypercolor_core::input::MacosArchitecture::AppleSilicon + } else { + hypercolor_core::input::MacosArchitecture::Intel + } + ); + if cfg!(target_os = "macos") { + assert!(platform.host_architecture.is_some()); + assert!(platform.translated_process.is_some()); + } else { + assert_eq!(platform.host_architecture, None); + assert_eq!(platform.translated_process, None); + } } } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index 712b6156a..1400999c0 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -150,6 +150,7 @@ fn fixture_capture_activates_only_for_live_demand() { contender: MacosCapabilityOwner::HomebrewService, observed_at_ms: 42, }), + Some(Arc::from("designated-app-sidecar")), ) .expect("fixture owner status updates"); source @@ -178,10 +179,17 @@ fn fixture_capture_activates_only_for_live_demand() { }) ); assert_eq!(platform.selection, MacosSelectionState::None); + assert_eq!(platform.selection_diagnostic_label, None); assert_eq!(platform.tahoe.host_architecture, MacosArchitecture::Intel); assert!(!platform.tahoe.translated_process); assert!(!platform.tahoe.content_tone_mapping_info); assert!(platform.tahoe.metal4); + assert_eq!(platform.stream_state.as_ref(), "inactive"); + assert_eq!(platform.queue_depth, 8); + assert_eq!(platform.admitted_native_bytes, 0); + assert_eq!(platform.frames_received, 0); + assert_eq!(platform.frames_published, 0); + assert_eq!(platform.publication_path, None); assert!(!fixture.is_active()); source.start().expect("fixture source starts idle"); assert!(matches!(source.sample(), Ok(InputData::None))); @@ -221,12 +229,28 @@ fn fixture_capture_activates_only_for_live_demand() { panic!("expected live macOS screen platform status"); }; assert_eq!(platform.state, CoreProtectedSourceState::Live); + assert_eq!(platform.stream_state.as_ref(), "active"); + assert_eq!(platform.capture_session_generation, Some(1)); + assert_eq!(platform.topology_generation, Some(1)); + assert_eq!(platform.resource_generation, Some(1)); + assert_eq!(platform.pixel_format.as_deref(), Some("bgra8")); + assert_eq!(platform.dynamic_range.as_deref(), Some("standard")); + assert_eq!(platform.color_space.as_deref(), Some("srgb")); + assert_eq!(platform.transfer_function.as_deref(), Some("srgb")); + assert_eq!(platform.native_width, Some(4)); + assert_eq!(platform.native_height, Some(2)); + assert!(platform.frames_received >= 1); + assert!(platform.frames_published >= 1); assert_eq!( platform.selection, MacosSelectionState::Display { source_id: Arc::clone(&source_id), } ); + assert_eq!( + platform.selection_diagnostic_label.as_deref(), + Some("display") + ); let tahoe = platform .tahoe_selection .as_ref() @@ -280,6 +304,46 @@ fn fixture_capture_activates_only_for_live_demand() { assert_eq!(platform.tahoe_selection, None); } +#[test] +fn stale_native_frame_never_enters_the_legacy_cpu_publication() { + let (mut source, fixture) = fixture_source(CaptureConfig { + target_fps: 60, + ..CaptureConfig::default() + }); + let status = source + .source_status_handle() + .expect("macOS fixture exposes status"); + source.start().expect("fixture source starts idle"); + source + .set_screen_capture_demand(ScreenCaptureDemand::active( + PixelExtent::new(4, 2).expect("fixture demand is valid"), + )) + .expect("fixture demand activates"); + fixture.set_selection(MacosCaptureSelection::Display { + source_id: Arc::from("display:stale-fixture"), + }); + fixture.publish_at( + fixture_frame(1, [0, 0, 255, 255]), + Instant::now() + .checked_sub(Duration::from_secs(1)) + .expect("fixture clock has one second of history"), + ); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + assert!(matches!(source.sample(), Ok(InputData::None))); + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = snapshot.platform.as_deref() else { + panic!("expected macOS screen platform status"); + }; + if platform.frames_stale == 1 { + break; + } + assert!(Instant::now() < deadline, "stale frame was not observed"); + thread::yield_now(); + } +} + #[test] fn reconfiguration_fences_the_previous_worker_generation() { let config = CaptureConfig { @@ -368,6 +432,7 @@ fn late_macos_capture_source_inherits_process_capabilities() { .set_macos_daemon_ownership( MacosCapabilityOwner::HomebrewService, Some(conflict.clone()), + Some(Arc::from("designated-homebrew")), ) .expect("manager retains ownership before source registration"); manager @@ -382,5 +447,9 @@ fn late_macos_capture_source_inherits_process_capabilities() { }; assert_eq!(platform.owner, MacosCapabilityOwner::HomebrewService); assert_eq!(platform.owner_conflict.as_deref(), Some(&conflict)); + assert_eq!( + platform.owner_designated_requirement_hash.as_deref(), + Some("designated-homebrew") + ); assert!(platform.tahoe.metal4); } diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index 18ab82bd4..6eaa25d44 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -296,6 +296,109 @@ pub struct MacosTahoeCapabilitiesApiStatus { pub metal4: bool, } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosInputTelemetryApiStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_last_transition_age_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_designated_requirement_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_architecture: Option, + pub executable_architecture: MacosArchitectureApi, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translated_process: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capture_session_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topology_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_capacity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_events_received: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_events_published: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_events_dropped: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tap_disabled_timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tap_disabled_user_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tap_reenabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_gaps: Option, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosFrameDropApiStatus { + pub reason: String, + pub count: u64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosScreenTelemetryApiStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_last_transition_age_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_designated_requirement_hash: Option, + pub executable_architecture: MacosArchitectureApi, + pub stream_state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capture_session_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topology_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publication_plan_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pixel_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_space: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transfer_function: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection_diagnostic_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_scale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_height: Option, + pub queue_depth: usize, + pub admitted_native_bytes: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_generations: Option, + pub frames_received: u64, + pub frames_published: u64, + pub frames_superseded: u64, + pub frames_malformed: u64, + pub frames_dropped: Vec, + pub frames_stale: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publication_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_reason: Option, + pub callback_total_ns: u64, + pub callback_max_ns: u64, + pub retain_total_ns: u64, + pub retain_max_ns: u64, + pub conversion_total_ns: u64, + pub conversion_max_ns: u64, + pub cpu_reduction_total_ns: u64, + pub cpu_reduction_max_ns: u64, + pub native_import_total_ns: u64, + pub native_import_max_ns: u64, + pub native_reduction_submit_total_ns: u64, + pub native_reduction_submit_max_ns: u64, + pub publication_total_ns: u64, + pub publication_max_ns: u64, +} + #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputSourcePlatformStatus { @@ -307,6 +410,7 @@ pub enum InputSourcePlatformStatus { pointer_owner: MacosCapabilityOwnerApi, #[serde(default, skip_serializing_if = "Option::is_none")] owner_conflict: Option, + telemetry: MacosInputTelemetryApiStatus, }, MacosScreen { state: MacosProtectedSourceStateApi, @@ -318,6 +422,7 @@ pub enum InputSourcePlatformStatus { tahoe_selection: Option, #[serde(default, skip_serializing_if = "Option::is_none")] owner_conflict: Option, + telemetry: MacosScreenTelemetryApiStatus, }, } @@ -751,22 +856,28 @@ fn input_source_status(source: &SourceStatus, now: Instant) -> InputSourceStatus platform: source .platform .as_deref() - .and_then(input_source_platform_status), + .and_then(|platform| input_source_platform_status(platform, now)), retired: source.retired, } } fn input_source_platform_status( platform: &SourcePlatformStatus, + now: Instant, ) -> Option { match platform { - SourcePlatformStatus::MacosInput(status) => Some(macos_input_platform_status(status)), - SourcePlatformStatus::MacosScreen(status) => Some(macos_screen_platform_status(status)), + SourcePlatformStatus::MacosInput(status) => Some(macos_input_platform_status(status, now)), + SourcePlatformStatus::MacosScreen(status) => { + Some(macos_screen_platform_status(status, now)) + } _ => None, } } -fn macos_input_platform_status(status: &MacosInputPlatformStatus) -> InputSourcePlatformStatus { +fn macos_input_platform_status( + status: &MacosInputPlatformStatus, + now: Instant, +) -> InputSourcePlatformStatus { InputSourcePlatformStatus::MacosInput { keyboard: macos_protected_source_state(status.keyboard), pointer: macos_protected_source_state(status.pointer), @@ -777,10 +888,36 @@ fn macos_input_platform_status(status: &MacosInputPlatformStatus) -> InputSource .owner_conflict .as_deref() .map(macos_daemon_owner_conflict), + telemetry: MacosInputTelemetryApiStatus { + authorization_last_transition_age_ms: status + .authorization_last_transition_at + .map(|transition| duration_ms(now.saturating_duration_since(transition))), + owner_designated_requirement_hash: status + .owner_designated_requirement_hash + .as_deref() + .map(str::to_owned), + host_architecture: status.host_architecture.map(macos_architecture), + executable_architecture: macos_architecture(status.executable_architecture), + translated_process: status.translated_process, + capture_session_generation: status.capture_session_generation, + topology_generation: status.topology_generation, + queue_capacity: status.queue_capacity, + queue_depth: status.queue_depth, + input_events_received: status.input_events_received, + input_events_published: status.input_events_published, + input_events_dropped: status.input_events_dropped, + tap_disabled_timeout: status.tap_disabled_timeout, + tap_disabled_user_input: status.tap_disabled_user_input, + tap_reenabled: status.tap_reenabled, + state_gaps: status.state_gaps, + }, } } -fn macos_screen_platform_status(status: &MacosScreenPlatformStatus) -> InputSourcePlatformStatus { +fn macos_screen_platform_status( + status: &MacosScreenPlatformStatus, + now: Instant, +) -> InputSourcePlatformStatus { InputSourcePlatformStatus::MacosScreen { state: macos_protected_source_state(status.state), tcc: macos_authorization_state(status.tcc), @@ -795,6 +932,64 @@ fn macos_screen_platform_status(status: &MacosScreenPlatformStatus) -> InputSour .owner_conflict .as_deref() .map(macos_daemon_owner_conflict), + telemetry: MacosScreenTelemetryApiStatus { + authorization_last_transition_age_ms: status + .authorization_last_transition_at + .map(|transition| duration_ms(now.saturating_duration_since(transition))), + owner_designated_requirement_hash: status + .owner_designated_requirement_hash + .as_deref() + .map(str::to_owned), + executable_architecture: macos_architecture(status.executable_architecture), + stream_state: status.stream_state.to_string(), + capture_session_generation: status.capture_session_generation, + topology_generation: status.topology_generation, + resource_generation: status.resource_generation, + publication_plan_generation: status.publication_plan_generation, + pixel_format: status.pixel_format.as_deref().map(str::to_owned), + dynamic_range: status.dynamic_range.as_deref().map(str::to_owned), + color_space: status.color_space.as_deref().map(str::to_owned), + transfer_function: status.transfer_function.as_deref().map(str::to_owned), + selection_diagnostic_label: status + .selection_diagnostic_label + .as_deref() + .map(str::to_owned), + display_scale: status.display_scale_bits.map(f64::from_bits), + native_width: status.native_width, + native_height: status.native_height, + queue_depth: status.queue_depth, + admitted_native_bytes: status.admitted_native_bytes, + pinned_generations: status.pinned_generations, + frames_received: status.frames_received, + frames_published: status.frames_published, + frames_superseded: status.frames_superseded, + frames_malformed: status.frames_malformed, + frames_dropped: status + .frames_dropped + .iter() + .map(|(reason, count)| MacosFrameDropApiStatus { + reason: reason.to_string(), + count: *count, + }) + .collect(), + frames_stale: status.frames_stale, + publication_path: status.publication_path.as_deref().map(str::to_owned), + fallback_reason: status.fallback_reason.as_deref().map(str::to_owned), + callback_total_ns: status.callback_total_ns, + callback_max_ns: status.callback_max_ns, + retain_total_ns: status.retain_total_ns, + retain_max_ns: status.retain_max_ns, + conversion_total_ns: status.conversion_total_ns, + conversion_max_ns: status.conversion_max_ns, + cpu_reduction_total_ns: status.cpu_reduction_total_ns, + cpu_reduction_max_ns: status.cpu_reduction_max_ns, + native_import_total_ns: status.native_import_total_ns, + native_import_max_ns: status.native_import_max_ns, + native_reduction_submit_total_ns: status.native_reduction_submit_total_ns, + native_reduction_submit_max_ns: status.native_reduction_submit_max_ns, + publication_total_ns: status.publication_total_ns, + publication_max_ns: status.publication_max_ns, + }, } } @@ -956,16 +1151,20 @@ fn macos_tahoe_capabilities( capabilities: &MacosTahoeCapabilities, ) -> MacosTahoeCapabilitiesApiStatus { MacosTahoeCapabilitiesApiStatus { - host_architecture: match capabilities.host_architecture { - MacosArchitecture::AppleSilicon => MacosArchitectureApi::AppleSilicon, - MacosArchitecture::Intel => MacosArchitectureApi::Intel, - }, + host_architecture: macos_architecture(capabilities.host_architecture), translated_process: capabilities.translated_process, content_tone_mapping_info: capabilities.content_tone_mapping_info, metal4: capabilities.metal4, } } +const fn macos_architecture(architecture: MacosArchitecture) -> MacosArchitectureApi { + match architecture { + MacosArchitecture::AppleSilicon => MacosArchitectureApi::AppleSilicon, + MacosArchitecture::Intel => MacosArchitectureApi::Intel, + } +} + fn input_source_issue_status(issue: &SourceIssue) -> InputSourceIssueStatus { InputSourceIssueStatus { code: issue.code.to_string(), @@ -2011,6 +2210,22 @@ mod tests { contender: MacosCapabilityOwner::HomebrewService, observed_at_ms: 1_725_000_000_123, })), + authorization_last_transition_at: None, + owner_designated_requirement_hash: None, + host_architecture: Some(MacosArchitecture::AppleSilicon), + executable_architecture: MacosArchitecture::Intel, + translated_process: Some(true), + capture_session_generation: Some(31), + topology_generation: Some(5), + queue_capacity: Some(2_048), + queue_depth: Some(7), + input_events_received: Some(1_000), + input_events_published: Some(990), + input_events_dropped: Some(10), + tap_disabled_timeout: Some(2), + tap_disabled_user_input: Some(1), + tap_reenabled: Some(3), + state_gaps: Some(4), }); let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); let value = serde_json::to_value(status).expect("input status should serialize"); @@ -2028,6 +2243,22 @@ mod tests { "active": "launchd_service", "contender": "homebrew_service", "observed_at_ms": 1_725_000_000_123_u64 + }, + "telemetry": { + "host_architecture": "apple_silicon", + "executable_architecture": "intel", + "translated_process": true, + "capture_session_generation": 31, + "topology_generation": 5, + "queue_capacity": 2048, + "queue_depth": 7, + "input_events_received": 1000, + "input_events_published": 990, + "input_events_dropped": 10, + "tap_disabled_timeout": 2, + "tap_disabled_user_input": 1, + "tap_reenabled": 3, + "state_gaps": 4 } }) ); @@ -2080,6 +2311,7 @@ mod tests { selection: MacosSelectionState::SessionScoped { content_style: Arc::from("multiple_windows"), }, + selection_diagnostic_label: Some(Arc::from("multiple_windows")), tahoe: MacosTahoeCapabilities { host_architecture: MacosArchitecture::AppleSilicon, translated_process: true, @@ -2097,41 +2329,102 @@ mod tests { contender: MacosCapabilityOwner::App, observed_at_ms: 1_725_000_000_456, })), + authorization_last_transition_at: None, + owner_designated_requirement_hash: None, + executable_architecture: MacosArchitecture::Intel, + stream_state: Arc::from("stopped"), + capture_session_generation: Some(29), + topology_generation: Some(3), + resource_generation: Some(8), + publication_plan_generation: Some(13), + pixel_format: Some(Arc::from("rgba16_float")), + dynamic_range: Some(Arc::from("high")), + color_space: Some(Arc::from("display_p3")), + transfer_function: Some(Arc::from("linear")), + display_scale_bits: Some(2.0_f64.to_bits()), + native_width: Some(3_840), + native_height: Some(2_160), + queue_depth: 8, + admitted_native_bytes: 268_435_456, + pinned_generations: Some(2), + frames_received: 120, + frames_published: 116, + frames_superseded: 2, + frames_malformed: 1, + frames_dropped: Arc::from([(Arc::from("validation"), 2)]), + frames_stale: 1, + publication_path: Some(Arc::from("cpu_fallback")), + fallback_reason: Some(Arc::from("native_descriptor_incompatible")), + callback_total_ns: 900, + callback_max_ns: 90, + retain_total_ns: 400, + retain_max_ns: 40, + conversion_total_ns: 700, + conversion_max_ns: 70, + cpu_reduction_total_ns: 1_100, + cpu_reduction_max_ns: 110, + native_import_total_ns: 600, + native_import_max_ns: 60, + native_reduction_submit_total_ns: 800, + native_reduction_submit_max_ns: 80, + publication_total_ns: 500, + publication_max_ns: 50, }); let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); let value = serde_json::to_value(status).expect("screen status should serialize"); assert_eq!(value["active_consumer_count"], 2); - assert_eq!( - value["platform"], - json!({ - "type": "macos_screen", - "state": "interrupted", - "tcc": "denied", - "owner": "standalone", - "selection": { - "type": "session_scoped", - "content_style": "multiple_windows" - }, - "tahoe": { - "host_architecture": "apple_silicon", - "translated_process": true, - "content_tone_mapping_info": true, - "metal4": false - }, - "tahoe_selection": { - "source_id": "session:23", - "capture_session_generation": 29, - "hdr_capture": true, - "dual_range_screenshots": true - }, - "owner_conflict": { - "active": "standalone", - "contender": "app", - "observed_at_ms": 1_725_000_000_456_u64 - } - }) - ); + let platform = &value["platform"]; + assert_eq!(platform["type"], "macos_screen"); + assert_eq!(platform["state"], "interrupted"); + assert_eq!(platform["tcc"], "denied"); + assert_eq!(platform["owner"], "standalone"); + assert_eq!( + platform["selection"], + json!({"type": "session_scoped", "content_style": "multiple_windows"}) + ); + assert_eq!(platform["tahoe"]["host_architecture"], "apple_silicon"); + assert_eq!( + platform["tahoe_selection"]["capture_session_generation"], + 29 + ); + assert_eq!(platform["owner_conflict"]["contender"], "app"); + let telemetry = &platform["telemetry"]; + assert_eq!(telemetry["executable_architecture"], "intel"); + assert_eq!(telemetry["stream_state"], "stopped"); + assert_eq!(telemetry["capture_session_generation"], 29); + assert_eq!(telemetry["topology_generation"], 3); + assert_eq!(telemetry["resource_generation"], 8); + assert_eq!(telemetry["publication_plan_generation"], 13); + assert_eq!(telemetry["pixel_format"], "rgba16_float"); + assert_eq!(telemetry["dynamic_range"], "high"); + assert_eq!(telemetry["color_space"], "display_p3"); + assert_eq!(telemetry["transfer_function"], "linear"); + assert_eq!(telemetry["selection_diagnostic_label"], "multiple_windows"); + assert_eq!(telemetry["display_scale"], 2.0); + assert_eq!(telemetry["native_width"], 3_840); + assert_eq!(telemetry["native_height"], 2_160); + assert_eq!(telemetry["queue_depth"], 8); + assert_eq!(telemetry["admitted_native_bytes"], 268_435_456_u64); + assert_eq!(telemetry["pinned_generations"], 2); + assert_eq!( + telemetry["frames_dropped"], + json!([{"reason": "validation", "count": 2}]) + ); + assert_eq!(telemetry["frames_stale"], 1); + assert_eq!(telemetry["frames_malformed"], 1); + assert_eq!(telemetry["publication_path"], "cpu_fallback"); + assert_eq!( + telemetry["fallback_reason"], + "native_descriptor_incompatible" + ); + assert_eq!(telemetry["callback_total_ns"], 900); + assert_eq!(telemetry["retain_total_ns"], 400); + assert_eq!(telemetry["conversion_total_ns"], 700); + assert_eq!(telemetry["cpu_reduction_total_ns"], 1_100); + assert_eq!(telemetry["native_import_total_ns"], 600); + assert_eq!(telemetry["native_reduction_submit_total_ns"], 800); + assert_eq!(telemetry["publication_total_ns"], 500); } #[test] @@ -2207,6 +2500,9 @@ mod tests { assert!(schemas.contains_key("MacosArchitectureApi")); assert!(schemas.contains_key("MacosTahoeCapabilitiesApiStatus")); assert!(schemas.contains_key("MacosTahoeSelectionCapabilitiesApiStatus")); + assert!(schemas.contains_key("MacosInputTelemetryApiStatus")); + assert!(schemas.contains_key("MacosScreenTelemetryApiStatus")); + assert!(schemas.contains_key("MacosFrameDropApiStatus")); let platform_schema = &schemas["InputSourcePlatformStatus"]; let encoded = serde_json::to_string(platform_schema).expect("schema should encode"); assert!(encoded.contains("macos_input")); diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index 8b08ee57d..3f8f54df4 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -26,6 +26,8 @@ use std::sync::Weak; ))] use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use std::time::Instant; use anyhow::{Context, Result}; #[cfg(test)] @@ -2128,8 +2130,12 @@ impl GpuSparkleFlinger { .downgrade() .upgrade() .context("native macOS capture owner retired before import")?; - let (imported, storage_id) = - bridge.import_frame(&self.device, target_owner.resource_generation, capture)?; + let import_started = Instant::now(); + let imported = bridge.import_frame(&self.device, target_owner.resource_generation, capture); + if let Some(timing_sink) = surface.timing_sink() { + timing_sink.record_import(import_started.elapsed()); + } + let (imported, storage_id) = imported?; anyhow::ensure!( imported.capture().storage_extent.width == surface.extent().width() && imported.capture().storage_extent.height == surface.extent().height(), @@ -2139,6 +2145,8 @@ impl GpuSparkleFlinger { let descriptor = &target_owner.descriptor; let (width, height, storage_id, texture, view) = if requires_work { self.flush_pending_output_submission()?; + let reduction_started = Instant::now(); + let mut submitted_native_reduction = false; let physical = target_owner .physical .as_ref() @@ -2160,11 +2168,12 @@ impl GpuSparkleFlinger { &mut encoder, )?; let _ = self.queue.submit(Some(encoder.finish())); + submitted_native_reduction = true; *physical_sequence = Some(content_generation); } drop(physical_sequence); - if let Some(logical_target) = target_owner.logical_target.as_ref() { + let target = if let Some(logical_target) = target_owner.logical_target.as_ref() { let mut logical_sequence = target_owner .logical_content_sequence .lock() @@ -2189,6 +2198,7 @@ impl GpuSparkleFlinger { &mut encoder, )?; let _ = self.queue.submit(Some(encoder.finish())); + submitted_native_reduction = true; *logical_sequence = Some(content_generation); } ( @@ -2208,7 +2218,11 @@ impl GpuSparkleFlinger { physical.target.texture().clone(), physical.target.view().clone(), ) + }; + if submitted_native_reduction && let Some(timing_sink) = surface.timing_sink() { + timing_sink.record_native_reduction_submission(reduction_started.elapsed()); } + target } else { let extent = descriptor.geometry().output_extent(); anyhow::ensure!( diff --git a/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs index 7e9eecdab..7e92e5798 100644 --- a/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs +++ b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs @@ -27,6 +27,30 @@ enum WatchSignal { Changed, } +#[derive(Clone)] +pub(crate) struct MacosOwnerPublication { + snapshot: MacosOwnerSnapshot, + designated_requirement_hash: Option>, +} + +impl MacosOwnerPublication { + fn from_record(record: &MacosOwnerRecord, startup_snapshot: MacosOwnerSnapshot) -> Self { + Self { + snapshot: snapshot_with_startup_recovery(record, startup_snapshot), + designated_requirement_hash: Some(Arc::from( + record.active_identity.designated_requirement_hash.as_str(), + )), + } + } + + pub(crate) fn without_identity(snapshot: MacosOwnerSnapshot) -> Self { + Self { + snapshot, + designated_requirement_hash: None, + } + } +} + pub(crate) struct PendingMacosOwnerWatch { watcher: RecommendedWatcher, signal_tx: SyncSender, @@ -84,13 +108,13 @@ impl PendingMacosOwnerWatch { pub(crate) fn reconcile_snapshot( &mut self, fallback: MacosOwnerSnapshot, - ) -> Result { + ) -> Result { let Some(record) = self.store.load_owner_record()? else { self.reconciled_fingerprint = None; - return Ok(fallback); + return Ok(MacosOwnerPublication::without_identity(fallback)); }; self.reconciled_fingerprint = Some(MacosOwnerIdentityFingerprint::from(&record)); - Ok(snapshot_with_startup_recovery( + Ok(MacosOwnerPublication::from_record( &record, self.startup_snapshot, )) @@ -132,12 +156,12 @@ impl PendingMacosOwnerWatch { .context("failed to spawn the macOS daemon owner watch worker")?; let publisher = tokio::spawn(async move { while snapshot_rx.changed().await.is_ok() { - let Some(snapshot) = *snapshot_rx.borrow_and_update() else { + let Some(publication) = snapshot_rx.borrow_and_update().clone() else { continue; }; let mut input_manager = input_manager.lock().await; if let Err(error) = - publish_owner_snapshot(&snapshots, &mut input_manager, &event_bus, snapshot) + publish_owner_snapshot(&snapshots, &mut input_manager, &event_bus, publication) { warn!(%error, "failed to publish macOS daemon ownership"); } @@ -194,7 +218,7 @@ fn watch_worker( signal_rx: Receiver, stopping: &AtomicBool, store: &MacosOwnerStore, - snapshots: &tokio::sync::watch::Sender>, + snapshots: &tokio::sync::watch::Sender>, startup_snapshot: MacosOwnerSnapshot, mut fingerprint: Option, ) { @@ -214,19 +238,28 @@ pub(crate) fn publish_owner_snapshot( snapshots: &ArcSwapOption, input_manager: &mut InputManager, event_bus: &HypercolorBus, - snapshot: MacosOwnerSnapshot, + publication: MacosOwnerPublication, ) -> anyhow::Result<()> { - publish_owner_snapshot_with(snapshots, input_manager, snapshot, |published_snapshot| { - event_bus.publish(owner_event(published_snapshot)); - }) + publish_owner_snapshot_with( + snapshots, + input_manager, + publication, + |published_snapshot| { + event_bus.publish(owner_event(published_snapshot)); + }, + ) } fn publish_owner_snapshot_with( snapshots: &ArcSwapOption, input_manager: &mut InputManager, - snapshot: MacosOwnerSnapshot, + publication: MacosOwnerPublication, publish_event: impl FnOnce(MacosOwnerSnapshot), ) -> anyhow::Result<()> { + let MacosOwnerPublication { + snapshot, + designated_requirement_hash, + } = publication; input_manager.set_macos_daemon_ownership( capability_owner(snapshot.active_owner), snapshot.conflict.map(|conflict| MacosDaemonOwnerConflict { @@ -234,6 +267,7 @@ fn publish_owner_snapshot_with( contender: capability_owner(conflict.contender_owner), observed_at_ms: conflict.observed_at_ms, }), + designated_requirement_hash, )?; snapshots.store(Some(Arc::new(snapshot))); publish_event(snapshot); @@ -242,7 +276,7 @@ fn publish_owner_snapshot_with( fn refresh_owner_snapshot( store: &MacosOwnerStore, - snapshots: &tokio::sync::watch::Sender>, + snapshots: &tokio::sync::watch::Sender>, fingerprint: &mut Option, startup_snapshot: MacosOwnerSnapshot, ) -> anyhow::Result<()> { @@ -254,7 +288,7 @@ fn refresh_owner_snapshot( return Ok(()); } *fingerprint = Some(next_fingerprint); - snapshots.send_replace(Some(snapshot_with_startup_recovery( + snapshots.send_replace(Some(MacosOwnerPublication::from_record( &record, startup_snapshot, ))); @@ -419,8 +453,8 @@ mod tests { use std::time::Duration; use super::{ - MacosOwnerIdentityFingerprint, PendingMacosOwnerWatch, enqueue_change, - event_touches_owner_record, owner_event, publish_owner_snapshot_with, + MacosOwnerIdentityFingerprint, MacosOwnerPublication, PendingMacosOwnerWatch, + enqueue_change, event_touches_owner_record, owner_event, publish_owner_snapshot_with, refresh_owner_snapshot, snapshot_with_startup_recovery, watch_worker, }; use crate::macos_owner::{ @@ -466,7 +500,9 @@ mod tests { assert_eq!( snapshot_rx .borrow() + .as_ref() .expect("updated snapshot should remain installed") + .snapshot .conflict .expect("updated snapshot should include conflict") .observed_at_ms, @@ -549,7 +585,10 @@ mod tests { publish_owner_snapshot_with( &snapshots, &mut input_manager, - snapshot, + MacosOwnerPublication { + snapshot, + designated_requirement_hash: Some(Arc::from("deadbeef")), + }, |published_snapshot| { assert_eq!(snapshots.load_full().as_deref(), Some(&published_snapshot)); event_published = true; @@ -594,11 +633,16 @@ mod tests { assert_eq!( reconciled + .snapshot .conflict .expect("reconciled snapshot should include the contender") .observed_at_ms, 42 ); + assert_eq!( + reconciled.designated_requirement_hash.as_deref(), + Some("deadbeef") + ); let record = store .load_owner_record() .expect("owner record should load") diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index 3e7ec4988..275fb7a38 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -326,26 +326,28 @@ impl DaemonState { // ── Input Manager ─────────────────────────────────────────────── #[cfg(target_os = "macos")] - let macos_owner_snapshot = match (pending_macos_owner_watch.as_mut(), macos_owner_snapshot) - { - (Some(watch), Some(snapshot)) => Some( - watch - .reconcile_snapshot(snapshot) - .context("failed to reconcile macOS daemon ownership before source startup")?, - ), - (None, snapshot) => snapshot, - (Some(_), None) => None, - }; + let macos_owner_publication = + match (pending_macos_owner_watch.as_mut(), macos_owner_snapshot) { + (Some(watch), Some(snapshot)) => { + Some(watch.reconcile_snapshot(snapshot).context( + "failed to reconcile macOS daemon ownership before source startup", + )?) + } + (None, snapshot) => { + snapshot.map(super::macos_owner_watch::MacosOwnerPublication::without_identity) + } + (Some(_), None) => None, + }; let (built_input_manager, browser_input) = build_input_manager(config, &config_manager)?; #[cfg(target_os = "macos")] let mut built_input_manager = built_input_manager; #[cfg(target_os = "macos")] - if let Some(snapshot) = macos_owner_snapshot { + if let Some(publication) = macos_owner_publication { super::macos_owner_watch::publish_owner_snapshot( &macos_daemon_ownership, &mut built_input_manager, &event_bus, - snapshot, + publication, )?; } let interaction_routing = InteractionRoutingControl::new( diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs index a181b37e5..7c3c614ed 100644 --- a/crates/hypercolor-macos-capture/src/diagnostics.rs +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -1,4 +1,5 @@ use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; use crate::MacosCaptureError; @@ -109,6 +110,15 @@ pub struct MacosCaptureCallbackDiagnostics { pub frames_published: u64, pub lifecycle_events: u64, pub superseded_deliveries: u64, + pub malformed_frames: u64, + pub callback_total_ns: u64, + pub callback_max_ns: u64, + pub retain_total_ns: u64, + pub retain_max_ns: u64, + pub conversion_total_ns: u64, + pub conversion_max_ns: u64, + pub publication_total_ns: u64, + pub publication_max_ns: u64, dropped: [u64; MacosFrameDropReason::ALL.len()], } @@ -128,10 +138,79 @@ pub(crate) struct CallbackCounters { frames_published: AtomicU64, lifecycle_events: AtomicU64, native_samples_superseded: AtomicU64, + malformed_frames: AtomicU64, + callback_timing: TimingCounters, + retain_timing: TimingCounters, + conversion_timing: TimingCounters, + publication_timing: TimingCounters, dropped: [AtomicU64; MacosFrameDropReason::ALL.len()], } +#[derive(Debug, Default)] +struct TimingCounters { + total_ns: AtomicU64, + max_ns: AtomicU64, +} + +impl TimingCounters { + fn record(&self, elapsed: Duration) { + let nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let _ = self + .total_ns + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| { + Some(total.saturating_add(nanos)) + }); + self.max_ns.fetch_max(nanos, Ordering::Relaxed); + } + + fn snapshot(&self) -> (u64, u64) { + ( + self.total_ns.load(Ordering::Relaxed), + self.max_ns.load(Ordering::Relaxed), + ) + } +} + +pub(crate) struct TimingObservation<'a> { + counters: &'a TimingCounters, + started: Instant, +} + +impl Drop for TimingObservation<'_> { + fn drop(&mut self) { + self.counters.record(self.started.elapsed()); + } +} + impl CallbackCounters { + pub(crate) fn observe_callback(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.callback_timing, + started: Instant::now(), + } + } + + pub(crate) fn observe_retain(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.retain_timing, + started: Instant::now(), + } + } + + pub(crate) fn observe_conversion(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.conversion_timing, + started: Instant::now(), + } + } + + pub(crate) fn observe_publication(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.publication_timing, + started: Instant::now(), + } + } + pub(crate) fn record_received(&self) { self.frames_received.fetch_add(1, Ordering::Relaxed); } @@ -150,17 +229,37 @@ impl CallbackCounters { } pub(crate) fn record_drop(&self, error: &MacosCaptureError) { + if matches!( + error, + MacosCaptureError::MalformedAttachment(_) + | MacosCaptureError::MalformedLuminanceAttachment(_) + ) { + self.malformed_frames.fetch_add(1, Ordering::Relaxed); + } self.dropped[MacosFrameDropReason::from_error(error) as usize] .fetch_add(1, Ordering::Relaxed); } pub(crate) fn snapshot(&self, superseded_deliveries: u64) -> MacosCaptureCallbackDiagnostics { + let (callback_total_ns, callback_max_ns) = self.callback_timing.snapshot(); + let (retain_total_ns, retain_max_ns) = self.retain_timing.snapshot(); + let (conversion_total_ns, conversion_max_ns) = self.conversion_timing.snapshot(); + let (publication_total_ns, publication_max_ns) = self.publication_timing.snapshot(); MacosCaptureCallbackDiagnostics { frames_received: self.frames_received.load(Ordering::Relaxed), frames_published: self.frames_published.load(Ordering::Relaxed), lifecycle_events: self.lifecycle_events.load(Ordering::Relaxed), superseded_deliveries: superseded_deliveries .saturating_add(self.native_samples_superseded.load(Ordering::Relaxed)), + malformed_frames: self.malformed_frames.load(Ordering::Relaxed), + callback_total_ns, + callback_max_ns, + retain_total_ns, + retain_max_ns, + conversion_total_ns, + conversion_max_ns, + publication_total_ns, + publication_max_ns, dropped: std::array::from_fn(|index| self.dropped[index].load(Ordering::Relaxed)), } } @@ -168,6 +267,8 @@ impl CallbackCounters { #[cfg(test)] mod tests { + use std::time::Duration; + use crate::MacosStreamDeliveryRejection; use super::{CallbackCounters, MacosCaptureError, MacosFrameDropReason}; @@ -194,4 +295,23 @@ mod tests { assert_eq!(diagnostics.total_dropped(), 1); assert_eq!(diagnostics.dropped(MacosFrameDropReason::Validation), 1); } + + #[test] + fn malformed_frames_remain_distinct_from_the_bounded_drop_reason() { + let counters = CallbackCounters::default(); + counters.record_drop(&MacosCaptureError::MalformedAttachment("status")); + + let diagnostics = counters.snapshot(0); + assert_eq!(diagnostics.malformed_frames, 1); + assert_eq!(diagnostics.dropped(MacosFrameDropReason::Attachment), 1); + } + + #[test] + fn timing_counters_saturate_totals_and_retain_the_maximum() { + let timing = super::TimingCounters::default(); + timing.record(Duration::from_nanos(40)); + timing.record(Duration::from_nanos(70)); + + assert_eq!(timing.snapshot(), (110, 70)); + } } diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 1b8dd9e36..555bfbdf6 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -360,6 +360,7 @@ fn publish_decoded_result( streams: &Weak, shared: &Arc, ) { + let _timing = shared.counters.observe_publication(); match result { Ok(DecodedSample { event: MacosFrameEvent::Frame(frame), @@ -411,6 +412,7 @@ define_class!( sample_buffer: &CMSampleBuffer, output_type: SCStreamOutputType, ) { + let _callback_timing = self.ivars().shared.counters.observe_callback(); self.ivars().shared.counters.record_received(); if self .ivars() @@ -421,6 +423,7 @@ define_class!( return; } let sample = if output_type == SCStreamOutputType::Screen { + let _retain_timing = self.ivars().shared.counters.observe_retain(); retain_sample( sample_buffer, self.ivars().cursor_composed, @@ -822,13 +825,17 @@ impl NativeStream { let mut decoder = MacosFrameDecoder::new(epoch); let mut delivery_validator = MacosStreamDeliveryValidator::new(configured_stream); delivery_validator.validate_configuration()?; + let decode_shared = Arc::clone(&shared); let worker_shared = Arc::clone(&shared); let worker_streams = streams.clone(); let worker = LatestSampleWorker::spawn( "hypercolor-macos-screen-capture", - move |sample: Result| match sample { - Ok(sample) => decode_sample(&mut decoder, &mut delivery_validator, sample), - Err(error) => Err(classify_delivery_error(&mut delivery_validator, error)), + move |sample: Result| { + let _timing = decode_shared.counters.observe_conversion(); + match sample { + Ok(sample) => decode_sample(&mut decoder, &mut delivery_validator, sample), + Err(error) => Err(classify_delivery_error(&mut delivery_validator, error)), + } }, move |result| { publish_decoded_result(result, epoch, &worker_streams, &worker_shared); diff --git a/crates/hypercolor-macos-input/src/macos.rs b/crates/hypercolor-macos-input/src/macos.rs index 8604ed512..85b6ce309 100644 --- a/crates/hypercolor-macos-input/src/macos.rs +++ b/crates/hypercolor-macos-input/src/macos.rs @@ -214,7 +214,7 @@ impl MacosInputSession { #[must_use] pub fn diagnostics(&self) -> MacosInputDiagnostics { - self.queue.diagnostics().snapshot() + self.queue.diagnostics_snapshot() } /// Stop the run loop, tear down both taps, join their worker, then flush @@ -449,6 +449,7 @@ fn handle_tap_disable(context: &TapContext, reason: MacosInputGapReason) { // SAFETY: the callback runs on the owning run-loop thread while the tap is // retained. Teardown clears this pointer only after removing the source. CGEvent::tap_enable(unsafe { &*tap }, true); + context.queue.diagnostics().record_tap_reenabled(); } fn decode_native_event( diff --git a/crates/hypercolor-macos-input/src/queue.rs b/crates/hypercolor-macos-input/src/queue.rs index 4636b7543..b646a3302 100644 --- a/crates/hypercolor-macos-input/src/queue.rs +++ b/crates/hypercolor-macos-input/src/queue.rs @@ -13,8 +13,14 @@ pub(crate) const DEFAULT_QUEUE_CAPACITY: usize = 2048; #[derive(Default)] pub(crate) struct Diagnostics { + events_received: AtomicU64, + events_published: AtomicU64, dropped_events: AtomicU64, tap_disable_count: AtomicU64, + tap_disabled_timeout: AtomicU64, + tap_disabled_user_input: AtomicU64, + tap_reenabled: AtomicU64, + state_gaps: AtomicU64, unsupported_system_events: AtomicU64, invalid_scroll_phases: AtomicU64, last_point_delta_x: AtomicI64, @@ -23,10 +29,18 @@ pub(crate) struct Diagnostics { } impl Diagnostics { - pub(crate) fn snapshot(&self) -> MacosInputDiagnostics { + fn snapshot(&self, queue_capacity: usize, queue_depth: usize) -> MacosInputDiagnostics { MacosInputDiagnostics { + queue_capacity, + queue_depth, + events_received: self.events_received.load(Ordering::Relaxed), + events_published: self.events_published.load(Ordering::Relaxed), dropped_events: self.dropped_events.load(Ordering::Relaxed), tap_disable_count: self.tap_disable_count.load(Ordering::Relaxed), + tap_disabled_timeout: self.tap_disabled_timeout.load(Ordering::Relaxed), + tap_disabled_user_input: self.tap_disabled_user_input.load(Ordering::Relaxed), + tap_reenabled: self.tap_reenabled.load(Ordering::Relaxed), + state_gaps: self.state_gaps.load(Ordering::Relaxed), unsupported_system_events: self.unsupported_system_events.load(Ordering::Relaxed), invalid_scroll_phases: self.invalid_scroll_phases.load(Ordering::Relaxed), last_point_delta_x: self.last_point_delta_x.load(Ordering::Relaxed), @@ -34,12 +48,30 @@ impl Diagnostics { } } + fn record_received(&self) { + self.events_received.fetch_add(1, Ordering::Relaxed); + } + + fn record_published(&self, count: usize) { + self.events_published + .fetch_add(u64::try_from(count).unwrap_or(u64::MAX), Ordering::Relaxed); + } + pub(crate) fn record_drop(&self) { self.dropped_events.fetch_add(1, Ordering::Relaxed); } pub(crate) fn record_tap_disable(&self, repeated: bool, reason: MacosInputGapReason) { self.tap_disable_count.fetch_add(1, Ordering::Relaxed); + match reason { + MacosInputGapReason::TapDisabledTimeout => { + self.tap_disabled_timeout.fetch_add(1, Ordering::Relaxed); + } + MacosInputGapReason::TapDisabledUserInput => { + self.tap_disabled_user_input.fetch_add(1, Ordering::Relaxed); + } + _ => {} + } if repeated { let encoded = match reason { MacosInputGapReason::TapDisabledTimeout => 1, @@ -50,6 +82,14 @@ impl Diagnostics { } } + pub(crate) fn record_tap_reenabled(&self) { + self.tap_reenabled.fetch_add(1, Ordering::Relaxed); + } + + fn record_gap(&self) { + self.state_gaps.fetch_add(1, Ordering::Relaxed); + } + pub(crate) fn record_unsupported_system_event(&self) { self.unsupported_system_events .fetch_add(1, Ordering::Relaxed); @@ -98,6 +138,10 @@ impl EventQueue { } pub(crate) fn enqueue(&self, event: MacosInputEvent) { + self.diagnostics.record_received(); + if matches!(event, MacosInputEvent::StateGap { .. }) { + self.diagnostics.record_gap(); + } if self.overflowed.load(Ordering::Acquire) { self.diagnostics.record_drop(); return; @@ -110,6 +154,7 @@ impl EventQueue { } pub(crate) fn request_gap(&self, reason: MacosInputGapReason) { + self.diagnostics.record_gap(); self.terminal_gaps .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -138,10 +183,12 @@ impl EventQueue { } pub(crate) fn drain_into(&self, output: &mut Vec) { + let initial_len = output.len(); while let Some(event) = self.events.pop() { output.push(event); } if self.overflowed.swap(false, Ordering::AcqRel) { + self.diagnostics.record_gap(); output.push(MacosInputEvent::StateGap { reason: MacosInputGapReason::QueueOverflow, }); @@ -153,6 +200,8 @@ impl EventQueue { .drain(..) .map(|reason| MacosInputEvent::StateGap { reason }), ); + self.diagnostics + .record_published(output.len().saturating_sub(initial_len)); } pub(crate) fn is_empty(&self) -> bool { @@ -169,6 +218,11 @@ impl EventQueue { &self.diagnostics } + pub(crate) fn diagnostics_snapshot(&self) -> MacosInputDiagnostics { + self.diagnostics + .snapshot(self.events.capacity(), self.events.len()) + } + fn notify(&self) { let _ = self.wake_tx.try_send(()); } @@ -206,7 +260,13 @@ mod tests { reason: MacosInputGapReason::QueueOverflow } ); - assert_eq!(queue.diagnostics().snapshot().dropped_events, 2); + let diagnostics = queue.diagnostics_snapshot(); + assert_eq!(diagnostics.queue_capacity, 2); + assert_eq!(diagnostics.queue_depth, 0); + assert_eq!(diagnostics.events_received, 4); + assert_eq!(diagnostics.events_published, 3); + assert_eq!(diagnostics.dropped_events, 2); + assert_eq!(diagnostics.state_gaps, 1); } #[test] @@ -233,12 +293,21 @@ mod tests { #[test] fn repeated_tap_disable_retains_the_native_reason() { let diagnostics = Diagnostics::default(); + diagnostics.record_tap_disable(false, MacosInputGapReason::TapDisabledTimeout); diagnostics.record_tap_disable(true, MacosInputGapReason::TapDisabledUserInput); + diagnostics.record_tap_reenabled(); assert_eq!( diagnostics.take_repeated_tap_disable(), Some(MacosInputGapReason::TapDisabledUserInput) ); assert_eq!(diagnostics.take_repeated_tap_disable(), None); + let snapshot = diagnostics.snapshot(2_048, 17); + assert_eq!(snapshot.queue_capacity, 2_048); + assert_eq!(snapshot.queue_depth, 17); + assert_eq!(snapshot.tap_disable_count, 2); + assert_eq!(snapshot.tap_disabled_timeout, 1); + assert_eq!(snapshot.tap_disabled_user_input, 1); + assert_eq!(snapshot.tap_reenabled, 1); } } diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs index 9dc50bfbc..fadec1d09 100644 --- a/crates/hypercolor-macos-input/src/shared.rs +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -204,8 +204,16 @@ pub struct MacosInputBatch<'a> { /// Monotonic native diagnostics for one session. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct MacosInputDiagnostics { + pub queue_capacity: usize, + pub queue_depth: usize, + pub events_received: u64, + pub events_published: u64, pub dropped_events: u64, pub tap_disable_count: u64, + pub tap_disabled_timeout: u64, + pub tap_disabled_user_input: u64, + pub tap_reenabled: u64, + pub state_gaps: u64, pub unsupported_system_events: u64, pub invalid_scroll_phases: u64, pub last_point_delta_x: i64, diff --git a/crates/hypercolor-macos-input/src/stubs.rs b/crates/hypercolor-macos-input/src/stubs.rs index e01daf6bc..a952989b2 100644 --- a/crates/hypercolor-macos-input/src/stubs.rs +++ b/crates/hypercolor-macos-input/src/stubs.rs @@ -33,12 +33,7 @@ impl MacosInputSession { #[must_use] pub const fn diagnostics(&self) -> MacosInputDiagnostics { MacosInputDiagnostics { - dropped_events: 0, - tap_disable_count: 0, - unsupported_system_events: 0, - invalid_scroll_phases: 0, - last_point_delta_x: 0, - last_point_delta_y: 0, + ..MacosInputDiagnostics::default() } } diff --git a/python/src/hypercolor/_generated/models/__init__.py b/python/src/hypercolor/_generated/models/__init__.py index 592f2dba3..bdafcf95e 100644 --- a/python/src/hypercolor/_generated/models/__init__.py +++ b/python/src/hypercolor/_generated/models/__init__.py @@ -279,7 +279,10 @@ MacosDaemonOwnerRecoveryRequiredApiStatus, ) from .macos_daemon_ownership_api_status import MacosDaemonOwnershipApiStatus +from .macos_frame_drop_api_status import MacosFrameDropApiStatus +from .macos_input_telemetry_api_status import MacosInputTelemetryApiStatus from .macos_protected_source_state_api import MacosProtectedSourceStateApi +from .macos_screen_telemetry_api_status import MacosScreenTelemetryApiStatus from .macos_selection_state_api_type_0 import MacosSelectionStateApiType0 from .macos_selection_state_api_type_0_type import MacosSelectionStateApiType0Type from .macos_selection_state_api_type_1 import MacosSelectionStateApiType1 @@ -610,7 +613,10 @@ "MacosDaemonOwnerConflictApiStatus", "MacosDaemonOwnerRecoveryRequiredApiStatus", "MacosDaemonOwnershipApiStatus", + "MacosFrameDropApiStatus", + "MacosInputTelemetryApiStatus", "MacosProtectedSourceStateApi", + "MacosScreenTelemetryApiStatus", "MacosSelectionStateApiType0", "MacosSelectionStateApiType0Type", "MacosSelectionStateApiType1", diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py index 9d9428be6..cfa75ecea 100644 --- a/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py @@ -18,6 +18,7 @@ from ..models.macos_daemon_owner_conflict_api_status import ( MacosDaemonOwnerConflictApiStatus, ) + from ..models.macos_input_telemetry_api_status import MacosInputTelemetryApiStatus T = TypeVar("T", bound="InputSourcePlatformStatusType0") @@ -32,6 +33,7 @@ class InputSourcePlatformStatusType0: keyboard_tcc (MacosAuthorizationStateApi): pointer (MacosProtectedSourceStateApi): pointer_owner (MacosCapabilityOwnerApi): + telemetry (MacosInputTelemetryApiStatus): type_ (InputSourcePlatformStatusType0Type): owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): """ @@ -41,6 +43,7 @@ class InputSourcePlatformStatusType0: keyboard_tcc: MacosAuthorizationStateApi pointer: MacosProtectedSourceStateApi pointer_owner: MacosCapabilityOwnerApi + telemetry: MacosInputTelemetryApiStatus type_: InputSourcePlatformStatusType0Type owner_conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -60,6 +63,8 @@ def to_dict(self) -> dict[str, Any]: pointer_owner = self.pointer_owner.value + telemetry = self.telemetry.to_dict() + type_ = self.type_.value owner_conflict: dict[str, Any] | None | Unset @@ -79,6 +84,7 @@ def to_dict(self) -> dict[str, Any]: "keyboard_tcc": keyboard_tcc, "pointer": pointer, "pointer_owner": pointer_owner, + "telemetry": telemetry, "type": type_, } ) @@ -92,6 +98,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.macos_daemon_owner_conflict_api_status import ( MacosDaemonOwnerConflictApiStatus, ) + from ..models.macos_input_telemetry_api_status import ( + MacosInputTelemetryApiStatus, + ) d = dict(src_dict) keyboard = MacosProtectedSourceStateApi(d.pop("keyboard")) @@ -104,6 +113,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: pointer_owner = MacosCapabilityOwnerApi(d.pop("pointer_owner")) + telemetry = MacosInputTelemetryApiStatus.from_dict(d.pop("telemetry")) + type_ = InputSourcePlatformStatusType0Type(d.pop("type")) def _parse_owner_conflict( @@ -133,6 +144,7 @@ def _parse_owner_conflict( keyboard_tcc=keyboard_tcc, pointer=pointer, pointer_owner=pointer_owner, + telemetry=telemetry, type_=type_, owner_conflict=owner_conflict, ) diff --git a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py index 14cfc62aa..e4b350c22 100644 --- a/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py @@ -18,6 +18,7 @@ from ..models.macos_daemon_owner_conflict_api_status import ( MacosDaemonOwnerConflictApiStatus, ) + from ..models.macos_screen_telemetry_api_status import MacosScreenTelemetryApiStatus from ..models.macos_selection_state_api_type_0 import MacosSelectionStateApiType0 from ..models.macos_selection_state_api_type_1 import MacosSelectionStateApiType1 from ..models.macos_selection_state_api_type_2 import MacosSelectionStateApiType2 @@ -41,6 +42,7 @@ class InputSourcePlatformStatusType1: state (MacosProtectedSourceStateApi): tahoe (MacosTahoeCapabilitiesApiStatus): tcc (MacosAuthorizationStateApi): + telemetry (MacosScreenTelemetryApiStatus): type_ (InputSourcePlatformStatusType1Type): owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): tahoe_selection (MacosTahoeSelectionCapabilitiesApiStatus | None | Unset): @@ -55,6 +57,7 @@ class InputSourcePlatformStatusType1: state: MacosProtectedSourceStateApi tahoe: MacosTahoeCapabilitiesApiStatus tcc: MacosAuthorizationStateApi + telemetry: MacosScreenTelemetryApiStatus type_: InputSourcePlatformStatusType1Type owner_conflict: MacosDaemonOwnerConflictApiStatus | None | Unset = UNSET tahoe_selection: MacosTahoeSelectionCapabilitiesApiStatus | None | Unset = UNSET @@ -90,6 +93,8 @@ def to_dict(self) -> dict[str, Any]: tcc = self.tcc.value + telemetry = self.telemetry.to_dict() + type_ = self.type_.value owner_conflict: dict[str, Any] | None | Unset @@ -117,6 +122,7 @@ def to_dict(self) -> dict[str, Any]: "state": state, "tahoe": tahoe, "tcc": tcc, + "telemetry": telemetry, "type": type_, } ) @@ -132,6 +138,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.macos_daemon_owner_conflict_api_status import ( MacosDaemonOwnerConflictApiStatus, ) + from ..models.macos_screen_telemetry_api_status import ( + MacosScreenTelemetryApiStatus, + ) from ..models.macos_selection_state_api_type_0 import ( MacosSelectionStateApiType0, ) @@ -194,6 +203,8 @@ def _parse_selection( tcc = MacosAuthorizationStateApi(d.pop("tcc")) + telemetry = MacosScreenTelemetryApiStatus.from_dict(d.pop("telemetry")) + type_ = InputSourcePlatformStatusType1Type(d.pop("type")) def _parse_owner_conflict( @@ -244,6 +255,7 @@ def _parse_tahoe_selection( state=state, tahoe=tahoe, tcc=tcc, + telemetry=telemetry, type_=type_, owner_conflict=owner_conflict, tahoe_selection=tahoe_selection, diff --git a/python/src/hypercolor/_generated/models/macos_frame_drop_api_status.py b/python/src/hypercolor/_generated/models/macos_frame_drop_api_status.py new file mode 100644 index 000000000..c9d975787 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_frame_drop_api_status.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="MacosFrameDropApiStatus") + + +@_attrs_define +class MacosFrameDropApiStatus: + """ + Attributes: + count (int): + reason (str): + """ + + count: int + reason: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + count = self.count + + reason = self.reason + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "count": count, + "reason": reason, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + count = d.pop("count") + + reason = d.pop("reason") + + macos_frame_drop_api_status = cls( + count=count, + reason=reason, + ) + + macos_frame_drop_api_status.additional_properties = d + return macos_frame_drop_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_input_telemetry_api_status.py b/python/src/hypercolor/_generated/models/macos_input_telemetry_api_status.py new file mode 100644 index 000000000..800937350 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_input_telemetry_api_status.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_architecture_api import MacosArchitectureApi +from ..types import UNSET, Unset + +T = TypeVar("T", bound="MacosInputTelemetryApiStatus") + + +@_attrs_define +class MacosInputTelemetryApiStatus: + """ + Attributes: + executable_architecture (MacosArchitectureApi): + authorization_last_transition_age_ms (int | None | Unset): + capture_session_generation (int | None | Unset): + host_architecture (MacosArchitectureApi | None | Unset): + input_events_dropped (int | None | Unset): + input_events_published (int | None | Unset): + input_events_received (int | None | Unset): + owner_designated_requirement_hash (None | str | Unset): + queue_capacity (int | None | Unset): + queue_depth (int | None | Unset): + state_gaps (int | None | Unset): + tap_disabled_timeout (int | None | Unset): + tap_disabled_user_input (int | None | Unset): + tap_reenabled (int | None | Unset): + topology_generation (int | None | Unset): + translated_process (bool | None | Unset): + """ + + executable_architecture: MacosArchitectureApi + authorization_last_transition_age_ms: int | None | Unset = UNSET + capture_session_generation: int | None | Unset = UNSET + host_architecture: MacosArchitectureApi | None | Unset = UNSET + input_events_dropped: int | None | Unset = UNSET + input_events_published: int | None | Unset = UNSET + input_events_received: int | None | Unset = UNSET + owner_designated_requirement_hash: None | str | Unset = UNSET + queue_capacity: int | None | Unset = UNSET + queue_depth: int | None | Unset = UNSET + state_gaps: int | None | Unset = UNSET + tap_disabled_timeout: int | None | Unset = UNSET + tap_disabled_user_input: int | None | Unset = UNSET + tap_reenabled: int | None | Unset = UNSET + topology_generation: int | None | Unset = UNSET + translated_process: bool | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + executable_architecture = self.executable_architecture.value + + authorization_last_transition_age_ms: int | None | Unset + if isinstance(self.authorization_last_transition_age_ms, Unset): + authorization_last_transition_age_ms = UNSET + else: + authorization_last_transition_age_ms = ( + self.authorization_last_transition_age_ms + ) + + capture_session_generation: int | None | Unset + if isinstance(self.capture_session_generation, Unset): + capture_session_generation = UNSET + else: + capture_session_generation = self.capture_session_generation + + host_architecture: None | str | Unset + if isinstance(self.host_architecture, Unset): + host_architecture = UNSET + elif isinstance(self.host_architecture, MacosArchitectureApi): + host_architecture = self.host_architecture.value + else: + host_architecture = self.host_architecture + + input_events_dropped: int | None | Unset + if isinstance(self.input_events_dropped, Unset): + input_events_dropped = UNSET + else: + input_events_dropped = self.input_events_dropped + + input_events_published: int | None | Unset + if isinstance(self.input_events_published, Unset): + input_events_published = UNSET + else: + input_events_published = self.input_events_published + + input_events_received: int | None | Unset + if isinstance(self.input_events_received, Unset): + input_events_received = UNSET + else: + input_events_received = self.input_events_received + + owner_designated_requirement_hash: None | str | Unset + if isinstance(self.owner_designated_requirement_hash, Unset): + owner_designated_requirement_hash = UNSET + else: + owner_designated_requirement_hash = self.owner_designated_requirement_hash + + queue_capacity: int | None | Unset + if isinstance(self.queue_capacity, Unset): + queue_capacity = UNSET + else: + queue_capacity = self.queue_capacity + + queue_depth: int | None | Unset + if isinstance(self.queue_depth, Unset): + queue_depth = UNSET + else: + queue_depth = self.queue_depth + + state_gaps: int | None | Unset + if isinstance(self.state_gaps, Unset): + state_gaps = UNSET + else: + state_gaps = self.state_gaps + + tap_disabled_timeout: int | None | Unset + if isinstance(self.tap_disabled_timeout, Unset): + tap_disabled_timeout = UNSET + else: + tap_disabled_timeout = self.tap_disabled_timeout + + tap_disabled_user_input: int | None | Unset + if isinstance(self.tap_disabled_user_input, Unset): + tap_disabled_user_input = UNSET + else: + tap_disabled_user_input = self.tap_disabled_user_input + + tap_reenabled: int | None | Unset + if isinstance(self.tap_reenabled, Unset): + tap_reenabled = UNSET + else: + tap_reenabled = self.tap_reenabled + + topology_generation: int | None | Unset + if isinstance(self.topology_generation, Unset): + topology_generation = UNSET + else: + topology_generation = self.topology_generation + + translated_process: bool | None | Unset + if isinstance(self.translated_process, Unset): + translated_process = UNSET + else: + translated_process = self.translated_process + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "executable_architecture": executable_architecture, + } + ) + if authorization_last_transition_age_ms is not UNSET: + field_dict["authorization_last_transition_age_ms"] = ( + authorization_last_transition_age_ms + ) + if capture_session_generation is not UNSET: + field_dict["capture_session_generation"] = capture_session_generation + if host_architecture is not UNSET: + field_dict["host_architecture"] = host_architecture + if input_events_dropped is not UNSET: + field_dict["input_events_dropped"] = input_events_dropped + if input_events_published is not UNSET: + field_dict["input_events_published"] = input_events_published + if input_events_received is not UNSET: + field_dict["input_events_received"] = input_events_received + if owner_designated_requirement_hash is not UNSET: + field_dict["owner_designated_requirement_hash"] = ( + owner_designated_requirement_hash + ) + if queue_capacity is not UNSET: + field_dict["queue_capacity"] = queue_capacity + if queue_depth is not UNSET: + field_dict["queue_depth"] = queue_depth + if state_gaps is not UNSET: + field_dict["state_gaps"] = state_gaps + if tap_disabled_timeout is not UNSET: + field_dict["tap_disabled_timeout"] = tap_disabled_timeout + if tap_disabled_user_input is not UNSET: + field_dict["tap_disabled_user_input"] = tap_disabled_user_input + if tap_reenabled is not UNSET: + field_dict["tap_reenabled"] = tap_reenabled + if topology_generation is not UNSET: + field_dict["topology_generation"] = topology_generation + if translated_process is not UNSET: + field_dict["translated_process"] = translated_process + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + executable_architecture = MacosArchitectureApi(d.pop("executable_architecture")) + + def _parse_authorization_last_transition_age_ms( + data: object, + ) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + authorization_last_transition_age_ms = ( + _parse_authorization_last_transition_age_ms( + d.pop("authorization_last_transition_age_ms", UNSET) + ) + ) + + def _parse_capture_session_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + capture_session_generation = _parse_capture_session_generation( + d.pop("capture_session_generation", UNSET) + ) + + def _parse_host_architecture( + data: object, + ) -> MacosArchitectureApi | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + host_architecture_type_1 = MacosArchitectureApi(data) + + return host_architecture_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosArchitectureApi | None | Unset, data) + + host_architecture = _parse_host_architecture(d.pop("host_architecture", UNSET)) + + def _parse_input_events_dropped(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + input_events_dropped = _parse_input_events_dropped( + d.pop("input_events_dropped", UNSET) + ) + + def _parse_input_events_published(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + input_events_published = _parse_input_events_published( + d.pop("input_events_published", UNSET) + ) + + def _parse_input_events_received(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + input_events_received = _parse_input_events_received( + d.pop("input_events_received", UNSET) + ) + + def _parse_owner_designated_requirement_hash( + data: object, + ) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + owner_designated_requirement_hash = _parse_owner_designated_requirement_hash( + d.pop("owner_designated_requirement_hash", UNSET) + ) + + def _parse_queue_capacity(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + queue_capacity = _parse_queue_capacity(d.pop("queue_capacity", UNSET)) + + def _parse_queue_depth(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + queue_depth = _parse_queue_depth(d.pop("queue_depth", UNSET)) + + def _parse_state_gaps(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + state_gaps = _parse_state_gaps(d.pop("state_gaps", UNSET)) + + def _parse_tap_disabled_timeout(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + tap_disabled_timeout = _parse_tap_disabled_timeout( + d.pop("tap_disabled_timeout", UNSET) + ) + + def _parse_tap_disabled_user_input(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + tap_disabled_user_input = _parse_tap_disabled_user_input( + d.pop("tap_disabled_user_input", UNSET) + ) + + def _parse_tap_reenabled(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + tap_reenabled = _parse_tap_reenabled(d.pop("tap_reenabled", UNSET)) + + def _parse_topology_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + topology_generation = _parse_topology_generation( + d.pop("topology_generation", UNSET) + ) + + def _parse_translated_process(data: object) -> bool | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(bool | None | Unset, data) + + translated_process = _parse_translated_process( + d.pop("translated_process", UNSET) + ) + + macos_input_telemetry_api_status = cls( + executable_architecture=executable_architecture, + authorization_last_transition_age_ms=authorization_last_transition_age_ms, + capture_session_generation=capture_session_generation, + host_architecture=host_architecture, + input_events_dropped=input_events_dropped, + input_events_published=input_events_published, + input_events_received=input_events_received, + owner_designated_requirement_hash=owner_designated_requirement_hash, + queue_capacity=queue_capacity, + queue_depth=queue_depth, + state_gaps=state_gaps, + tap_disabled_timeout=tap_disabled_timeout, + tap_disabled_user_input=tap_disabled_user_input, + tap_reenabled=tap_reenabled, + topology_generation=topology_generation, + translated_process=translated_process, + ) + + macos_input_telemetry_api_status.additional_properties = d + return macos_input_telemetry_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py b/python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py new file mode 100644 index 000000000..7d616520d --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py @@ -0,0 +1,632 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.macos_architecture_api import MacosArchitectureApi +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.macos_frame_drop_api_status import MacosFrameDropApiStatus + + +T = TypeVar("T", bound="MacosScreenTelemetryApiStatus") + + +@_attrs_define +class MacosScreenTelemetryApiStatus: + """ + Attributes: + admitted_native_bytes (int): + callback_max_ns (int): + callback_total_ns (int): + conversion_max_ns (int): + conversion_total_ns (int): + cpu_reduction_max_ns (int): + cpu_reduction_total_ns (int): + executable_architecture (MacosArchitectureApi): + frames_dropped (list[MacosFrameDropApiStatus]): + frames_malformed (int): + frames_published (int): + frames_received (int): + frames_stale (int): + frames_superseded (int): + native_import_max_ns (int): + native_import_total_ns (int): + native_reduction_submit_max_ns (int): + native_reduction_submit_total_ns (int): + publication_max_ns (int): + publication_total_ns (int): + queue_depth (int): + retain_max_ns (int): + retain_total_ns (int): + stream_state (str): + authorization_last_transition_age_ms (int | None | Unset): + capture_session_generation (int | None | Unset): + color_space (None | str | Unset): + display_scale (float | None | Unset): + dynamic_range (None | str | Unset): + fallback_reason (None | str | Unset): + native_height (int | None | Unset): + native_width (int | None | Unset): + owner_designated_requirement_hash (None | str | Unset): + pinned_generations (int | None | Unset): + pixel_format (None | str | Unset): + publication_path (None | str | Unset): + publication_plan_generation (int | None | Unset): + resource_generation (int | None | Unset): + selection_diagnostic_label (None | str | Unset): + topology_generation (int | None | Unset): + transfer_function (None | str | Unset): + """ + + admitted_native_bytes: int + callback_max_ns: int + callback_total_ns: int + conversion_max_ns: int + conversion_total_ns: int + cpu_reduction_max_ns: int + cpu_reduction_total_ns: int + executable_architecture: MacosArchitectureApi + frames_dropped: list[MacosFrameDropApiStatus] + frames_malformed: int + frames_published: int + frames_received: int + frames_stale: int + frames_superseded: int + native_import_max_ns: int + native_import_total_ns: int + native_reduction_submit_max_ns: int + native_reduction_submit_total_ns: int + publication_max_ns: int + publication_total_ns: int + queue_depth: int + retain_max_ns: int + retain_total_ns: int + stream_state: str + authorization_last_transition_age_ms: int | None | Unset = UNSET + capture_session_generation: int | None | Unset = UNSET + color_space: None | str | Unset = UNSET + display_scale: float | None | Unset = UNSET + dynamic_range: None | str | Unset = UNSET + fallback_reason: None | str | Unset = UNSET + native_height: int | None | Unset = UNSET + native_width: int | None | Unset = UNSET + owner_designated_requirement_hash: None | str | Unset = UNSET + pinned_generations: int | None | Unset = UNSET + pixel_format: None | str | Unset = UNSET + publication_path: None | str | Unset = UNSET + publication_plan_generation: int | None | Unset = UNSET + resource_generation: int | None | Unset = UNSET + selection_diagnostic_label: None | str | Unset = UNSET + topology_generation: int | None | Unset = UNSET + transfer_function: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + admitted_native_bytes = self.admitted_native_bytes + + callback_max_ns = self.callback_max_ns + + callback_total_ns = self.callback_total_ns + + conversion_max_ns = self.conversion_max_ns + + conversion_total_ns = self.conversion_total_ns + + cpu_reduction_max_ns = self.cpu_reduction_max_ns + + cpu_reduction_total_ns = self.cpu_reduction_total_ns + + executable_architecture = self.executable_architecture.value + + frames_dropped = [] + for frames_dropped_item_data in self.frames_dropped: + frames_dropped_item = frames_dropped_item_data.to_dict() + frames_dropped.append(frames_dropped_item) + + frames_malformed = self.frames_malformed + + frames_published = self.frames_published + + frames_received = self.frames_received + + frames_stale = self.frames_stale + + frames_superseded = self.frames_superseded + + native_import_max_ns = self.native_import_max_ns + + native_import_total_ns = self.native_import_total_ns + + native_reduction_submit_max_ns = self.native_reduction_submit_max_ns + + native_reduction_submit_total_ns = self.native_reduction_submit_total_ns + + publication_max_ns = self.publication_max_ns + + publication_total_ns = self.publication_total_ns + + queue_depth = self.queue_depth + + retain_max_ns = self.retain_max_ns + + retain_total_ns = self.retain_total_ns + + stream_state = self.stream_state + + authorization_last_transition_age_ms: int | None | Unset + if isinstance(self.authorization_last_transition_age_ms, Unset): + authorization_last_transition_age_ms = UNSET + else: + authorization_last_transition_age_ms = ( + self.authorization_last_transition_age_ms + ) + + capture_session_generation: int | None | Unset + if isinstance(self.capture_session_generation, Unset): + capture_session_generation = UNSET + else: + capture_session_generation = self.capture_session_generation + + color_space: None | str | Unset + if isinstance(self.color_space, Unset): + color_space = UNSET + else: + color_space = self.color_space + + display_scale: float | None | Unset + if isinstance(self.display_scale, Unset): + display_scale = UNSET + else: + display_scale = self.display_scale + + dynamic_range: None | str | Unset + if isinstance(self.dynamic_range, Unset): + dynamic_range = UNSET + else: + dynamic_range = self.dynamic_range + + fallback_reason: None | str | Unset + if isinstance(self.fallback_reason, Unset): + fallback_reason = UNSET + else: + fallback_reason = self.fallback_reason + + native_height: int | None | Unset + if isinstance(self.native_height, Unset): + native_height = UNSET + else: + native_height = self.native_height + + native_width: int | None | Unset + if isinstance(self.native_width, Unset): + native_width = UNSET + else: + native_width = self.native_width + + owner_designated_requirement_hash: None | str | Unset + if isinstance(self.owner_designated_requirement_hash, Unset): + owner_designated_requirement_hash = UNSET + else: + owner_designated_requirement_hash = self.owner_designated_requirement_hash + + pinned_generations: int | None | Unset + if isinstance(self.pinned_generations, Unset): + pinned_generations = UNSET + else: + pinned_generations = self.pinned_generations + + pixel_format: None | str | Unset + if isinstance(self.pixel_format, Unset): + pixel_format = UNSET + else: + pixel_format = self.pixel_format + + publication_path: None | str | Unset + if isinstance(self.publication_path, Unset): + publication_path = UNSET + else: + publication_path = self.publication_path + + publication_plan_generation: int | None | Unset + if isinstance(self.publication_plan_generation, Unset): + publication_plan_generation = UNSET + else: + publication_plan_generation = self.publication_plan_generation + + resource_generation: int | None | Unset + if isinstance(self.resource_generation, Unset): + resource_generation = UNSET + else: + resource_generation = self.resource_generation + + selection_diagnostic_label: None | str | Unset + if isinstance(self.selection_diagnostic_label, Unset): + selection_diagnostic_label = UNSET + else: + selection_diagnostic_label = self.selection_diagnostic_label + + topology_generation: int | None | Unset + if isinstance(self.topology_generation, Unset): + topology_generation = UNSET + else: + topology_generation = self.topology_generation + + transfer_function: None | str | Unset + if isinstance(self.transfer_function, Unset): + transfer_function = UNSET + else: + transfer_function = self.transfer_function + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "admitted_native_bytes": admitted_native_bytes, + "callback_max_ns": callback_max_ns, + "callback_total_ns": callback_total_ns, + "conversion_max_ns": conversion_max_ns, + "conversion_total_ns": conversion_total_ns, + "cpu_reduction_max_ns": cpu_reduction_max_ns, + "cpu_reduction_total_ns": cpu_reduction_total_ns, + "executable_architecture": executable_architecture, + "frames_dropped": frames_dropped, + "frames_malformed": frames_malformed, + "frames_published": frames_published, + "frames_received": frames_received, + "frames_stale": frames_stale, + "frames_superseded": frames_superseded, + "native_import_max_ns": native_import_max_ns, + "native_import_total_ns": native_import_total_ns, + "native_reduction_submit_max_ns": native_reduction_submit_max_ns, + "native_reduction_submit_total_ns": native_reduction_submit_total_ns, + "publication_max_ns": publication_max_ns, + "publication_total_ns": publication_total_ns, + "queue_depth": queue_depth, + "retain_max_ns": retain_max_ns, + "retain_total_ns": retain_total_ns, + "stream_state": stream_state, + } + ) + if authorization_last_transition_age_ms is not UNSET: + field_dict["authorization_last_transition_age_ms"] = ( + authorization_last_transition_age_ms + ) + if capture_session_generation is not UNSET: + field_dict["capture_session_generation"] = capture_session_generation + if color_space is not UNSET: + field_dict["color_space"] = color_space + if display_scale is not UNSET: + field_dict["display_scale"] = display_scale + if dynamic_range is not UNSET: + field_dict["dynamic_range"] = dynamic_range + if fallback_reason is not UNSET: + field_dict["fallback_reason"] = fallback_reason + if native_height is not UNSET: + field_dict["native_height"] = native_height + if native_width is not UNSET: + field_dict["native_width"] = native_width + if owner_designated_requirement_hash is not UNSET: + field_dict["owner_designated_requirement_hash"] = ( + owner_designated_requirement_hash + ) + if pinned_generations is not UNSET: + field_dict["pinned_generations"] = pinned_generations + if pixel_format is not UNSET: + field_dict["pixel_format"] = pixel_format + if publication_path is not UNSET: + field_dict["publication_path"] = publication_path + if publication_plan_generation is not UNSET: + field_dict["publication_plan_generation"] = publication_plan_generation + if resource_generation is not UNSET: + field_dict["resource_generation"] = resource_generation + if selection_diagnostic_label is not UNSET: + field_dict["selection_diagnostic_label"] = selection_diagnostic_label + if topology_generation is not UNSET: + field_dict["topology_generation"] = topology_generation + if transfer_function is not UNSET: + field_dict["transfer_function"] = transfer_function + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.macos_frame_drop_api_status import MacosFrameDropApiStatus + + d = dict(src_dict) + admitted_native_bytes = d.pop("admitted_native_bytes") + + callback_max_ns = d.pop("callback_max_ns") + + callback_total_ns = d.pop("callback_total_ns") + + conversion_max_ns = d.pop("conversion_max_ns") + + conversion_total_ns = d.pop("conversion_total_ns") + + cpu_reduction_max_ns = d.pop("cpu_reduction_max_ns") + + cpu_reduction_total_ns = d.pop("cpu_reduction_total_ns") + + executable_architecture = MacosArchitectureApi(d.pop("executable_architecture")) + + frames_dropped = [] + _frames_dropped = d.pop("frames_dropped") + for frames_dropped_item_data in _frames_dropped: + frames_dropped_item = MacosFrameDropApiStatus.from_dict( + frames_dropped_item_data + ) + + frames_dropped.append(frames_dropped_item) + + frames_malformed = d.pop("frames_malformed") + + frames_published = d.pop("frames_published") + + frames_received = d.pop("frames_received") + + frames_stale = d.pop("frames_stale") + + frames_superseded = d.pop("frames_superseded") + + native_import_max_ns = d.pop("native_import_max_ns") + + native_import_total_ns = d.pop("native_import_total_ns") + + native_reduction_submit_max_ns = d.pop("native_reduction_submit_max_ns") + + native_reduction_submit_total_ns = d.pop("native_reduction_submit_total_ns") + + publication_max_ns = d.pop("publication_max_ns") + + publication_total_ns = d.pop("publication_total_ns") + + queue_depth = d.pop("queue_depth") + + retain_max_ns = d.pop("retain_max_ns") + + retain_total_ns = d.pop("retain_total_ns") + + stream_state = d.pop("stream_state") + + def _parse_authorization_last_transition_age_ms( + data: object, + ) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + authorization_last_transition_age_ms = ( + _parse_authorization_last_transition_age_ms( + d.pop("authorization_last_transition_age_ms", UNSET) + ) + ) + + def _parse_capture_session_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + capture_session_generation = _parse_capture_session_generation( + d.pop("capture_session_generation", UNSET) + ) + + def _parse_color_space(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + color_space = _parse_color_space(d.pop("color_space", UNSET)) + + def _parse_display_scale(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + display_scale = _parse_display_scale(d.pop("display_scale", UNSET)) + + def _parse_dynamic_range(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + dynamic_range = _parse_dynamic_range(d.pop("dynamic_range", UNSET)) + + def _parse_fallback_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + fallback_reason = _parse_fallback_reason(d.pop("fallback_reason", UNSET)) + + def _parse_native_height(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + native_height = _parse_native_height(d.pop("native_height", UNSET)) + + def _parse_native_width(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + native_width = _parse_native_width(d.pop("native_width", UNSET)) + + def _parse_owner_designated_requirement_hash( + data: object, + ) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + owner_designated_requirement_hash = _parse_owner_designated_requirement_hash( + d.pop("owner_designated_requirement_hash", UNSET) + ) + + def _parse_pinned_generations(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + pinned_generations = _parse_pinned_generations( + d.pop("pinned_generations", UNSET) + ) + + def _parse_pixel_format(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + pixel_format = _parse_pixel_format(d.pop("pixel_format", UNSET)) + + def _parse_publication_path(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + publication_path = _parse_publication_path(d.pop("publication_path", UNSET)) + + def _parse_publication_plan_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + publication_plan_generation = _parse_publication_plan_generation( + d.pop("publication_plan_generation", UNSET) + ) + + def _parse_resource_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + resource_generation = _parse_resource_generation( + d.pop("resource_generation", UNSET) + ) + + def _parse_selection_diagnostic_label(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + selection_diagnostic_label = _parse_selection_diagnostic_label( + d.pop("selection_diagnostic_label", UNSET) + ) + + def _parse_topology_generation(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + topology_generation = _parse_topology_generation( + d.pop("topology_generation", UNSET) + ) + + def _parse_transfer_function(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + transfer_function = _parse_transfer_function(d.pop("transfer_function", UNSET)) + + macos_screen_telemetry_api_status = cls( + admitted_native_bytes=admitted_native_bytes, + callback_max_ns=callback_max_ns, + callback_total_ns=callback_total_ns, + conversion_max_ns=conversion_max_ns, + conversion_total_ns=conversion_total_ns, + cpu_reduction_max_ns=cpu_reduction_max_ns, + cpu_reduction_total_ns=cpu_reduction_total_ns, + executable_architecture=executable_architecture, + frames_dropped=frames_dropped, + frames_malformed=frames_malformed, + frames_published=frames_published, + frames_received=frames_received, + frames_stale=frames_stale, + frames_superseded=frames_superseded, + native_import_max_ns=native_import_max_ns, + native_import_total_ns=native_import_total_ns, + native_reduction_submit_max_ns=native_reduction_submit_max_ns, + native_reduction_submit_total_ns=native_reduction_submit_total_ns, + publication_max_ns=publication_max_ns, + publication_total_ns=publication_total_ns, + queue_depth=queue_depth, + retain_max_ns=retain_max_ns, + retain_total_ns=retain_total_ns, + stream_state=stream_state, + authorization_last_transition_age_ms=authorization_last_transition_age_ms, + capture_session_generation=capture_session_generation, + color_space=color_space, + display_scale=display_scale, + dynamic_range=dynamic_range, + fallback_reason=fallback_reason, + native_height=native_height, + native_width=native_width, + owner_designated_requirement_hash=owner_designated_requirement_hash, + pinned_generations=pinned_generations, + pixel_format=pixel_format, + publication_path=publication_path, + publication_plan_generation=publication_plan_generation, + resource_generation=resource_generation, + selection_diagnostic_label=selection_diagnostic_label, + topology_generation=topology_generation, + transfer_function=transfer_function, + ) + + macos_screen_telemetry_api_status.additional_properties = d + return macos_screen_telemetry_api_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties From 8341c5fe2a33ee321b1eace9d06723a88874936e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 09:58:08 -0700 Subject: [PATCH 092/144] fix(macos): keep nonnative diagnostics portable The non-macOS input stub constructed runtime diagnostics from Default inside a const function, which fails on Linux targets. Keep the accessor runtime-only so native and stub implementations expose the same portable contract. Co-Authored-By: Nova (GPT-5.5 Codex) --- crates/hypercolor-macos-input/src/stubs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/hypercolor-macos-input/src/stubs.rs b/crates/hypercolor-macos-input/src/stubs.rs index a952989b2..4902e6f3b 100644 --- a/crates/hypercolor-macos-input/src/stubs.rs +++ b/crates/hypercolor-macos-input/src/stubs.rs @@ -31,7 +31,7 @@ impl MacosInputSession { } #[must_use] - pub const fn diagnostics(&self) -> MacosInputDiagnostics { + pub fn diagnostics(&self) -> MacosInputDiagnostics { MacosInputDiagnostics { ..MacosInputDiagnostics::default() } From 2a12031d4dbc0f24ddb04c6f32c3e201af7e9191 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 10:15:07 -0700 Subject: [PATCH 093/144] fix(input): bind protected actions to exact executors Protected-source callbacks execute in a concrete local process, so source status cannot identify the process that receives a grant. Bind each action to its executor and resolve topology before releasing the manager lock. Headless picker requests now return a typed app-UI remedy without invoking native UI. Wayland reselects through its existing PipeWire command channel, and every successful action reports its exact owner. Co-Authored-By: Nova (GPT-5.6) --- crates/hypercolor-cli/src/commands/access.rs | 44 +++++- crates/hypercolor-core/src/input/macos.rs | 56 +++---- crates/hypercolor-core/src/input/mod.rs | 64 +++++++- .../hypercolor-core/src/input/screen/macos.rs | 14 +- .../src/input/screen/wayland.rs | 80 +++++++--- crates/hypercolor-core/src/input/traits.rs | 112 +++++++++++++- crates/hypercolor-core/tests/input_tests.rs | 35 +++++ .../tests/macos_host_input_tests.rs | 41 +++++- .../tests/macos_screen_capture_tests.rs | 45 +++++- crates/hypercolor-daemon/src/api/capture.rs | 137 +++++++++++++++--- 10 files changed, 552 insertions(+), 76 deletions(-) diff --git a/crates/hypercolor-cli/src/commands/access.rs b/crates/hypercolor-cli/src/commands/access.rs index 664f528ce..2147d006e 100644 --- a/crates/hypercolor-cli/src/commands/access.rs +++ b/crates/hypercolor-cli/src/commands/access.rs @@ -40,6 +40,28 @@ impl AccessCommand { Self::ChooseScreenSource => "Screen source picker completed", } } + + fn human_success_message(self, response: &serde_json::Value) -> String { + let owner = response + .get("grant_owner") + .and_then(serde_json::Value::as_str) + .map(grant_owner_label) + .unwrap_or("unavailable from this daemon"); + format!("{}; grant owner: {owner}", self.success_message()) + } +} + +fn grant_owner_label(owner: &str) -> &str { + match owner { + "app_sidecar" => "Hypercolor.app sidecar", + "app" => "Hypercolor.app", + "launchd_service" => "direct launchd service", + "homebrew_service" => "Homebrew service", + "broker" => "authenticated app broker", + "standalone" => "standalone daemon", + "platform_backend" => "active platform backend", + _ => "unknown process topology", + } } /// Execute one explicit protected-source action. @@ -56,7 +78,7 @@ pub async fn execute(args: &AccessArgs, client: &DaemonClient, ctx: &OutputConte if ctx.format == OutputFormat::Json { ctx.print_json(&response)?; } else { - ctx.success(args.command.success_message()); + ctx.success(&args.command.human_success_message(&response)); } Ok(()) } @@ -65,7 +87,7 @@ pub async fn execute(args: &AccessArgs, client: &DaemonClient, ctx: &OutputConte mod tests { use clap::Parser; - use super::AccessCommand; + use super::{AccessCommand, grant_owner_label}; use crate::{Cli, Commands}; #[test] @@ -105,4 +127,22 @@ mod tests { assert_eq!(args.command, expected); } } + + #[test] + fn protected_actions_name_the_exact_grant_owner() { + let response = serde_json::json!({"grant_owner": "broker"}); + assert_eq!( + AccessCommand::AuthorizeInputMonitoring.human_success_message(&response), + "Input Monitoring request completed; grant owner: authenticated app broker" + ); + assert_eq!(grant_owner_label("homebrew_service"), "Homebrew service"); + assert_eq!( + grant_owner_label("platform_backend"), + "active platform backend" + ); + assert_eq!( + AccessCommand::ChooseScreenSource.human_success_message(&serde_json::json!({})), + "Screen source picker completed; grant owner: unavailable from this daemon" + ); + } } diff --git a/crates/hypercolor-core/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs index adc6398b2..420894d2a 100644 --- a/crates/hypercolor-core/src/input/macos.rs +++ b/crates/hypercolor-core/src/input/macos.rs @@ -931,34 +931,36 @@ impl InputSource for MacosHostInput { let result = Arc::clone(&self.authorization_result); #[cfg(feature = "macos-native-fixtures")] let fixture = self.fixture.clone(); - Some(Arc::new(move || { - #[cfg(feature = "macos-native-fixtures")] - let granted = fixture - .as_ref() - .map_or_else(request_input_monitoring, |fixture| { - let mut backend = fixture - .backend - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if backend.request_granted { - backend.preflight_granted = true; - true + Some(ProtectedSourceAuthorizationAction::current_macos_process( + Arc::new(move || { + #[cfg(feature = "macos-native-fixtures")] + let granted = fixture + .as_ref() + .map_or_else(request_input_monitoring, |fixture| { + let mut backend = fixture + .backend + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if backend.request_granted { + backend.preflight_granted = true; + true + } else { + false + } + }); + #[cfg(not(feature = "macos-native-fixtures"))] + let granted = request_input_monitoring(); + result.store( + if granted { + AUTHORIZATION_GRANTED } else { - false - } - }); - #[cfg(not(feature = "macos-native-fixtures"))] - let granted = request_input_monitoring(); - result.store( - if granted { - AUTHORIZATION_GRANTED - } else { - AUTHORIZATION_DENIED - }, - Ordering::Release, - ); - Ok(granted) - })) + AUTHORIZATION_DENIED + }, + Ordering::Release, + ); + Ok(granted) + }), + )) } fn interaction_diagnostics(&self) -> Option { diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 37e9ef527..5f588c4fa 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -58,7 +58,8 @@ pub use traits::MacosScreenshotReferenceAction; pub use traits::{ InputData, InputSource, InteractionBatch, InteractionData, InteractionDegradation, InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, - ProtectedSourceAuthorizationAction, ScreenData, ScreenSourcePickerAction, ScreenZoneColors, + ProtectedSourceActionExecutor, ProtectedSourceActionOwner, ProtectedSourceAuthorizationAction, + ResolvedProtectedSourceAction, ScreenData, ScreenSourcePickerAction, ScreenZoneColors, ScrollAggregate, }; pub use windows::WindowsHostInput; @@ -1999,6 +2000,47 @@ impl InputManager { .find_map(|source| source.input_authorization_action()) } + fn resolve_protected_source_action( + &self, + action: A, + executor: ProtectedSourceActionExecutor, + presentation_required: bool, + ) -> ResolvedProtectedSourceAction { + if executor == ProtectedSourceActionExecutor::PlatformBackend { + return ResolvedProtectedSourceAction::Local { + action, + owner: ProtectedSourceActionOwner::PlatformBackend, + }; + } + + let active_owner = self.macos_capability_owner; + let requires_app_ui = matches!( + active_owner, + MacosCapabilityOwner::App | MacosCapabilityOwner::Broker + ) || presentation_required + && matches!( + active_owner, + MacosCapabilityOwner::LaunchdService | MacosCapabilityOwner::HomebrewService + ); + if requires_app_ui { + return ResolvedProtectedSourceAction::RequiresAppUi { active_owner }; + } + ResolvedProtectedSourceAction::Local { + action, + owner: ProtectedSourceActionOwner::Macos(active_owner), + } + } + + /// Resolve the explicit Input Monitoring request against this process. + #[must_use] + pub fn resolved_input_authorization_action( + &self, + ) -> Option> { + let action = self.input_authorization_action()?; + let executor = action.executor(); + Some(self.resolve_protected_source_action(action, executor, false)) + } + /// Resolve the explicit Screen Recording request without retaining the /// input-manager lock while native authorization UI runs. #[must_use] @@ -2008,6 +2050,16 @@ impl InputManager { .find_map(|source| source.screen_authorization_action()) } + /// Resolve the explicit Screen Recording request against this process. + #[must_use] + pub fn resolved_screen_authorization_action( + &self, + ) -> Option> { + let action = self.screen_authorization_action()?; + let executor = action.executor(); + Some(self.resolve_protected_source_action(action, executor, false)) + } + /// Resolve the native picker action without retaining the input-manager /// lock while system UI runs. #[must_use] @@ -2017,6 +2069,16 @@ impl InputManager { .find_map(|source| source.screen_source_picker_action()) } + /// Resolve the native picker request against its exact local executor. + #[must_use] + pub fn resolved_screen_source_picker_action( + &self, + ) -> Option> { + let action = self.screen_source_picker_action()?; + let executor = action.executor(); + Some(self.resolve_protected_source_action(action, executor, true)) + } + #[cfg(target_os = "macos")] #[must_use] pub fn macos_screenshot_reference_action(&self) -> Option { diff --git a/crates/hypercolor-core/src/input/screen/macos.rs b/crates/hypercolor-core/src/input/screen/macos.rs index 10be1274d..4a3e50f42 100644 --- a/crates/hypercolor-core/src/input/screen/macos.rs +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -1558,15 +1558,19 @@ impl InputSource for MacosScreenCaptureInput { fn screen_authorization_action(&self) -> Option { let control = Arc::clone(&self.control); - Some(Arc::new(move || { - control.request_authorization(); - Ok(control.authorization() == MacosAuthorizationState::Authorized) - })) + Some(ProtectedSourceAuthorizationAction::current_macos_process( + Arc::new(move || { + control.request_authorization(); + Ok(control.authorization() == MacosAuthorizationState::Authorized) + }), + )) } fn screen_source_picker_action(&self) -> Option { let control = Arc::clone(&self.control); - Some(Arc::new(move || control.present_picker())) + Some(ScreenSourcePickerAction::current_macos_process(Arc::new( + move || control.present_picker(), + ))) } #[cfg(target_os = "macos")] diff --git a/crates/hypercolor-core/src/input/screen/wayland.rs b/crates/hypercolor-core/src/input/screen/wayland.rs index 459cd39f4..a88877133 100644 --- a/crates/hypercolor-core/src/input/screen/wayland.rs +++ b/crates/hypercolor-core/src/input/screen/wayland.rs @@ -49,7 +49,7 @@ use crate::input::screen::{ ScreenWorkerExactLedgerBuilder, ScreenWorkerPreparation, ScreenWorkerPreparationTicket, ScreenWorkerRetirement, SourceScale, analyze_screen_frame, }; -use crate::input::traits::{InputData, InputSource}; +use crate::input::traits::{InputData, InputSource, ScreenSourcePickerAction}; use crate::input::worker_retention::{retain_input_worker, spawn_input_worker}; use crate::input::{ SourceIssue, SourceKind, SourceSessionSlot, SourceSessionWriter, SourceStatusHandle, @@ -1775,22 +1775,7 @@ impl WaylandScreenCaptureInput { return Ok(()); } - { - // The session-epoch lock serializes this clear against the - // worker's own token persist, so a grant landing concurrently - // cannot interleave with the clear in either order. - let _session_guard = self - .settings - .expected_epoch - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Ok(mut current) = self.settings.config.lock() { - current.restore_token = None; - } - if let Some(sink) = &self.token_sink { - sink(None); - } - } + clear_restore_token(&self.settings, self.token_sink.as_ref()); if !self.running || !self.capture_demand.is_active() { return Ok(()); @@ -1800,6 +1785,33 @@ impl WaylandScreenCaptureInput { self.restart_worker() } + fn detached_reselect_action(&self) -> ScreenSourcePickerAction { + let settings = Arc::clone(&self.settings); + let token_sink = self.token_sink.clone(); + let worker = self.worker.as_ref().map(|worker| { + ( + Arc::clone(&worker.portal_pending), + worker.command_tx.clone(), + ) + }); + ScreenSourcePickerAction::platform_backend(Arc::new(move || { + if worker + .as_ref() + .is_some_and(|(portal_pending, _)| portal_pending.load(Ordering::SeqCst)) + { + debug!("Portal source picker is already open; ignoring re-pick request"); + return Ok(()); + } + clear_restore_token(&settings, token_sink.as_ref()); + if let Some((_, command_tx)) = &worker { + command_tx + .send(WorkerCommand::Reselect) + .map_err(|_| anyhow!("Wayland capture worker rejected source reselect"))?; + } + Ok(()) + })) + } + fn portal_pending(&self) -> bool { self.worker .as_ref() @@ -2386,6 +2398,25 @@ impl InputSource for WaylandScreenCaptureInput { fn reselect_screen_source(&mut self) -> anyhow::Result<()> { self.reselect_source() } + + fn screen_source_picker_action(&self) -> Option { + Some(self.detached_reselect_action()) + } +} + +fn clear_restore_token(settings: &SharedSettings, token_sink: Option<&RestoreTokenSink>) { + let _session_guard = settings + .expected_epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + settings + .config + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .restore_token = None; + if let Some(sink) = token_sink { + sink(None); + } } struct WaylandCaptureWorker { @@ -2461,6 +2492,7 @@ struct WorkerFlags { enum WorkerCommand { SetDemand(ScreenCaptureDemand), + Reselect, PrepareExact { ticket: ScreenWorkerPreparationTicket, cancelled: Arc, @@ -3803,6 +3835,12 @@ fn run_capture_worker( let reason = match loop_outcome { Ok(PipeWireLoopExit::Stopped) => return, + Ok(PipeWireLoopExit::Reselect) => { + extent_corrections = 0; + native_extent_override = None; + info!("Re-opening Wayland screencast source picker"); + continue; + } Ok(PipeWireLoopExit::RequiresNativeExtent(extent)) => { if extent_corrections >= 3 { let parking = @@ -4016,6 +4054,7 @@ async fn open_portal_session( #[derive(Clone, Debug, PartialEq, Eq)] enum PipeWireLoopExit { Stopped, + Reselect, Terminal(String), Unavailable(String), /// Initial negotiation fixated a different native extent than requested @@ -4701,6 +4740,13 @@ fn run_pipewire_loop( warn!(active, %error, "Failed to update PipeWire stream active state"); } } + WorkerCommand::Reselect => { + *loop_exit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(PipeWireLoopExit::Reselect); + mainloop.quit(); + } WorkerCommand::PrepareExact { ticket, cancelled, diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 7a72e94d0..408f65702 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -5,7 +5,9 @@ //! the render loop consumes per frame. use super::graph::InteractionSourceOrigin; -use super::status::{SourceStatusError, SourceStatusHandle, SourceStatusReporter}; +use super::status::{ + MacosCapabilityOwner, SourceStatusError, SourceStatusHandle, SourceStatusReporter, +}; use crate::input::audio::{AudioRuntimeRetirement, PreparedAudioReconfiguration}; use crate::types::audio::{AudioData, AudioPipelineConfig}; use crate::types::canvas::{PublishedSurface, SurfaceResourceOwner}; @@ -14,11 +16,115 @@ use hypercolor_types::sensor::SystemSnapshot; use std::ops::Deref; use std::sync::Arc; +/// Process class that executes one detached protected-source action. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedSourceActionExecutor { + /// The macOS process hosting the source executes the action locally. + CurrentMacosProcess, + /// The active platform backend executes the action locally. + PlatformBackend, +} + +/// Exact process identity that owns a successfully executed protected action. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedSourceActionOwner { + /// The authoritative macOS daemon topology for the current process. + Macos(MacosCapabilityOwner), + /// The active non-macOS capture backend. + PlatformBackend, +} + +/// Whether a detached protected-source action can execute in this process. +pub enum ResolvedProtectedSourceAction { + /// The callback is locally executable after the input-manager lock drops. + Local { + /// Detached callback owned by the resolved executor. + action: A, + /// Exact owner of the resulting grant or selection. + owner: ProtectedSourceActionOwner, + }, + /// The active topology cannot present the required native UI. + RequiresAppUi { + /// Authoritative macOS daemon topology that rejected local execution. + active_owner: MacosCapabilityOwner, + }, +} + /// Explicit local authorization request detached from input-graph locks. -pub type ProtectedSourceAuthorizationAction = Arc anyhow::Result + Send + Sync>; +#[derive(Clone)] +pub struct ProtectedSourceAuthorizationAction { + callback: Arc anyhow::Result + Send + Sync>, + executor: ProtectedSourceActionExecutor, +} + +impl ProtectedSourceAuthorizationAction { + pub(crate) fn current_macos_process( + callback: Arc anyhow::Result + Send + Sync>, + ) -> Self { + Self { + callback, + executor: ProtectedSourceActionExecutor::CurrentMacosProcess, + } + } + + /// Return the process class that owns callback execution. + #[must_use] + pub const fn executor(&self) -> ProtectedSourceActionExecutor { + self.executor + } + + /// Execute the detached authorization request. + /// + /// # Errors + /// + /// Returns an error when the native authorization API rejects the request. + pub fn execute(&self) -> anyhow::Result { + (self.callback)() + } +} /// Explicit native source-picker presentation detached from input-graph locks. -pub type ScreenSourcePickerAction = Arc anyhow::Result<()> + Send + Sync>; +#[derive(Clone)] +pub struct ScreenSourcePickerAction { + callback: Arc anyhow::Result<()> + Send + Sync>, + executor: ProtectedSourceActionExecutor, +} + +impl ScreenSourcePickerAction { + pub(crate) fn current_macos_process( + callback: Arc anyhow::Result<()> + Send + Sync>, + ) -> Self { + Self { + callback, + executor: ProtectedSourceActionExecutor::CurrentMacosProcess, + } + } + + #[cfg(target_os = "linux")] + pub(crate) fn platform_backend( + callback: Arc anyhow::Result<()> + Send + Sync>, + ) -> Self { + Self { + callback, + executor: ProtectedSourceActionExecutor::PlatformBackend, + } + } + + /// Return the process class that owns callback execution. + #[must_use] + pub const fn executor(&self) -> ProtectedSourceActionExecutor { + self.executor + } + + /// Execute the detached picker request. + /// + /// # Errors + /// + /// Returns an error when the native picker cannot be presented. + pub fn execute(&self) -> anyhow::Result<()> { + (self.callback)() + } +} #[cfg(target_os = "macos")] pub type MacosScreenshotReferenceAction = Arc< diff --git a/crates/hypercolor-core/tests/input_tests.rs b/crates/hypercolor-core/tests/input_tests.rs index b5d76ab52..e9f7d5d1d 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -2550,6 +2550,41 @@ fn wayland_screen_capture_input_stays_idle_without_capture_demand() { assert!(!src.is_running()); } +#[cfg(target_os = "linux")] +#[test] +fn wayland_picker_action_is_detached_and_names_the_platform_backend() { + let persisted = Arc::new(Mutex::new(Vec::new())); + let sink_log = Arc::clone(&persisted); + let mut config = CaptureConfig::default(); + config.restore_token = Some("persisted-selection".to_owned()); + let source = + WaylandScreenCaptureInput::new(config).with_restore_token_sink(Arc::new(move |token| { + sink_log + .lock() + .expect("restore-token sink lock") + .push(token) + })); + let mut manager = InputManager::new(); + manager.add_source(Box::new(source)); + + let action = manager + .resolved_screen_source_picker_action() + .expect("Wayland source exposes a detached picker request"); + let hypercolor_core::input::ResolvedProtectedSourceAction::Local { action, owner } = action + else { + panic!("Wayland picker must execute in its platform backend"); + }; + assert_eq!( + owner, + hypercolor_core::input::ProtectedSourceActionOwner::PlatformBackend + ); + action.execute().expect("detached picker request succeeds"); + assert_eq!( + *persisted.lock().expect("restore-token result lock"), + vec![None] + ); +} + // ── Screen Capture Live Reconfiguration ────────────────────────────────── #[derive(Default)] diff --git a/crates/hypercolor-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs index 324b26a31..c7c94ca90 100644 --- a/crates/hypercolor-core/tests/macos_host_input_tests.rs +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -335,7 +335,7 @@ mod fixtures { use std::sync::Arc; use hypercolor_core::input::{ - InputData, InputSource, MacosAuthorizationState, MacosCapabilityOwner, + InputData, InputManager, InputSource, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, MacosHostInput, MacosInputFixtureBackend, MacosProtectedSourceState, SourcePlatformStatus, SourceState, }; @@ -548,7 +548,11 @@ mod fixtures { .input_authorization_action() .expect("keyboard source should expose authorization"); - assert!(action().expect("fixture authorization should succeed")); + assert!( + action + .execute() + .expect("fixture authorization should succeed") + ); source .sample() .expect("source should consume action result"); @@ -576,4 +580,37 @@ mod fixtures { assert_eq!(platform.translated_process, None); } } + + #[test] + fn manager_rejects_daemon_local_authorization_for_a_broker_owner() { + let backend = + MacosInputFixtureBackend::new(false, true, event_masks(true, true), true, desktop(1)); + let (source, _) = MacosHostInput::new_deterministic_fixture(true, true, backend); + let status = source + .source_status_handle() + .expect("macOS host source exposes status"); + let mut manager = InputManager::new(); + manager.add_source(Box::new(source)); + manager + .set_macos_daemon_ownership(MacosCapabilityOwner::Broker, None, None) + .expect("owner update should publish"); + + let action = manager + .resolved_input_authorization_action() + .expect("manager should preserve the explicit request"); + assert!(matches!( + action, + hypercolor_core::input::ResolvedProtectedSourceAction::RequiresAppUi { + active_owner: MacosCapabilityOwner::Broker, + } + )); + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosInput(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS input platform status"); + }; + assert_eq!( + platform.keyboard_tcc, + MacosAuthorizationState::NotDetermined + ); + } } diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index 1400999c0..c7ca45cbb 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -404,8 +404,8 @@ fn authorization_and_picker_actions_run_outside_graph_ownership() { .screen_source_picker_action() .expect("screen source exposes picker action"); - assert!(authorize().expect("fixture authorization succeeds")); - picker().expect("fixture picker succeeds"); + assert!(authorize.execute().expect("fixture authorization succeeds")); + picker.execute().expect("fixture picker succeeds"); source.sample().expect("source refreshes platform status"); let snapshot = status.snapshot(); @@ -416,6 +416,47 @@ fn authorization_and_picker_actions_run_outside_graph_ownership() { assert_eq!(platform.state, CoreProtectedSourceState::NeedsSelection); } +#[test] +fn manager_gates_headless_macos_picker_before_local_execution() { + let (source, _) = fixture_source(CaptureConfig::default()); + let status = source + .source_status_handle() + .expect("macOS fixture exposes status"); + let mut manager = InputManager::new(); + manager.add_source(Box::new(source)); + manager + .set_macos_daemon_ownership(MacosCapabilityOwner::LaunchdService, None, None) + .expect("owner update should publish"); + + let authorize = manager + .resolved_screen_authorization_action() + .expect("manager should preserve the authorization request"); + let picker = manager + .resolved_screen_source_picker_action() + .expect("manager should preserve the picker request"); + + assert!(matches!( + authorize, + hypercolor_core::input::ResolvedProtectedSourceAction::Local { + owner: hypercolor_core::input::ProtectedSourceActionOwner::Macos( + MacosCapabilityOwner::LaunchdService + ), + .. + } + )); + assert!(matches!( + picker, + hypercolor_core::input::ResolvedProtectedSourceAction::RequiresAppUi { + active_owner: MacosCapabilityOwner::LaunchdService, + } + )); + let snapshot = status.snapshot(); + let Some(SourcePlatformStatus::MacosScreen(platform)) = snapshot.platform.as_deref() else { + panic!("fixture should publish macOS screen status"); + }; + assert_eq!(platform.selection, MacosSelectionState::None); +} + #[test] fn late_macos_capture_source_inherits_process_capabilities() { let (source, _) = fixture_source(CaptureConfig::default()); diff --git a/crates/hypercolor-daemon/src/api/capture.rs b/crates/hypercolor-daemon/src/api/capture.rs index b4bf7c8df..1831aef2a 100644 --- a/crates/hypercolor-daemon/src/api/capture.rs +++ b/crates/hypercolor-daemon/src/api/capture.rs @@ -6,9 +6,45 @@ use axum::extract::State; use axum::response::Response; use tracing::{info, warn}; +use hypercolor_core::input::{ + MacosCapabilityOwner, ProtectedSourceActionOwner, ResolvedProtectedSourceAction, +}; + use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; +const fn grant_owner_name(owner: MacosCapabilityOwner) -> &'static str { + match owner { + MacosCapabilityOwner::AppSidecar => "app_sidecar", + MacosCapabilityOwner::App => "app", + MacosCapabilityOwner::LaunchdService => "launchd_service", + MacosCapabilityOwner::HomebrewService => "homebrew_service", + MacosCapabilityOwner::Broker => "broker", + MacosCapabilityOwner::Standalone => "standalone", + } +} + +const fn protected_action_owner_name(owner: ProtectedSourceActionOwner) -> &'static str { + match owner { + ProtectedSourceActionOwner::Macos(owner) => grant_owner_name(owner), + ProtectedSourceActionOwner::PlatformBackend => "platform_backend", + } +} + +fn requires_app_ui_details(active_owner: MacosCapabilityOwner) -> serde_json::Value { + serde_json::json!({ + "active_owner": grant_owner_name(active_owner), + "remedy": { "kind": "requires_app_ui" }, + }) +} + +fn requires_app_ui(action: &str, active_owner: MacosCapabilityOwner) -> Response { + ApiError::validation_with_details( + format!("{action} must run in Hypercolor.app for the active process topology"), + requires_app_ui_details(active_owner), + ) +} + /// `POST /api/v1/input/authorize` — Request macOS Input Monitoring. pub async fn authorize_input_monitoring(State(state): State>) -> Response { let Some(manager) = state.config_manager.as_ref() else { @@ -22,15 +58,24 @@ pub async fn authorize_input_monitoring(State(state): State>) -> R } let action = { let input_manager = state.input_manager.lock().await; - input_manager.input_authorization_action() + input_manager.resolved_input_authorization_action() }; let Some(action) = action else { return ApiError::validation("No Input Monitoring authorization action is available"); }; - match tokio::task::spawn_blocking(move || action()).await { + let (action, grant_owner) = match action { + ResolvedProtectedSourceAction::Local { action, owner } => (action, owner), + ResolvedProtectedSourceAction::RequiresAppUi { active_owner } => { + return requires_app_ui("Input Monitoring authorization", active_owner); + } + }; + match tokio::task::spawn_blocking(move || action.execute()).await { Ok(Ok(authorized)) => { info!(authorized, "Input Monitoring authorization requested"); - ApiResponse::ok(serde_json::json!({ "authorized": authorized })) + ApiResponse::ok(serde_json::json!({ + "authorized": authorized, + "grant_owner": protected_action_owner_name(grant_owner), + })) } Ok(Err(error)) => { warn!(%error, "Input Monitoring authorization failed"); @@ -54,15 +99,24 @@ pub async fn authorize_screen_recording(State(state): State>) -> R } let action = { let input_manager = state.input_manager.lock().await; - input_manager.screen_authorization_action() + input_manager.resolved_screen_authorization_action() }; let Some(action) = action else { return ApiError::validation("No Screen Recording authorization action is available"); }; - match tokio::task::spawn_blocking(move || action()).await { + let (action, grant_owner) = match action { + ResolvedProtectedSourceAction::Local { action, owner } => (action, owner), + ResolvedProtectedSourceAction::RequiresAppUi { active_owner } => { + return requires_app_ui("Screen Recording authorization", active_owner); + } + }; + match tokio::task::spawn_blocking(move || action.execute()).await { Ok(Ok(authorized)) => { info!(authorized, "Screen Recording authorization requested"); - ApiResponse::ok(serde_json::json!({ "authorized": authorized })) + ApiResponse::ok(serde_json::json!({ + "authorized": authorized, + "grant_owner": protected_action_owner_name(grant_owner), + })) } Ok(Err(error)) => { warn!(%error, "Screen Recording authorization failed"); @@ -90,30 +144,38 @@ pub async fn pick_capture_source(State(state): State>) -> Response ); } - let picker_result = { - let mut input_manager = state.input_manager.lock().await; + let action = { + let input_manager = state.input_manager.lock().await; if !input_manager.has_screen_source() { return ApiError::validation( "No screen capture source is registered; restart the daemon or re-enable capture", ); } - if let Some(action) = input_manager.screen_source_picker_action() { - drop(input_manager); - tokio::task::spawn_blocking(move || action()) - .await - .map_err(|error| anyhow::anyhow!("source picker task failed: {error}")) - .and_then(|result| result) - } else { - input_manager.reselect_screen_source() + input_manager.resolved_screen_source_picker_action() + }; + let Some(action) = action else { + return ApiError::validation("No detached screen source picker action is available"); + }; + let (action, grant_owner) = match action { + ResolvedProtectedSourceAction::Local { action, owner } => (action, owner), + ResolvedProtectedSourceAction::RequiresAppUi { active_owner } => { + return requires_app_ui("Screen source picker", active_owner); } }; + let picker_result = tokio::task::spawn_blocking(move || action.execute()) + .await + .map_err(|error| anyhow::anyhow!("source picker task failed: {error}")) + .and_then(|result| result); if let Err(error) = picker_result { warn!(%error, "Failed to re-open screen source picker"); return ApiError::internal(format!("Failed to re-open source picker: {error}")); } info!("Screen capture source picker requested"); - ApiResponse::ok(serde_json::json!({ "picking": true })) + ApiResponse::ok(serde_json::json!({ + "picking": true, + "grant_owner": protected_action_owner_name(grant_owner), + })) } /// One display output the capture backend can address, for monitor pickers. @@ -156,3 +218,44 @@ pub async fn list_capture_monitors() -> Response { ApiResponse::ok(monitors) } + +#[cfg(test)] +mod tests { + use hypercolor_core::input::{MacosCapabilityOwner, ProtectedSourceActionOwner}; + + use super::{grant_owner_name, protected_action_owner_name, requires_app_ui_details}; + + #[test] + fn protected_grant_owner_names_are_stable_and_process_specific() { + assert_eq!( + [ + MacosCapabilityOwner::AppSidecar, + MacosCapabilityOwner::App, + MacosCapabilityOwner::LaunchdService, + MacosCapabilityOwner::HomebrewService, + MacosCapabilityOwner::Broker, + MacosCapabilityOwner::Standalone, + ] + .map(grant_owner_name), + [ + "app_sidecar", + "app", + "launchd_service", + "homebrew_service", + "broker", + "standalone", + ] + ); + assert_eq!( + protected_action_owner_name(ProtectedSourceActionOwner::PlatformBackend), + "platform_backend" + ); + assert_eq!( + requires_app_ui_details(MacosCapabilityOwner::LaunchdService), + serde_json::json!({ + "active_owner": "launchd_service", + "remedy": { "kind": "requires_app_ui" }, + }) + ); + } +} From 268c1fc53f9fbc74a4960b40e7b85ed155cf7504 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 10:15:31 -0700 Subject: [PATCH 094/144] fix(servo): preserve canonical pointer scroll events The queued-frame recent-key filter exhaustively classifies canonical input events. Include PointerScroll as a non-key event so Servo builds accept the shared scroll vocabulary without altering recent-key reconstruction. Co-Authored-By: Nova (GPT-5.6) --- crates/hypercolor-core/src/effect/servo/renderer/frame_queue.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/hypercolor-core/src/effect/servo/renderer/frame_queue.rs b/crates/hypercolor-core/src/effect/servo/renderer/frame_queue.rs index 6572797f5..5459c923f 100644 --- a/crates/hypercolor-core/src/effect/servo/renderer/frame_queue.rs +++ b/crates/hypercolor-core/src/effect/servo/renderer/frame_queue.rs @@ -422,6 +422,7 @@ fn normalize_queued_interaction(interaction: &mut crate::input::InteractionData) InputEvent::Key { .. } | InputEvent::MouseButton { .. } | InputEvent::MouseWheel { .. } + | InputEvent::PointerScroll { .. } | InputEvent::MidiNote { .. } | InputEvent::MidiControlChange { .. } | InputEvent::MidiPitchBend { .. } From 53161eac536f492b351ec249f19a160e49ca2630 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 10:23:07 -0700 Subject: [PATCH 095/144] fix(macos): reject nonexistent broker canary rows The TCC owner diagnostic could label the current process as a capture broker without any broker package, launcher, or authenticated transport existing. Keep its topology vocabulary closed to implemented daemon owners so a local probe cannot mint false Wave 0 evidence. Co-Authored-By: Nova (GPT-5.6) --- .../examples/probe_macos_tcc_owner.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs b/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs index 7858c96de..eca9881eb 100644 --- a/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs +++ b/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs @@ -25,7 +25,6 @@ enum Topology { DirectLaunchd, Homebrew, Standalone, - CaptureBroker, } impl Topology { @@ -36,7 +35,6 @@ impl Topology { Self::DirectLaunchd => "direct_launchd", Self::Homebrew => "homebrew", Self::Standalone => "standalone", - Self::CaptureBroker => "capture_broker", } } } @@ -403,9 +401,8 @@ fn parse_topology(value: &str) -> Result { "direct-launchd" => Ok(Topology::DirectLaunchd), "homebrew" => Ok(Topology::Homebrew), "standalone" => Ok(Topology::Standalone), - "capture-broker" => Ok(Topology::CaptureBroker), _ => Err(format!( - "unknown topology {value:?}; expected app-sidecar, direct-launchd, homebrew, standalone, or capture-broker" + "unknown topology {value:?}; expected app-sidecar, direct-launchd, homebrew, or standalone" )), } } @@ -420,8 +417,8 @@ fn print_help() { notarization, and TCC evidence. No prompt appears unless an explicit\n\ --authorize-input or --authorize-screen flag is present.\n\ \n\ - TOPOLOGY is app-sidecar, direct-launchd, homebrew, standalone, or\n\ - capture-broker. --output creates a new file and never overwrites." + TOPOLOGY is app-sidecar, direct-launchd, homebrew, or standalone.\n\ + --output creates a new file and never overwrites." ); } @@ -433,6 +430,7 @@ mod tests { fn topology_is_required_and_closed() { assert!(parse_args([]).is_err()); assert!(parse_args(["--topology".to_owned(), "future".to_owned()]).is_err()); + assert!(parse_args(["--topology".to_owned(), "capture-broker".to_owned()]).is_err()); assert_eq!( parse_args(["--topology".to_owned(), "app-sidecar".to_owned()]) .expect("arguments should parse") From 2ce483bec0840de8da790cdbed5387fc54aa9293 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 11:15:34 -0700 Subject: [PATCH 096/144] fix(daemon): retain native screen leases through GPU completion Track each macOS screen lease with the submission that samples it. Cache eviction and teardown retain IOSurface owners until completion. Cover composition, direct sampling, preview, finalization, reduction, and materialization submissions. Co-Authored-By: Nova (Codex) --- .../src/render_thread/producer_queue.rs | 100 ++++++++++- .../src/render_thread/sparkleflinger/gpu.rs | 158 +++++++++++++++++- .../sparkleflinger/gpu/compositor.rs | 128 +++++++++++++- .../sparkleflinger/gpu/display_finalize.rs | 21 ++- .../sparkleflinger/gpu/preview.rs | 42 ++++- .../sparkleflinger/gpu/sampler.rs | 7 + .../sparkleflinger/gpu/source.rs | 28 ++++ 7 files changed, 459 insertions(+), 25 deletions(-) diff --git a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs index b42ee2133..4f1334db7 100644 --- a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs +++ b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs @@ -20,9 +20,62 @@ use hypercolor_macos_capture::MacosCaptureFrame; use hypercolor_macos_gpu_interop::ImportedMacosScreenFrame; #[cfg(all(feature = "wgpu", target_os = "windows"))] use hypercolor_windows_gpu_interop::ScreenTextureCopy; +#[cfg(feature = "wgpu")] +use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +/// Values that must outlive the GPU submission that references them. +/// +/// The compositor retires entries in queue order because wgpu submissions on +/// one queue complete in that same order. +#[cfg(feature = "wgpu")] +#[derive(Debug)] +pub(crate) struct SubmissionRetirementQueue { + entries: VecDeque<(K, Vec)>, +} + +#[cfg(feature = "wgpu")] +impl Default for SubmissionRetirementQueue { + fn default() -> Self { + Self { + entries: VecDeque::new(), + } + } +} + +#[cfg(feature = "wgpu")] +impl SubmissionRetirementQueue { + pub(crate) fn retire(&mut self, submission: K, values: Vec) { + if !values.is_empty() { + self.entries.push_back((submission, values)); + } + } + + pub(crate) fn release_completed(&mut self, mut is_complete: impl FnMut(&K) -> bool) { + while self + .entries + .front() + .is_some_and(|(submission, _)| is_complete(submission)) + { + self.entries.pop_front(); + } + } + + pub(crate) fn front_submission(&self) -> Option<&K> { + self.entries.front().map(|(submission, _)| submission) + } + + pub(crate) fn release_front(&mut self) { + self.entries.pop_front(); + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.len() + } +} + #[cfg(feature = "wgpu")] #[derive(Debug, Clone)] pub(crate) struct GpuTextureFrame { @@ -523,9 +576,54 @@ impl ProducerFrameState { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use hypercolor_core::types::canvas::{Canvas, PublishedSurface}; - use super::{ProducerFrame, ProducerFrameState, ProducerQueue}; + use super::{ProducerFrame, ProducerFrameState, ProducerQueue, SubmissionRetirementQueue}; + + struct LeaseDropProbe(Arc); + + impl Drop for LeaseDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn submission_retirement_queue_keeps_evicted_leases_until_completion() { + let dropped = Arc::new(AtomicUsize::new(0)); + let mut retirements = SubmissionRetirementQueue::default(); + retirements.retire(17_u64, vec![LeaseDropProbe(Arc::clone(&dropped))]); + + // Cache eviction only removes its own entry. The submission queue keeps + // the native owner alive until the device reports this submission done. + retirements.release_completed(|_| false); + assert_eq!(retirements.len(), 1); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + + retirements.release_completed(|submission| *submission == 17); + assert_eq!(retirements.len(), 0); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } + + #[test] + fn submission_retirement_queue_never_releases_past_an_incomplete_submission() { + let dropped = Arc::new(AtomicUsize::new(0)); + let mut retirements = SubmissionRetirementQueue::default(); + retirements.retire(17_u64, vec![LeaseDropProbe(Arc::clone(&dropped))]); + retirements.retire(18_u64, vec![LeaseDropProbe(Arc::clone(&dropped))]); + + retirements.release_completed(|submission| *submission == 18); + + assert_eq!(retirements.len(), 2); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + + retirements.release_completed(|_| true); + assert_eq!(retirements.len(), 0); + assert_eq!(dropped.load(Ordering::SeqCst), 2); + } #[test] fn producer_queue_latches_fresh_then_retains() { diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs index 3f8f54df4..0e9e0cd2c 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -86,13 +86,13 @@ use super::{ use crate::render_thread::gpu_device::{ GpuBackendPreference, GpuRenderDevice, texture_format_name, }; -#[cfg(all(target_os = "macos", feature = "screen-capture"))] -use crate::render_thread::producer_queue::MacosScreenTextureLease; #[cfg(target_os = "windows")] use crate::render_thread::producer_queue::WindowsScreenTextureLease; use crate::render_thread::producer_queue::{ GpuTextureFrame, GpuTextureFrameLease, GpuTextureFrameOrigin, ProducerFrame, }; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use crate::render_thread::producer_queue::{MacosScreenTextureLease, SubmissionRetirementQueue}; use crate::render_thread::sparkleflinger::gpu_sampling::{ GpuSampleSource, GpuSamplingPlan, GpuSamplingPreparation, GpuSpatialSampler, }; @@ -1280,6 +1280,9 @@ pub(crate) struct GpuSparkleFlinger { screen_target: Option, #[cfg(all(target_os = "macos", feature = "screen-capture"))] metal4_capable: bool, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_lease_retirements: + SubmissionRetirementQueue, #[cfg(test)] superseded_frame_count: usize, #[cfg(test)] @@ -1308,6 +1311,14 @@ struct FrameInFlight { generation: u64, encoder: EncoderStage, readbacks: Vec, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: Vec, +} + +pub(super) struct StashedFrame { + pub(super) encoder: wgpu::CommandEncoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + pub(super) native_screen_leases: Vec, } enum EncoderStage { @@ -1333,6 +1344,9 @@ impl FrameInFlight { generation: u64, encoder: wgpu::CommandEncoder, preview_readback: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: Vec< + MacosScreenTextureLease, + >, ) -> Self { let readbacks = preview_readback.map_or_else(Vec::new, |readback| { vec![StagedReadback::Preview { @@ -1344,6 +1358,8 @@ impl FrameInFlight { generation, encoder: EncoderStage::Building(Some(encoder)), readbacks, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, } } @@ -1359,6 +1375,8 @@ impl FrameInFlight { readback: preview_readback, stage: ReadbackStage::Submitted(submission_index), }], + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: Vec::new(), } } @@ -1434,7 +1452,7 @@ impl FrameInFlight { Some(submission_index) } - fn supersede(mut self, reason: &'static str) -> Option { + fn supersede(mut self, reason: &'static str) -> Option { let encoder = self.take_encoder_for_chaining(); self.encoder = EncoderStage::Superseded; self.readbacks.clear(); @@ -1443,7 +1461,16 @@ impl FrameInFlight { reason, "superseding deferred GPU frame" ); - encoder + encoder.map(|encoder| StashedFrame { + encoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: std::mem::take(&mut self.native_screen_leases), + }) + } + + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + fn take_native_screen_leases(&mut self) -> Vec { + std::mem::take(&mut self.native_screen_leases) } #[cfg(test)] @@ -1463,6 +1490,8 @@ impl FrameInFlight { }, stage: ReadbackStage::Encoded, }], + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: Vec::new(), } } } @@ -1689,6 +1718,8 @@ impl GpuSparkleFlinger { screen_target, #[cfg(all(target_os = "macos", feature = "screen-capture"))] metal4_capable, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_lease_retirements: SubmissionRetirementQueue::default(), #[cfg(test)] superseded_frame_count: 0, #[cfg(test)] @@ -2013,6 +2044,8 @@ impl GpuSparkleFlinger { { self.screen_storage_id = None; } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.release_completed_native_screen_leases(); } #[cfg(target_os = "windows")] @@ -2143,6 +2176,14 @@ impl GpuSparkleFlinger { ); let content_generation = imported.content_sequence(); let descriptor = &target_owner.descriptor; + let native_screen_submission_lease = MacosScreenTextureLease::new( + imported.clone(), + capture_owner.clone(), + target_owner.clone(), + target_lifetime.clone(), + shared_target_lifetime.clone(), + capture_lifetime.clone(), + ); let (width, height, storage_id, texture, view) = if requires_work { self.flush_pending_output_submission()?; let reduction_started = Instant::now(); @@ -2167,7 +2208,11 @@ impl GpuSparkleFlinger { macos_reduction_descriptor(descriptor)?, &mut encoder, )?; - let _ = self.queue.submit(Some(encoder.finish())); + let submission_index = self.queue.submit(Some(encoder.finish())); + self.retire_native_screen_leases( + submission_index, + vec![native_screen_submission_lease.clone()], + ); submitted_native_reduction = true; *physical_sequence = Some(content_generation); } @@ -2197,7 +2242,11 @@ impl GpuSparkleFlinger { macos_native_letterbox_fill(descriptor)?, &mut encoder, )?; - let _ = self.queue.submit(Some(encoder.finish())); + let submission_index = self.queue.submit(Some(encoder.finish())); + self.retire_native_screen_leases( + submission_index, + vec![native_screen_submission_lease.clone()], + ); submitted_native_reduction = true; *logical_sequence = Some(content_generation); } @@ -2348,6 +2397,13 @@ impl GpuSparkleFlinger { &mut zones, None, )?; + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + if let Some(submission_index) = dispatch.submission_index.clone() { + self.retire_native_screen_leases( + submission_index, + frame.macos_screen_lease.clone().into_iter().collect(), + ); + } if dispatch.queue_saturated || !dispatch.sampled { if let Some(pending) = dispatch.pending_readback { self.spatial_sampler.discard_pending_readback(pending); @@ -3035,7 +3091,12 @@ impl GpuSparkleFlinger { let submission_index = frame.submit(&self.queue); debug_assert!(submission_index.is_some()); if let Some(submission_index) = submission_index { - self.finish_pending_uploads(submission_index); + self.finish_pending_uploads(submission_index.clone()); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases( + submission_index, + frame.take_native_screen_leases(), + ); } self.release_retired_uniform_slots(); } @@ -3045,7 +3106,7 @@ impl GpuSparkleFlinger { pub(super) fn supersede_frame_in_flight( &mut self, reason: &'static str, - ) -> Option { + ) -> Option { let frame = self.frame_in_flight.take()?; let encoder = frame.supersede(reason); #[cfg(test)] @@ -3059,6 +3120,29 @@ impl GpuSparkleFlinger { &mut self, encoder: wgpu::CommandEncoder, preview_readback: Option, + ) { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.stage_frame_in_flight_with_native_screen_leases(encoder, preview_readback, Vec::new()); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] + { + debug_assert!( + self.frame_in_flight.is_none(), + "deferred GPU frame must be submitted or superseded before replacement" + ); + self.frame_in_flight = Some(FrameInFlight::building( + self.output_generation, + encoder, + preview_readback, + )); + } + } + + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + fn stage_frame_in_flight_with_native_screen_leases( + &mut self, + encoder: wgpu::CommandEncoder, + preview_readback: Option, + native_screen_leases: Vec, ) { debug_assert!( self.frame_in_flight.is_none(), @@ -3068,9 +3152,60 @@ impl GpuSparkleFlinger { self.output_generation, encoder, preview_readback, + native_screen_leases, )); } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + fn retire_native_screen_leases( + &mut self, + submission_index: wgpu::SubmissionIndex, + leases: Vec, + ) { + self.native_screen_lease_retirements + .retire(submission_index, leases); + self.release_completed_native_screen_leases(); + } + + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + fn release_completed_native_screen_leases(&mut self) { + let device = &self.device; + self.native_screen_lease_retirements + .release_completed(|submission_index| { + match device.poll(wgpu::PollType::Wait { + submission_index: Some(submission_index.clone()), + timeout: Some(std::time::Duration::ZERO), + }) { + Ok(_) => true, + Err(wgpu::PollError::Timeout) => false, + Err(error) => { + tracing::debug!(%error, "GPU native screen lease retirement poll failed"); + false + } + } + }); + } + + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + fn wait_for_native_screen_lease_retirements(&mut self) { + while let Some(submission_index) = self + .native_screen_lease_retirements + .front_submission() + .cloned() + { + match self.device.poll(wgpu::PollType::Wait { + submission_index: Some(submission_index), + timeout: None, + }) { + Ok(_) => self.native_screen_lease_retirements.release_front(), + Err(error) => { + tracing::debug!(%error, "GPU stopped before native screen lease retirement"); + self.native_screen_lease_retirements.release_front(); + } + } + } + } + fn pending_preview_readback(&self) -> Option<&PendingPreviewReadback> { self.frame_in_flight .as_ref() @@ -3142,6 +3277,13 @@ impl fmt::Debug for GpuSparkleFlinger { } } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +impl Drop for GpuSparkleFlinger { + fn drop(&mut self) { + self.wait_for_native_screen_lease_retirements(); + } +} + impl GpuCompositorSurfaceSet { fn finish_pending_uploads(&mut self, submission_index: wgpu::SubmissionIndex) { self.pending_upload_buffers.clear(); diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs index 966f1d3d9..6fc04c56b 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/compositor.rs @@ -35,6 +35,8 @@ use super::{ ScreenUploadContentKey, padded_bytes_per_row, texture_extent, }; use crate::performance::CompositorBackendKind; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use crate::render_thread::producer_queue::MacosScreenTextureLease; #[cfg(any( target_os = "windows", all(target_os = "macos", feature = "screen-capture") @@ -201,6 +203,8 @@ impl GpuSparkleFlinger { requires_cpu_sampling_canvas, preview_surface_request, None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + Vec::new(), None, ); } @@ -305,6 +309,15 @@ impl GpuSparkleFlinger { )?; let pending_output_submission = self.supersede_frame_in_flight("current output readback restaged"); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let (pending_output_submission, native_screen_leases) = pending_output_submission + .map_or_else( + || (None, Vec::new()), + |stashed| (Some(stashed.encoder), stashed.native_screen_leases), + ); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] + let pending_output_submission = + pending_output_submission.map(|stashed| stashed.encoder); if preview_surface_request.is_some() && !requires_cpu_sampling_canvas { self.ready_preview_surface = None; } else { @@ -318,6 +331,8 @@ impl GpuSparkleFlinger { requires_cpu_sampling_canvas, preview_surface_request, pending_output_submission, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, prepared_preview_surface, ); } @@ -403,6 +418,8 @@ impl GpuSparkleFlinger { .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("SparkleFlinger GPU compose"), }); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let mut native_screen_leases = Vec::new(); let mut use_front_as_current = true; let mut uploaded_screen_frames = uploaded_screen_frame_scratch @@ -424,6 +441,8 @@ impl GpuSparkleFlinger { &mut surfaces.source_copy_bind_groups, &mut encoder, &first_layer.frame, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + &mut native_screen_leases, #[cfg(test)] &mut surfaces.front_upload_count, ); @@ -446,6 +465,8 @@ impl GpuSparkleFlinger { first_layer, first_uploaded_screen_frame, true, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + &mut native_screen_leases, ); if let Err(error) = compose_result { drop(uploaded_screen_frames); @@ -469,6 +490,8 @@ impl GpuSparkleFlinger { layer, uploaded_screen_frame, use_front_as_current, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + &mut native_screen_leases, ); if let Err(error) = compose_result { drop(uploaded_screen_frames); @@ -495,6 +518,13 @@ impl GpuSparkleFlinger { self.output_generation = self.output_generation.saturating_add(1); self.cached_sample_result = None; if !requires_cpu_sampling_canvas && !requires_preview_surface { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.stage_frame_in_flight_with_native_screen_leases( + encoder, + None, + native_screen_leases, + ); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] self.stage_frame_in_flight(encoder, None); return Ok(gpu_composed_without_surfaces()); } @@ -506,7 +536,9 @@ impl GpuSparkleFlinger { { let cached_surface = cached.surface.clone(); let submission_index = self.queue.submit(Some(encoder.finish())); - self.finish_pending_uploads(submission_index); + self.finish_pending_uploads(submission_index.clone()); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases(submission_index, native_screen_leases); self.release_retired_uniform_slots(); return Ok(gpu_composed_from_surface( cached_surface, @@ -521,6 +553,8 @@ impl GpuSparkleFlinger { requires_cpu_sampling_canvas, preview_surface_request, Some(encoder), + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, prepared_preview_surface, ) } @@ -779,6 +813,9 @@ impl GpuSparkleFlinger { requires_cpu_sampling_canvas: bool, preview_surface_request: Option, encoder: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: Vec< + MacosScreenTextureLease, + >, prepared_preview_surface: Option, ) -> Result { if requires_cpu_sampling_canvas { @@ -789,11 +826,24 @@ impl GpuSparkleFlinger { // sampler through the one-frame readback latch. if readback_key.is_some() { if let Some(encoder) = encoder { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.stage_frame_in_flight_with_native_screen_leases( + encoder, + None, + native_screen_leases, + ); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] self.stage_frame_in_flight(encoder, None); } return Ok(gpu_composed_without_surfaces()); } - return self.latch_sampling_surface_readback(width, height, encoder); + return self.latch_sampling_surface_readback( + width, + height, + encoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, + ); } let Some(current_output) = self.current_output else { anyhow::bail!("GPU readback requested without a composed output surface"); @@ -808,10 +858,19 @@ impl GpuSparkleFlinger { request, cache_as_full_size, encoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, prepared_preview_surface, ); } if let Some(encoder) = encoder { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.stage_frame_in_flight_with_native_screen_leases( + encoder, + None, + native_screen_leases, + ); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] self.stage_frame_in_flight(encoder, None); } Ok(gpu_composed_without_surfaces()) @@ -838,6 +897,9 @@ impl GpuSparkleFlinger { width: u32, height: u32, encoder: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: Vec< + MacosScreenTextureLease, + >, ) -> Result { self.resolve_pending_sampling_readback(); let latched = self @@ -846,7 +908,13 @@ impl GpuSparkleFlinger { .as_ref() .filter(|latched| latched.width == width && latched.height == height) .map(|latched| latched.surface.clone()); - self.stage_sampling_surface_readback(width, height, encoder)?; + self.stage_sampling_surface_readback( + width, + height, + encoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, + )?; Ok(match latched { Some(surface) => gpu_composed_from_surface(surface, true), None => gpu_composed_without_surfaces(), @@ -935,6 +1003,9 @@ impl GpuSparkleFlinger { width: u32, height: u32, encoder: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] mut native_screen_leases: Vec< + MacosScreenTextureLease, + >, ) -> Result<()> { // A staged preview readback shares the deferred-submission slot. // Route it through the preview machinery first so its buffer map @@ -949,14 +1020,32 @@ impl GpuSparkleFlinger { .has_pending_output_submission() .then(|| self.supersede_frame_in_flight("sampling readback chained")) .flatten(); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] let encoder = match (encoder, stashed) { (Some(encoder), Some(stashed)) => { // Submit the stashed encoder first so its work stays ordered // ahead of the compose encoder we are extending. - self.queue.submit(Some(stashed.finish())); + let submission_index = self.queue.submit(Some(stashed.encoder.finish())); + self.retire_native_screen_leases(submission_index, stashed.native_screen_leases); Some(encoder) } - (Some(encoder), None) | (None, Some(encoder)) => Some(encoder), + (Some(encoder), None) => Some(encoder), + (None, Some(stashed)) => { + native_screen_leases.extend(stashed.native_screen_leases); + Some(stashed.encoder) + } + (None, None) => None, + }; + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] + let encoder = match (encoder, stashed) { + (Some(encoder), Some(stashed)) => { + // Submit the stashed encoder first so its work stays ordered + // ahead of the compose encoder we are extending. + self.queue.submit(Some(stashed.encoder.finish())); + Some(encoder) + } + (Some(encoder), None) => Some(encoder), + (None, Some(stashed)) => Some(stashed.encoder), (None, None) => None, }; @@ -985,7 +1074,11 @@ impl GpuSparkleFlinger { || height == 0 || source_texture.is_none() { - self.submit_sampling_encoder(encoder); + self.submit_sampling_encoder( + encoder, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, + ); return Ok(()); } let Some(source_texture) = source_texture else { @@ -1027,6 +1120,8 @@ impl GpuSparkleFlinger { let readback = buffers.readbacks[slot].clone(); let submission_index = self.queue.submit(Some(encoder.finish())); self.finish_pending_uploads(submission_index.clone()); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases(submission_index.clone(), native_screen_leases); self.release_retired_uniform_slots(); let (sender, receiver) = mpsc::channel::>(); readback @@ -1046,10 +1141,18 @@ impl GpuSparkleFlinger { Ok(()) } - fn submit_sampling_encoder(&mut self, encoder: Option) { + fn submit_sampling_encoder( + &mut self, + encoder: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: Vec< + MacosScreenTextureLease, + >, + ) { if let Some(encoder) = encoder { let submission_index = self.queue.submit(Some(encoder.finish())); - self.finish_pending_uploads(submission_index); + self.finish_pending_uploads(submission_index.clone()); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases(submission_index, native_screen_leases); self.release_retired_uniform_slots(); } } @@ -1141,6 +1244,9 @@ fn compose_layer_into_gpu( layer: &CompositionLayer, uploaded_screen_frame: Option<&GpuTextureFrame>, use_front_as_current: bool, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: &mut Vec< + MacosScreenTextureLease, + >, ) -> Result<()> { let shader_mode = if layer.mode == CompositionMode::Replace && layer.opacity >= 1.0 { ComposeShaderMode::Replace @@ -1189,6 +1295,8 @@ fn compose_layer_into_gpu( &mut surfaces.source_copy_bind_groups, frame, output, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, ); set_texture_contents( surfaces, @@ -1247,6 +1355,10 @@ fn compose_layer_into_gpu( surfaces.compose_dispatch_count = surfaces.compose_dispatch_count.saturating_add(1); } if let Some(frame) = gpu_frame.as_ref() { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + if let Some(lease) = frame.macos_screen_lease() { + native_screen_leases.push(lease); + } if uploaded_screen_frame.is_none() { record_gpu_source_upload_skipped(); } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/display_finalize.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/display_finalize.rs index 76abe395b..8898175e6 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/display_finalize.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/display_finalize.rs @@ -386,6 +386,8 @@ impl GpuSparkleFlinger { }); let scene_gpu = gpu_source_frame(scene); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let mut native_screen_leases = Vec::new(); prepare_display_source_texture( device, queue, @@ -396,6 +398,8 @@ impl GpuSparkleFlinger { scene, scene_gpu.as_ref(), "SparkleFlinger Display Scene Source", + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + &mut native_screen_leases, #[cfg(test)] &mut surfaces.scene_upload_count, ); @@ -410,10 +414,23 @@ impl GpuSparkleFlinger { face, face_gpu.as_ref(), "SparkleFlinger Display Face Source", + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + &mut native_screen_leases, #[cfg(test)] &mut surfaces.face_upload_count, ); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + for frame in [&scene_gpu, &face_gpu] + .into_iter() + .flatten() + .filter(|frame| !frame.needs_display_source_copy()) + { + if let Some(lease) = frame.macos_screen_lease() { + native_screen_leases.push(lease); + } + } + let scene_view = scene_gpu .as_ref() .filter(|frame| !frame.needs_display_source_copy()) @@ -540,10 +557,12 @@ impl GpuSparkleFlinger { surfaces.yuv_layout, used_bytes, mapped_bytes, - submission_index, + submission_index.clone(), readback_buffer, readback_slot, )); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases(submission_index, native_screen_leases); self.release_retired_uniform_slots(); Ok(GpuDisplayFinalizeDispatch::Pending(pending)) } diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/preview.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/preview.rs index 26330374a..9d42a2388 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/preview.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/preview.rs @@ -211,12 +211,25 @@ impl GpuSparkleFlinger { if self.pending_preview_readback().is_none() { return Ok(()); } - let (frame_in_flight, queue) = (&mut self.frame_in_flight, &self.queue); - let submission_index = frame_in_flight - .as_mut() - .and_then(|frame| frame.submit(queue)); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + let mut native_screen_leases = Vec::new(); + let submission_index = { + let frame_in_flight = &mut self.frame_in_flight; + let submission_index = frame_in_flight + .as_mut() + .and_then(|frame| frame.submit(&self.queue)); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + if submission_index.is_some() + && let Some(frame) = frame_in_flight.as_mut() + { + native_screen_leases = frame.take_native_screen_leases(); + } + submission_index + }; if let Some(submission_index) = submission_index { - self.finish_pending_uploads(submission_index); + self.finish_pending_uploads(submission_index.clone()); + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.retire_native_screen_leases(submission_index, native_screen_leases); self.release_retired_uniform_slots(); } if self.pending_preview_map.is_some() { @@ -562,6 +575,9 @@ impl GpuSparkleFlinger { request: PreviewSurfaceRequest, cache_as_full_size: bool, encoder: Option, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: Vec< + super::MacosScreenTextureLease, + >, prepared_surface_change: Option, ) -> Result { if !cache_as_full_size @@ -621,10 +637,10 @@ impl GpuSparkleFlinger { .map(|pending| match &pending.readback { PendingPreviewReadback::PreviewBuffer { slot, .. } => *slot, }); - if let Some(encoder) = + if let Some(stashed) = self.supersede_frame_in_flight("preview restaged over retained frame") { - drop(encoder); + drop(stashed); self.discard_pending_uploads(); } let preview_surfaces = self @@ -719,6 +735,18 @@ impl GpuSparkleFlinger { u64::from(preview_surfaces.padded_bytes_per_row) * u64::from(request.height), ); } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.stage_frame_in_flight_with_native_screen_leases( + encoder, + Some(PendingPreviewReadback::PreviewBuffer { + request, + readback_key, + cache_as_full_size, + slot: readback_slot, + }), + native_screen_leases, + ); + #[cfg(not(all(target_os = "macos", feature = "screen-capture")))] self.stage_frame_in_flight( encoder, Some(PendingPreviewReadback::PreviewBuffer { diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/sampler.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/sampler.rs index c709c09c2..0062a7a27 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/sampler.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/sampler.rs @@ -121,6 +121,13 @@ impl GpuSparkleFlinger { .clone() .or(previous_submission) { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + if let Some(frame) = frame_in_flight.as_mut() { + self.retire_native_screen_leases( + submission_index.clone(), + frame.take_native_screen_leases(), + ); + } if let Some(pending_preview_readback) = frame_in_flight .as_mut() .and_then(FrameInFlight::take_preview_readback) diff --git a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs index 361df09c3..dbdeceabf 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/source.rs @@ -12,6 +12,8 @@ use super::{ GpuCompositorSurfaceSet, GpuCompositorTexture, GpuDisplaySourceTexture, PendingUploadBuffers, SOURCE_COPY_PARAM_BYTES, texture_extent, }; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use crate::render_thread::producer_queue::MacosScreenTextureLease; #[cfg(any( target_os = "windows", all(target_os = "macos", feature = "screen-capture") @@ -130,6 +132,9 @@ pub(super) fn prepare_display_source_texture( frame: &ProducerFrame, gpu_frame: Option<&GpuSourceFrame<'_>>, label: &'static str, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: &mut Vec< + MacosScreenTextureLease, + >, #[cfg(test)] upload_count: &mut usize, ) { let Some(gpu_frame) = gpu_frame else { @@ -171,6 +176,8 @@ pub(super) fn prepare_display_source_texture( &mut source.bind_group_cache, gpu_frame, &source.texture, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, ); source.cached_upload = None; source.cached_gpu_copy = Some(next_copy); @@ -319,6 +326,15 @@ impl GpuSourceFrame<'_> { Self::Texture(frame) => frame.native_screen_cache_lease(), } } + + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + pub(super) fn macos_screen_lease(&self) -> Option { + match self { + #[cfg(feature = "servo-gpu-import")] + Self::Imported(_) => None, + Self::Texture(frame) => frame.macos_screen_lease.clone(), + } + } } pub(super) fn gpu_source_frame(frame: &ProducerFrame) -> Option> { @@ -342,6 +358,9 @@ pub(super) fn copy_frame_into_output_texture( bind_group_cache: &mut SourceCopyBindGroupCache, encoder: &mut wgpu::CommandEncoder, frame: &ProducerFrame, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: &mut Vec< + MacosScreenTextureLease, + >, #[cfg(test)] upload_count: &mut usize, ) { if let Some(frame) = gpu_source_frame(frame) { @@ -355,6 +374,8 @@ pub(super) fn copy_frame_into_output_texture( bind_group_cache, &frame, output, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, ); *cached_upload = None; return; @@ -379,7 +400,14 @@ pub(super) fn copy_gpu_source_frame_into_texture( bind_group_cache: &mut SourceCopyBindGroupCache, frame: &GpuSourceFrame<'_>, output: &GpuCompositorTexture, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] native_screen_leases: &mut Vec< + MacosScreenTextureLease, + >, ) { + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + if let Some(lease) = frame.macos_screen_lease() { + native_screen_leases.push(lease); + } if frame.requires_shader_copy_to(&output.texture) { let params_offset = encode_source_copy_params_upload( device, From ac563f6535b77b7ccb5bbe41d0723d450147d261 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 11:25:47 -0700 Subject: [PATCH 097/144] fix(macos): fence interrupted capture recovery Serialize demand changes, candidate installation, and stream start under the native stream state lock. A stopped or superseded candidate cannot restart. Recovery advances exactly one session epoch, and stale interruptions cannot replace a newer live status. Co-Authored-By: Chandrasekhar (Codex) --- crates/hypercolor-macos-capture/src/native.rs | 541 ++++++++++++++++-- 1 file changed, 491 insertions(+), 50 deletions(-) diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs index 555bfbdf6..a05e20cff 100644 --- a/crates/hypercolor-macos-capture/src/native.rs +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -791,6 +791,8 @@ struct NativeStream { filter: NativeFilter, selection: MacosCaptureSelection, source_id: Arc, + request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, worker: LatestSampleWorker>, _output: Retained, _queue: DispatchRetained, @@ -884,6 +886,8 @@ impl NativeStream { filter: NativeFilter(retained_filter), selection, source_id, + request, + reserve_pool: Arc::clone(reserve_pool), worker, _output: output, _queue: queue, @@ -928,6 +932,19 @@ impl NativeStream { .join() .map_err(|_| MacosCaptureError::CaptureWorkerPanicked) } + + fn discard_unstarted(self) -> Result<(), MacosCaptureError> { + self.retire_after_native_stop() + } + + fn interruption_restage(&self, selection_revision: u64) -> InterruptedRestagePlan { + InterruptedRestagePlan { + recovery: InterruptedRestage::interrupted(self.epoch(), selection_revision), + filter: self.filter.clone(), + request: self.request, + reserve_pool: Arc::clone(&self.reserve_pool), + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -937,12 +954,126 @@ enum StreamRole { Stale, } +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InterruptionRecoveryPhase { + Interrupted, + Starting { epoch: u64 }, + Live { epoch: u64 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InterruptedRestage { + interrupted_epoch: u64, + selection_revision: u64, + restage_epoch: Option, +} + +impl InterruptedRestage { + const fn interrupted(interrupted_epoch: u64, selection_revision: u64) -> Self { + Self { + interrupted_epoch, + selection_revision, + restage_epoch: None, + } + } + + #[cfg(test)] + const fn phase(self) -> InterruptionRecoveryPhase { + match self.restage_epoch { + Some(epoch) => InterruptionRecoveryPhase::Starting { epoch }, + None => InterruptionRecoveryPhase::Interrupted, + } + } + + const fn can_schedule( + self, + capture_active: bool, + active_epoch: u64, + selection_revision: u64, + ) -> bool { + self.restage_epoch.is_none() + && capture_active + && active_epoch == 0 + && self.selection_revision == selection_revision + } + + fn can_begin(self, state: &StreamState, shared: &SessionShared) -> bool { + self.can_schedule( + shared.capture_active(), + shared.current_epoch(), + state.selection_revision, + ) && state.current.is_none() + && state.candidate.is_none() + && state.staging_epoch.is_none() + } + + const fn schedule(mut self, epoch: u64) -> Option { + if self.restage_epoch.is_some() || epoch <= self.interrupted_epoch { + return None; + } + self.restage_epoch = Some(epoch); + Some(self) + } + + fn matches(self, epoch: u64) -> bool { + self.restage_epoch == Some(epoch) + } + + #[cfg(test)] + fn complete(self, epoch: u64) -> Option { + self.matches(epoch) + .then_some(InterruptionRecoveryPhase::Live { epoch }) + } +} + +#[derive(Clone)] +struct InterruptedRestagePlan { + recovery: InterruptedRestage, + filter: NativeFilter, + request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CandidateStage { + epoch: u64, + selection_revision: u64, + recovery_current_epoch: Option, + recovery: Option, +} + +impl CandidateStage { + fn is_current(self, state: &StreamState, shared: &SessionShared) -> bool { + shared.capture_active() + && state.staging_epoch == Some(self.epoch) + && state.selection_revision == self.selection_revision + && state.candidate.is_none() + && self + .recovery_current_epoch + .is_none_or(|current_epoch| shared.current_epoch() == current_epoch) + && self.recovery.is_none_or(|recovery| { + state.current.is_none() + && state.pending_interruption == Some(recovery) + && recovery.matches(self.epoch) + }) + } +} + +struct StreamRemoval { + role: StreamRole, + stream: Option, + selection_revision: u64, +} + #[derive(Default)] struct StreamState { current: Option, candidate: Option, selected_filter: Option, selection_revision: u64, + pending_interruption: Option, + staging_epoch: Option, } struct StreamSlot { @@ -974,35 +1105,117 @@ impl StreamSlot { request: MacosStreamRequest, reserve_pool: &PoolReservationFactory, epoch: u64, - ) -> Result<(), MacosCaptureError> { - let candidate = NativeStream::prepare( + recovery: Option, + ) -> Result { + let Some((stage, replaced)) = self.reserve_candidate_stage(epoch, recovery)? else { + return Ok(false); + }; + if let Some(replaced) = replaced { + self.stop_stream(replaced); + } + let candidate = match NativeStream::prepare( filter, request, epoch, Arc::clone(&self.shared), Arc::downgrade(self), reserve_pool, - )?; + ) { + Ok(candidate) => candidate, + Err(error) => { + self.cancel_candidate_stage(stage); + return Err(error); + } + }; + self.start_candidate_stage(candidate, stage) + } + + fn reserve_candidate_stage( + &self, + epoch: u64, + recovery: Option, + ) -> Result)>, MacosCaptureError> { + let mut state = lock(&self.state); + if !self.shared.capture_active() { + return Ok(None); + } + let current_epoch = self.shared.current_epoch(); + let recovery = match recovery { + Some(recovery) => { + if !recovery.can_begin(&state, &self.shared) { + return Ok(None); + } + let recovery = recovery + .schedule(epoch) + .expect("interrupted recovery schedules exactly one later epoch"); + state.pending_interruption = Some(recovery); + self.shared + .set_status(MacosProtectedSourceState::Interrupted); + Some(recovery) + } + None => { + state.selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.pending_interruption = None; + None + } + }; + let stage = CandidateStage { + epoch, + selection_revision: state.selection_revision, + recovery_current_epoch: recovery.map(|_| current_epoch), + recovery, + }; + state.staging_epoch = Some(epoch); + Ok(Some((stage, state.candidate.take()))) + } + + fn cancel_candidate_stage(&self, stage: CandidateStage) { + let mut state = lock(&self.state); + if state.staging_epoch == Some(stage.epoch) { + state.staging_epoch = None; + } + if stage + .recovery + .is_some_and(|recovery| state.pending_interruption == Some(recovery)) + { + state.pending_interruption = None; + } + } + + fn start_candidate_stage( + self: &Arc, + candidate: NativeStream, + stage: CandidateStage, + ) -> Result { let stream = candidate.stream.clone(); - let replaced = { + let mut candidate = Some(candidate); + let discarded = { let mut state = lock(&self.state); - state.selection_revision = state - .selection_revision - .checked_add(1) - .ok_or(MacosCaptureError::SequenceExhausted)?; - state.candidate.replace(candidate) + if !stage.is_current(&state, &self.shared) { + true + } else { + state.candidate = candidate.take(); + state.staging_epoch = None; + self.shared.set_status(MacosProtectedSourceState::Starting); + start_stream( + &stream, + stage.epoch, + Arc::downgrade(self), + Arc::clone(&self.shared), + ); + false + } }; - if let Some(replaced) = replaced { - self.stop_stream(replaced); + if discarded { + candidate + .expect("uninstalled candidate remains owned") + .discard_unstarted()?; + return Ok(false); } - self.shared.set_status(MacosProtectedSourceState::Starting); - start_stream( - &stream, - epoch, - Arc::downgrade(self), - Arc::clone(&self.shared), - ); - Ok(()) + Ok(true) } fn activate( @@ -1023,6 +1236,10 @@ impl StreamSlot { return false; }; let previous = state.current.replace(candidate); + let recovered = state + .pending_interruption + .take_if(|recovery| recovery.matches(epoch)) + .is_some(); state.selected_filter = state.current.as_ref().map(|current| current.filter.clone()); if let Some(current) = &state.current { self.shared.confirm_selection( @@ -1033,6 +1250,9 @@ impl StreamSlot { ); } self.shared.activate_epoch(epoch); + if recovered { + self.shared.set_status(MacosProtectedSourceState::Live); + } previous }; if let Some(previous) = previous { @@ -1041,14 +1261,24 @@ impl StreamSlot { true } - fn remove(&self, epoch: u64) -> (StreamRole, Option) { + fn remove(&self, epoch: u64) -> StreamRemoval { let mut state = lock(&self.state); if state .candidate .as_ref() .is_some_and(|candidate| candidate.epoch() == epoch) { - return (StreamRole::Candidate, state.candidate.take()); + if state + .pending_interruption + .is_some_and(|recovery| recovery.matches(epoch)) + { + state.pending_interruption = None; + } + return StreamRemoval { + role: StreamRole::Candidate, + stream: state.candidate.take(), + selection_revision: state.selection_revision, + }; } if state .current @@ -1058,9 +1288,17 @@ impl StreamSlot { let current = state.current.take(); self.shared.activate_epoch(0); self.shared.clear_tahoe_selection(); - return (StreamRole::Current, current); + return StreamRemoval { + role: StreamRole::Current, + stream: current, + selection_revision: state.selection_revision, + }; + } + StreamRemoval { + role: StreamRole::Stale, + stream: None, + selection_revision: state.selection_revision, } - (StreamRole::Stale, None) } fn accepts_epoch(&self, epoch: u64) -> bool { @@ -1079,6 +1317,14 @@ impl StreamSlot { lock(&self.state).current.is_some() } + fn has_newer_lifecycle(&self, selection_revision: u64) -> bool { + let state = lock(&self.state); + state.selection_revision != selection_revision + || state.current.is_some() + || state.candidate.is_some() + || state.staging_epoch.is_some() + } + fn active_identity(&self) -> Option<(Arc, u64)> { lock(&self.state) .current @@ -1103,6 +1349,8 @@ impl StreamSlot { .selection_revision .checked_add(1) .ok_or(MacosCaptureError::SequenceExhausted)?; + state.pending_interruption = None; + state.staging_epoch = None; state.selected_filter = Some(NativeFilter(filter)); drop(state); self.shared.set_unconfirmed_selection(selection); @@ -1183,10 +1431,32 @@ impl StreamSlot { .map(|current| current.stream.clone()) } - fn stop(&self) { + fn stage_interrupted_recovery( + self: &Arc, + plan: InterruptedRestagePlan, + ) -> Result { + let epoch = self.allocate_epoch()?; + self.stage_candidate( + &plan.filter.0, + plan.request, + &plan.reserve_pool, + epoch, + Some(plan.recovery), + ) + } + + fn set_capture_active(&self, active: bool) -> bool { let (current, candidate) = { let mut state = lock(&self.state); + if self.shared.set_capture_active(active) == active { + return false; + } + if active { + return true; + } state.selection_revision = state.selection_revision.saturating_add(1); + state.pending_interruption = None; + state.staging_epoch = None; if state.current.is_none() && state.selected_filter.is_none() && let Some(candidate) = state.candidate.as_ref() @@ -1203,6 +1473,7 @@ impl StreamSlot { if let Some(current) = current { self.stop_stream(current); } + true } fn stop_stream(&self, stream: NativeStream) { @@ -1250,25 +1521,55 @@ fn handle_stream_error( shared: &SessionShared, error: &NSError, ) { - let (role, retired) = streams - .upgrade() - .map_or((StreamRole::Stale, None), |streams| streams.remove(epoch)); - if let Some(retired) = retired + let Some(streams) = streams.upgrade() else { + return; + }; + let removal = streams.remove(epoch); + let state = classify_stream_error(error); + let role = removal.role; + let selection_revision = removal.selection_revision; + let recovery = (removal.role == StreamRole::Current + && state == MacosProtectedSourceState::Interrupted) + .then(|| { + removal + .stream + .as_ref() + .map(|stream| stream.interruption_restage(removal.selection_revision)) + }) + .flatten(); + if let Some(retired) = removal.stream && let Err(worker_error) = retired.retire_after_native_stop() { shared.counters.record_drop(&worker_error); } + if let Some(recovery) = recovery { + let stream_error = native_error("ScreenCaptureKit stream", error); + match streams.stage_interrupted_recovery(recovery) { + Ok(true) => shared.publish_recoverable_error(stream_error), + Ok(false) => { + if !shared.capture_active() || streams.has_newer_lifecycle(selection_revision) { + shared.publish_recoverable_error(stream_error); + } + } + Err(stage_error) => { + shared.publish_recoverable_error(stream_error); + handle_filter_error(&streams, shared, stage_error); + } + } + return; + } let preserve_current = match role { - StreamRole::Candidate - if streams - .upgrade() - .is_some_and(|streams| streams.has_current()) => - { + StreamRole::Candidate if streams.has_current() => { shared.set_status(MacosProtectedSourceState::Live); true } + StreamRole::Current + if !shared.capture_active() || streams.has_newer_lifecycle(selection_revision) => + { + true + } StreamRole::Candidate | StreamRole::Current => { - shared.set_status(classify_stream_error(error)); + shared.set_status(state); false } StreamRole::Stale => return, @@ -1291,16 +1592,20 @@ fn handle_fatal_stream_error( let Some(streams) = streams.upgrade() else { return; }; - let (role, retired) = streams.remove(epoch); - let preserve_current = role == StreamRole::Candidate && streams.has_current(); + let removal = streams.remove(epoch); + let preserve_current = removal.role == StreamRole::Candidate && streams.has_current(); + let superseded_current = removal.role == StreamRole::Current + && (!shared.capture_active() || streams.has_newer_lifecycle(removal.selection_revision)); if preserve_current { shared.set_status(MacosProtectedSourceState::Live); shared.publish_recoverable_error(error); - } else if role != StreamRole::Stale { + } else if superseded_current { + shared.publish_recoverable_error(error); + } else if removal.role != StreamRole::Stale { shared.set_status(MacosProtectedSourceState::Failed); shared.publish_error(error); } - let Some(retired) = retired else { + let Some(retired) = removal.stream else { return; }; let stop_shared = Arc::clone(&shared); @@ -1436,11 +1741,10 @@ impl PickerObserver { } fn set_active(&self, active: bool) { - if self.ivars().shared.set_capture_active(active) == active { + if !self.ivars().streams.set_capture_active(active) { return; } if !active { - self.ivars().streams.stop(); let status = if self.ivars().streams.has_selection() { MacosProtectedSourceState::ReadyIdle } else { @@ -1459,8 +1763,7 @@ impl PickerObserver { } fn stop(&self) { - self.ivars().shared.set_capture_active(false); - self.ivars().streams.stop(); + self.ivars().streams.set_capture_active(false); } } @@ -1489,7 +1792,7 @@ fn stage_filter( ) { let result = streams .allocate_epoch() - .and_then(|epoch| streams.stage_candidate(filter, request, reserve_pool, epoch)); + .and_then(|epoch| streams.stage_candidate(filter, request, reserve_pool, epoch, None)); if let Err(error) = result { handle_filter_error(streams, shared, error); } @@ -2837,16 +3140,16 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use super::{ - MacosCaptureColorimetry, MacosCaptureDynamicRange, MacosCaptureError, - MacosCapturePixelFormat, MacosColorPrimaries, MacosColorRange, MacosConfiguredStream, - MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosPixelExtent, - MacosProtectedSourceState, MacosRuntimeCapability, MacosStreamDeliveryRejection, - MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, - MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTransferFunction, + CandidateStage, InterruptedRestage, InterruptionRecoveryPhase, MacosCaptureColorimetry, + MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, MacosColorPrimaries, + MacosColorRange, MacosConfiguredStream, MacosDeliveredFrameMetadata, MacosHostArchitecture, + MacosPixelExtent, MacosProtectedSourceState, MacosRuntimeCapability, + MacosStreamDeliveryRejection, MacosStreamDeliveryState, MacosStreamDeliveryValidator, + MacosStreamPreset, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTransferFunction, MacosValidatedStreamDelivery, PoolBackingLifetime, PoolObservation, SCCaptureDynamicRange, SCStreamConfiguration, SCStreamConfigurationPreset, ScreenshotCaptureBackend, ScreenshotFilterHandle, ScreenshotIdentityFence, ScreenshotImageCompletion, - ScreenshotTransactionSnapshot, SessionShared, SysctlI32Value, + ScreenshotTransactionSnapshot, SessionShared, StreamState, SysctlI32Value, capture_capabilities_from_probes, capture_dynamic_range, classify_delivery_error, color_range_from_fourcc, conservative_pool_quote, execute_screenshot_transaction, session_selection_source_id, with_admitted_surface, @@ -2955,6 +3258,144 @@ mod tests { screenshot_capture_selector: MacosRuntimeCapability::Absent, }; + struct StreamSlotStartFixture { + shared: SessionShared, + state: StreamState, + started: Vec, + discarded: Vec, + } + + impl StreamSlotStartFixture { + fn new(current_epoch: u64, selection_revision: u64) -> Self { + let shared = SessionShared::new( + MacosProtectedSourceState::Live, + super::MacosCaptureSelector::Auto, + MacosTahoeCapabilities { + content_tone_mapping_info: MacosRuntimeCapability::Absent, + screenshot_api: MacosRuntimeCapability::Absent, + }, + ); + shared.set_capture_active(true); + shared.activate_epoch(current_epoch); + Self { + shared, + state: StreamState { + selection_revision, + ..StreamState::default() + }, + started: Vec::new(), + discarded: Vec::new(), + } + } + + fn reserve_regular(&mut self, epoch: u64) -> CandidateStage { + self.state.selection_revision += 1; + self.state.pending_interruption = None; + self.state.staging_epoch = Some(epoch); + CandidateStage { + epoch, + selection_revision: self.state.selection_revision, + recovery_current_epoch: None, + recovery: None, + } + } + + fn stop_demand(&mut self) { + self.shared.set_capture_active(false); + self.shared.activate_epoch(0); + self.state.selection_revision += 1; + self.state.pending_interruption = None; + self.state.staging_epoch = None; + } + + fn activate_newer_session(&mut self, epoch: u64) { + self.shared.activate_epoch(epoch); + self.state.staging_epoch = None; + } + + fn try_start(&mut self, stage: CandidateStage) -> bool { + if !stage.is_current(&self.state, &self.shared) { + self.discarded.push(stage.epoch); + return false; + } + self.state.staging_epoch = None; + self.started.push(stage.epoch); + true + } + } + + #[test] + fn interrupted_restage_transitions_once_from_interrupted_to_live() { + let recovery = InterruptedRestage::interrupted(41, 9); + assert_eq!(recovery.phase(), InterruptionRecoveryPhase::Interrupted); + assert!(recovery.can_schedule(true, 0, 9)); + + let recovery = recovery + .schedule(42) + .expect("the next session epoch should schedule one recovery restage"); + assert_eq!( + recovery.phase(), + InterruptionRecoveryPhase::Starting { epoch: 42 } + ); + assert_eq!( + recovery.complete(42), + Some(InterruptionRecoveryPhase::Live { epoch: 42 }) + ); + assert_eq!(recovery.complete(43), None); + assert_eq!(recovery.schedule(43), None); + } + + #[test] + fn interrupted_restage_cancels_when_capture_demand_reaches_zero() { + let recovery = InterruptedRestage::interrupted(41, 9); + + assert!(!recovery.can_schedule(false, 0, 9)); + } + + #[test] + fn interrupted_restage_rejects_newer_selection_and_session_epochs() { + let recovery = InterruptedRestage::interrupted(41, 9); + + assert!(!recovery.can_schedule(true, 0, 10)); + assert!(!recovery.can_schedule(true, 42, 9)); + } + + #[test] + fn stream_slot_start_fixture_discards_a_candidate_after_demand_stops() { + let mut fixture = StreamSlotStartFixture::new(41, 9); + let stage = fixture.reserve_regular(42); + + fixture.stop_demand(); + + assert!(!fixture.try_start(stage)); + assert!(fixture.started.is_empty()); + assert_eq!(fixture.discarded, vec![42]); + } + + #[test] + fn stream_slot_start_fixture_rejects_a_repick_before_the_old_start_runs() { + let mut fixture = StreamSlotStartFixture::new(41, 9); + let stale = fixture.reserve_regular(42); + let current = fixture.reserve_regular(43); + + assert!(!fixture.try_start(stale)); + assert!(fixture.try_start(current)); + assert_eq!(fixture.started, vec![43]); + assert_eq!(fixture.discarded, vec![42]); + } + + #[test] + fn stream_slot_start_fixture_never_regresses_a_newer_live_session_to_interrupted() { + let mut fixture = StreamSlotStartFixture::new(0, 9); + let recovery = InterruptedRestage::interrupted(41, 9); + + assert!(recovery.can_begin(&fixture.state, &fixture.shared)); + fixture.activate_newer_session(43); + + assert!(!recovery.can_begin(&fixture.state, &fixture.shared)); + assert_eq!(fixture.shared.status(), MacosProtectedSourceState::Live); + } + #[test] fn missing_arm64_and_translation_sysctls_resolve_native_intel_sdr() { let capabilities = capture_capabilities_from_probes( From b07ff6c9f03d102eee0526aee962918002166839 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 12:58:04 -0700 Subject: [PATCH 098/144] perf(macos): qualify Metal 4 native reduction Probe the exact Metal 4 facilities used by the native reducer. Benchmark the typed command path against the committed wgpu baseline. Adoption requires exact output parity and ten percent lower GPU reduction time at p95. Emit bounded decision artifacts for every qualified failure phase. Precommit feedback setup remains not run. Postcommit feedback proves hardware work began. Co-Authored-By: Nova (GPT-5 Codex) --- .../hypercolor-macos-gpu-interop/Cargo.toml | 11 + .../examples/bench_macos_reduction.rs | 1403 +++++++++++++++-- .../hypercolor-macos-gpu-interop/src/macos.rs | 45 +- .../hypercolor-macos-gpu-interop/src/stubs.rs | 26 +- .../tests/descriptor_tests.rs | 37 +- 5 files changed, 1422 insertions(+), 100 deletions(-) diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index 095ae3dd9..11e47d25a 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -54,14 +54,25 @@ objc2-core-video = { workspace = true, optional = true, features = [ objc2-io-surface = { workspace = true, features = ["std", "IOSurfaceRef", "IOSurfaceTypes", "objc2-core-foundation", "libc", "bitflags"] } objc2-metal = { workspace = true, features = [ "std", + "MTL4ArgumentTable", + "MTL4CommandAllocator", + "MTL4CommandBuffer", + "MTL4CommitFeedback", + "MTL4CommandEncoder", + "MTL4CommandQueue", + "MTL4ComputeCommandEncoder", "MTLAllocation", + "MTLBuffer", "MTLCommandBuffer", "MTLCommandEncoder", "MTLComputeCommandEncoder", "MTLComputePipeline", "MTLDevice", + "MTLEvent", + "MTLGPUAddress", "MTLLibrary", "MTLPixelFormat", + "MTLResidencySet", "MTLResource", "MTLTexture", "MTLTypes", diff --git a/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs b/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs index 77dd3b740..561962164 100644 --- a/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs +++ b/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs @@ -8,14 +8,17 @@ const DEFAULT_OUTPUT: Extent = Extent { width: 320, height: 180, }; -const DEFAULT_ITERATIONS: usize = 20; -const DEFAULT_WARMUP: usize = 3; +const DEFAULT_ITERATIONS: usize = 100; +const DEFAULT_WARMUP: usize = 10; +const MIN_ITERATIONS: usize = 100; +const MIN_WARMUP: usize = 10; const MAX_DIMENSION: u32 = 8_192; const MAX_PIXELS: u64 = 67_108_864; const MAX_ITERATIONS: usize = 10_000; const MAX_WARMUP: usize = 1_000; const MAX_OPTION_PAIRS: usize = 5; const BYTES_PER_PIXEL: u64 = 4; +const GPU_REDUCTION_METRIC: &str = "gpu_reduction_time"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Extent { @@ -142,10 +145,10 @@ impl Args { "--filter" => parsed.filter = Filter::parse(&value)?, "--iterations" => { parsed.iterations = - parse_bounded_usize(&value, "iterations", 1, MAX_ITERATIONS)?; + parse_bounded_usize(&value, "iterations", MIN_ITERATIONS, MAX_ITERATIONS)?; } "--warmup" => { - parsed.warmup = parse_bounded_usize(&value, "warmup", 0, MAX_WARMUP)?; + parsed.warmup = parse_bounded_usize(&value, "warmup", MIN_WARMUP, MAX_WARMUP)?; } _ => return Err(format!("unknown argument {argument}")), } @@ -187,21 +190,238 @@ struct Percentiles { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Metal4Decision { NotQualified { - missing_facilities: [Option<&'static str>; 5], + missing_facilities: [Option<&'static str>; 8], }, - NotImplemented, + NotMeasured(Option), + Adopt, + Reject(Metal4Rejection), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Metal4Rejection { + OutputMismatch, + BaselineMetricUnavailable, + InsufficientP95Improvement, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Metal4Evidence { + output_parity: bool, + wgpu_gpu_p95_ns: u128, + metal4_gpu_p95_ns: u128, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Metal4Failure { + QualifiedSetup, + Warmup, + BaselineMeasurement, + WgpuGpuIntervalUnavailable, + BaselineReadback, + BaselineParity, + BaselinePercentile, + TargetCreation, + ShaderCompilation, + ShaderEntryPoint, + PipelineCreation, + MetalDeviceAccess, + TargetTextureAccess, + SourcePlaneAccess, + CommandSetup, + DispatchOrCompletion, + CommitFeedback, + OutputReadback, + Metal4Percentile, +} + +impl Metal4Failure { + const fn code(self) -> &'static str { + match self { + Self::QualifiedSetup => "qualified_setup_failed", + Self::Warmup => "warmup_failed", + Self::BaselineMeasurement => "baseline_measurement_failed", + Self::WgpuGpuIntervalUnavailable => "wgpu_gpu_interval_unavailable", + Self::BaselineReadback => "baseline_readback_failed", + Self::BaselineParity => "baseline_parity_failed", + Self::BaselinePercentile => "baseline_percentile_failed", + Self::TargetCreation => "target_creation_failed", + Self::ShaderCompilation => "shader_compilation_failed", + Self::ShaderEntryPoint => "shader_entry_point_missing", + Self::PipelineCreation => "pipeline_creation_failed", + Self::MetalDeviceAccess => "metal_device_access_failed", + Self::TargetTextureAccess => "target_texture_access_failed", + Self::SourcePlaneAccess => "source_plane_access_failed", + Self::CommandSetup => "command_setup_failed", + Self::DispatchOrCompletion => "dispatch_or_completion_failed", + Self::CommitFeedback => "commit_feedback_failed", + Self::OutputReadback => "output_readback_failed", + Self::Metal4Percentile => "metal4_percentile_failed", + } + } + + const fn hardware_run(self) -> bool { + matches!( + self, + Self::DispatchOrCompletion + | Self::CommitFeedback + | Self::OutputReadback + | Self::Metal4Percentile + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Metal4Evaluation { + NotRun, + Failed(Metal4Failure), + Measured(Metal4Evidence), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Metal4Artifact { + hardware_attempted: bool, + hardware_run: bool, + status: &'static str, + decision: &'static str, + reason: &'static str, + failure: Option<&'static str>, + missing_facilities: [Option<&'static str>; 8], } fn metal4_decision( probe: hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe, + evaluation: Metal4Evaluation, ) -> Metal4Decision { - if probe.all_required_facilities() { - Metal4Decision::NotImplemented - } else { - Metal4Decision::NotQualified { + if !probe.all_required_facilities() { + return Metal4Decision::NotQualified { missing_facilities: probe.missing_facilities(), + }; + } + let evidence = match evaluation { + Metal4Evaluation::NotRun => return Metal4Decision::NotMeasured(None), + Metal4Evaluation::Failed(failure) => { + return Metal4Decision::NotMeasured(Some(failure)); + } + Metal4Evaluation::Measured(evidence) => evidence, + }; + if !evidence.output_parity { + return Metal4Decision::Reject(Metal4Rejection::OutputMismatch); + } + if evidence.wgpu_gpu_p95_ns == 0 { + return Metal4Decision::Reject(Metal4Rejection::BaselineMetricUnavailable); + } + let adoption_ceiling = evidence + .wgpu_gpu_p95_ns + .saturating_sub(evidence.wgpu_gpu_p95_ns.div_ceil(10)); + if evidence.metal4_gpu_p95_ns <= adoption_ceiling { + Metal4Decision::Adopt + } else { + Metal4Decision::Reject(Metal4Rejection::InsufficientP95Improvement) + } +} + +fn metal4_artifact( + probe: hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe, + evaluation: Metal4Evaluation, +) -> Metal4Artifact { + let hardware_run = match evaluation { + Metal4Evaluation::NotRun => false, + Metal4Evaluation::Failed(failure) => failure.hardware_run(), + Metal4Evaluation::Measured(_) => true, + }; + let hardware_attempted = matches!( + evaluation, + Metal4Evaluation::Failed(_) | Metal4Evaluation::Measured(_) + ); + match metal4_decision(probe, evaluation) { + Metal4Decision::NotQualified { missing_facilities } => Metal4Artifact { + hardware_attempted, + hardware_run, + status: "not_qualified", + decision: "not_evaluated", + reason: "required_facilities_unavailable", + failure: None, + missing_facilities, + }, + Metal4Decision::NotMeasured(failure) => Metal4Artifact { + hardware_attempted, + hardware_run, + status: "not_measured", + decision: "not_evaluated", + reason: failure.map_or("qualified_hardware_run_missing", Metal4Failure::code), + failure: failure.map(Metal4Failure::code), + missing_facilities: [None; 8], + }, + Metal4Decision::Adopt => Metal4Artifact { + hardware_attempted, + hardware_run, + status: "measured", + decision: "adopt", + reason: "exact_parity_and_p95_improvement_at_least_10_percent", + failure: None, + missing_facilities: [None; 8], + }, + Metal4Decision::Reject(reason) => Metal4Artifact { + hardware_attempted, + hardware_run, + status: "measured", + decision: "reject", + reason: match reason { + Metal4Rejection::OutputMismatch => "output_parity_mismatch", + Metal4Rejection::BaselineMetricUnavailable => "wgpu_p95_metric_unavailable", + Metal4Rejection::InsufficientP95Improvement => "p95_improvement_below_10_percent", + }, + failure: None, + missing_facilities: [None; 8], + }, + } +} + +#[cfg(any(target_os = "macos", test))] +fn write_metal4_artifact( + output: &mut impl std::io::Write, + probe: hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe, + evaluation: Metal4Evaluation, +) -> std::io::Result<()> { + writeln!(output, "metal4_registry_id={}", probe.metal_registry_id)?; + writeln!(output, "metal4_family={}", probe.metal4_family)?; + writeln!( + output, + "metal4_command_allocator={}", + probe.command_allocator + )?; + writeln!(output, "metal4_command_queue={}", probe.command_queue)?; + writeln!(output, "metal4_command_buffer={}", probe.command_buffer)?; + writeln!(output, "metal4_argument_table={}", probe.argument_table)?; + writeln!(output, "metal4_residency_set={}", probe.residency_set)?; + writeln!(output, "metal4_shared_event={}", probe.shared_event)?; + writeln!(output, "metal4_commit_feedback={}", probe.commit_feedback)?; + writeln!(output, "metal4_artifact_schema=spec76-v1")?; + let artifact = metal4_artifact(probe, evaluation); + writeln!( + output, + "metal4_hardware_attempted={}", + artifact.hardware_attempted + )?; + writeln!(output, "metal4_hardware_run={}", artifact.hardware_run)?; + writeln!(output, "metal4_status={}", artifact.status)?; + writeln!(output, "metal4_decision={}", artifact.decision)?; + writeln!(output, "metal4_decision_reason={}", artifact.reason)?; + if let Some(failure) = artifact.failure { + writeln!(output, "metal4_failure={failure}")?; + } + let mut missing = artifact.missing_facilities.into_iter().flatten().peekable(); + if missing.peek().is_some() { + write!(output, "metal4_missing_facilities=")?; + for (index, facility) in missing.enumerate() { + if index > 0 { + write!(output, ",")?; + } + write!(output, "{facility}")?; } + writeln!(output)?; } + Ok(()) } fn percentiles(samples: &[u128]) -> Result { @@ -224,6 +444,27 @@ const fn percentile_index(sample_count: usize, percentile: usize) -> usize { (sample_count * percentile).div_ceil(100).saturating_sub(1) } +fn p95_improvement_basis_points(baseline_ns: u128, candidate_ns: u128) -> Option { + if baseline_ns == 0 { + return None; + } + let baseline = i128::try_from(baseline_ns).unwrap_or(i128::MAX); + let candidate = i128::try_from(candidate_ns).unwrap_or(i128::MAX); + Some(baseline.saturating_sub(candidate).saturating_mul(10_000) / baseline) +} + +fn gpu_interval_nanoseconds(started: f64, completed: f64) -> Result { + let seconds = completed - started; + let nanoseconds = seconds * 1_000_000_000.0; + if started <= 0.0 || !started.is_finite() || !completed.is_finite() || seconds <= 0.0 { + return Err("GPU interval feedback is invalid".to_owned()); + } + if !nanoseconds.is_finite() || nanoseconds <= 0.0 || nanoseconds > u128::MAX as f64 { + return Err("GPU duration is not representable in nanoseconds".to_owned()); + } + Ok(nanoseconds.round() as u128) +} + #[cfg(target_os = "macos")] fn main() { if let Err(error) = macos::run() { @@ -240,8 +481,12 @@ fn main() { #[cfg(target_os = "macos")] mod macos { - use std::sync::{Arc, mpsc}; - use std::time::Instant; + use std::ffi::{c_int, c_ulong, c_void}; + use std::io::{self, Write}; + use std::mem::{align_of, size_of}; + use std::ptr::NonNull; + use std::sync::{Arc, Condvar, Mutex, mpsc}; + use std::time::{Duration, Instant}; use hypercolor_macos_capture::{ MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, @@ -253,19 +498,111 @@ mod macos { MacosNativeReductionFilter, MacosNativeTargetFormat, MacosScreenBridge, probe_macos_metal4_capabilities, }; + use objc2::{ + msg_send, + rc::Retained, + runtime::{AnyObject, ProtocolObject}, + }; + use objc2_foundation::NSString; + use objc2_metal::{ + MTL4ArgumentTable, MTL4ArgumentTableDescriptor, MTL4CommandAllocator, MTL4CommandBuffer, + MTL4CommandEncoder, MTL4CommandQueue, MTL4CommitOptions, MTL4ComputeCommandEncoder, + MTLBuffer, MTLCommandBuffer, MTLComputePipelineState, MTLDevice, MTLLibrary, + MTLResidencySet, MTLResidencySetDescriptor, MTLResourceOptions, MTLSharedEvent, MTLSize, + MTLTexture, + }; + + use super::{ + Args, BYTES_PER_PIXEL, Extent, Filter, Metal4Evaluation, Metal4Evidence, Metal4Failure, + Percentiles, gpu_interval_nanoseconds, percentiles, write_metal4_artifact, + }; + + const METAL4_COMPLETION_TIMEOUT_MS: u64 = 10_000; + const NATIVE_REDUCTION_SHADER: &str = include_str!("../src/native_reduction.metal"); + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Metal4Measurement { + timings: Percentiles, + output_parity: bool, + } + + #[derive(Clone, Copy)] + struct Metal4Report { + probe: MacosMetal4CapabilityProbe, + evaluation: Metal4Evaluation, + measurement: Option, + } + + struct QualifiedBenchmarkReport { + frame: Arc, + cpu: Percentiles, + wgpu: WgpuMeasurement, + metal4: Metal4Report, + terminal_error: Option<&'static str>, + } + + struct Metal4RunError { + failure: Metal4Failure, + detail: String, + } - use super::{Args, BYTES_PER_PIXEL, Extent, Filter, Percentiles, percentiles}; + impl Metal4RunError { + fn new(failure: Metal4Failure, detail: impl Into) -> Self { + Self { + failure, + detail: detail.into(), + } + } + } pub fn run() -> Result<(), String> { let args = Args::parse_from(std::env::args())?; - let source_pixels = synthetic_bgra(args.source)?; - let frame = Arc::new(capture_frame(args.source, &source_pixels)?); let wgpu = WgpuFixture::new()?; - let bridge = MacosScreenBridge::new(&wgpu.device).map_err(|error| error.to_string())?; + let metal4 = + probe_macos_metal4_capabilities(&wgpu.device).map_err(|error| error.to_string())?; + if !metal4.all_required_facilities() { + print_metal4_artifact(metal4, Metal4Evaluation::NotRun); + flush_stdout(); + return Ok(()); + } + match run_qualified(args, &wgpu, metal4) { + Ok(report) => { + print_report( + args, + &report.frame, + &wgpu.adapter_info, + report.cpu, + &report.wgpu, + report.metal4, + ); + flush_stdout(); + report + .terminal_error + .map_or(Ok(()), |error| Err(error.to_owned())) + } + Err(error) => { + print_metal4_artifact(metal4, Metal4Evaluation::Failed(error.failure)); + flush_stdout(); + Err(error.detail) + } + } + } + + fn run_qualified( + args: Args, + wgpu: &WgpuFixture, + metal4: MacosMetal4CapabilityProbe, + ) -> Result { + let setup_error = |error: String| Metal4RunError::new(Metal4Failure::QualifiedSetup, error); + let source_pixels = synthetic_bgra(args.source).map_err(&setup_error)?; + let frame = Arc::new(capture_frame(args.source, &source_pixels).map_err(&setup_error)?); + let bridge = + MacosScreenBridge::new(&wgpu.device).map_err(|error| setup_error(error.to_string()))?; let imported = bridge .import_frame(&wgpu.device, 1, Arc::clone(&frame)) - .map_err(|error| error.to_string())?; - let reducer = MacosNativeReducer::new(&wgpu.device).map_err(|error| error.to_string())?; + .map_err(|error| setup_error(error.to_string()))?; + let reducer = MacosNativeReducer::new(&wgpu.device) + .map_err(|error| setup_error(error.to_string()))?; let target = reducer .create_target( &wgpu.device, @@ -273,12 +610,14 @@ mod macos { args.output.height, MacosNativeTargetFormat::Rgba8, ) - .map_err(|error| error.to_string())?; - let descriptor = reduction_descriptor(args)?; - let mut cpu_output = allocate_bytes(args.output.byte_len()?, "CPU output")?; + .map_err(|error| setup_error(error.to_string()))?; + let descriptor = reduction_descriptor(args).map_err(&setup_error)?; + let output_bytes = args.output.byte_len().map_err(&setup_error)?; + let mut cpu_output = allocate_bytes(output_bytes, "CPU output").map_err(&setup_error)?; for _ in 0..args.warmup { - reduce_scalar(&frame, args.output, args.filter, &mut cpu_output)?; + reduce_scalar(&frame, args.output, args.filter, &mut cpu_output) + .map_err(|error| Metal4RunError::new(Metal4Failure::Warmup, error))?; reduce_wgpu( &wgpu.device, &wgpu.queue, @@ -286,43 +625,88 @@ mod macos { &imported, &target, descriptor, - )?; + ) + .map_err(|error| Metal4RunError::new(Metal4Failure::Warmup, error))?; } let cpu_times = measure(args.iterations, || { reduce_scalar(&frame, args.output, args.filter, &mut cpu_output) - })?; - let gpu_times = measure(args.iterations, || { - reduce_wgpu( - &wgpu.device, - &wgpu.queue, - &reducer, - &imported, - &target, - descriptor, - ) - })?; + }) + .map_err(|error| Metal4RunError::new(Metal4Failure::BaselineMeasurement, error))?; + let wgpu_measurement = measure_wgpu( + wgpu, + &reducer, + &imported, + &target, + descriptor, + args.iterations, + )?; let gpu_output = - read_texture_pixels(&wgpu.device, &wgpu.queue, target.texture(), args.output)?; + read_texture_pixels(&wgpu.device, &wgpu.queue, target.texture(), args.output) + .map_err(|error| Metal4RunError::new(Metal4Failure::BaselineReadback, error))?; if cpu_output != gpu_output { let mismatch = cpu_output .iter() .zip(&gpu_output) .position(|(cpu, gpu)| cpu != gpu) .unwrap_or(cpu_output.len()); - return Err(format!( - "exact output parity failed at byte {mismatch}: CPU={:?}, wgpu={:?}", - cpu_output.get(mismatch), - gpu_output.get(mismatch) + return Err(Metal4RunError::new( + Metal4Failure::BaselineParity, + format!( + "exact output parity failed at byte {mismatch}: CPU={:?}, wgpu={:?}", + cpu_output.get(mismatch), + gpu_output.get(mismatch) + ), )); } - let cpu = percentiles(&cpu_times)?; - let gpu = percentiles(&gpu_times)?; - let metal4 = - probe_macos_metal4_capabilities(&wgpu.device).map_err(|error| error.to_string())?; - print_report(args, &frame, &wgpu.adapter_info, cpu, gpu, metal4); - Ok(()) + let cpu = percentiles(&cpu_times) + .map_err(|error| Metal4RunError::new(Metal4Failure::BaselinePercentile, error))?; + let metal4_measurement = evaluate_metal4(wgpu, &reducer, &imported, args, &cpu_output)?; + let terminal_error = + (!metal4_measurement.output_parity).then_some("Metal 4 exact output parity failed"); + let wgpu_gpu_p95_ns = wgpu_measurement.gpu.p95_ns; + Ok(QualifiedBenchmarkReport { + frame, + cpu, + wgpu: wgpu_measurement, + metal4: Metal4Report { + probe: metal4, + evaluation: Metal4Evaluation::Measured(Metal4Evidence { + output_parity: metal4_measurement.output_parity, + wgpu_gpu_p95_ns, + metal4_gpu_p95_ns: metal4_measurement.timings.p95_ns, + }), + measurement: Some(metal4_measurement), + }, + terminal_error, + }) + } + + fn evaluate_metal4( + wgpu: &WgpuFixture, + reducer: &MacosNativeReducer, + imported: &hypercolor_macos_gpu_interop::ImportedMacosScreenFrame, + args: Args, + cpu_output: &[u8], + ) -> Result { + let target = reducer + .create_target( + &wgpu.device, + args.output.width, + args.output.height, + MacosNativeTargetFormat::Rgba8, + ) + .map_err(|error| { + Metal4RunError::new(Metal4Failure::TargetCreation, error.to_string()) + })?; + let timings = measure_metal4(&wgpu.device, imported, &target, args)?; + let output = read_texture_pixels(&wgpu.device, &wgpu.queue, target.texture(), args.output) + .map_err(|error| Metal4RunError::new(Metal4Failure::OutputReadback, error))?; + Ok(Metal4Measurement { + timings, + output_parity: output == cpu_output, + }) } fn allocate_bytes(length: usize, name: &str) -> Result, String> { @@ -455,6 +839,604 @@ mod macos { Ok(()) } + fn reduce_wgpu_measured( + device: &wgpu::Device, + queue: &wgpu::Queue, + reducer: &MacosNativeReducer, + imported: &hypercolor_macos_gpu_interop::ImportedMacosScreenFrame, + target: &hypercolor_macos_gpu_interop::MacosNativeReductionTarget, + descriptor: MacosNativeReductionDescriptor, + ) -> Result<(u128, u128), Metal4RunError> { + let started = Instant::now(); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bench_macos_reduction measured wgpu iteration"), + }); + reducer + .encode(imported, target, descriptor, &mut encoder) + .map_err(|error| { + Metal4RunError::new(Metal4Failure::BaselineMeasurement, error.to_string()) + })?; + // SAFETY: the raw command buffer is retained only for post-completion + // timing queries. wgpu still owns encoding, submission, and teardown. + let command_buffer = unsafe { + encoder.as_hal_mut::(|hal_encoder| { + let raw = hal_encoder?.raw_command_buffer()?; + Retained::retain(std::ptr::from_ref(raw).cast_mut()) + }) + } + .ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::WgpuGpuIntervalUnavailable, + "wgpu reduction has no retained Metal command buffer", + ) + })?; + let submission = queue.submit(Some(encoder.finish())); + device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| { + Metal4RunError::new( + Metal4Failure::BaselineMeasurement, + format!("wgpu reduction wait failed: {error:?}"), + ) + })?; + let (gpu_started, gpu_completed) = metal_command_buffer_gpu_interval(&command_buffer); + Ok(( + started.elapsed().as_nanos(), + gpu_interval_nanoseconds(gpu_started, gpu_completed).map_err(|error| { + Metal4RunError::new(Metal4Failure::WgpuGpuIntervalUnavailable, error) + })?, + )) + } + + fn metal_command_buffer_gpu_interval( + command_buffer: &ProtocolObject, + ) -> (f64, f64) { + // SAFETY: these selectors are scalar properties of MTLCommandBuffer, and + // the retained command buffer has completed before this function runs. + unsafe { + ( + msg_send![command_buffer, GPUStartTime], + msg_send![command_buffer, GPUEndTime], + ) + } + } + + struct WgpuMeasurement { + completion: Percentiles, + gpu: Percentiles, + } + + fn measure_wgpu( + wgpu: &WgpuFixture, + reducer: &MacosNativeReducer, + imported: &hypercolor_macos_gpu_interop::ImportedMacosScreenFrame, + target: &hypercolor_macos_gpu_interop::MacosNativeReductionTarget, + descriptor: MacosNativeReductionDescriptor, + iterations: usize, + ) -> Result { + let mut completion_samples = Vec::new(); + completion_samples + .try_reserve_exact(iterations) + .map_err(|_| { + Metal4RunError::new( + Metal4Failure::BaselineMeasurement, + "wgpu completion sample allocation failed", + ) + })?; + let mut gpu_samples = Vec::new(); + gpu_samples.try_reserve_exact(iterations).map_err(|_| { + Metal4RunError::new( + Metal4Failure::BaselineMeasurement, + "wgpu GPU sample allocation failed", + ) + })?; + for _ in 0..iterations { + let (completion_ns, gpu_ns) = reduce_wgpu_measured( + &wgpu.device, + &wgpu.queue, + reducer, + imported, + target, + descriptor, + )?; + completion_samples.push(completion_ns); + gpu_samples.push(gpu_ns); + } + Ok(WgpuMeasurement { + completion: percentiles(&completion_samples) + .map_err(|error| Metal4RunError::new(Metal4Failure::BaselinePercentile, error))?, + gpu: percentiles(&gpu_samples) + .map_err(|error| Metal4RunError::new(Metal4Failure::BaselinePercentile, error))?, + }) + } + + #[repr(C, align(16))] + #[derive(Clone, Copy)] + struct Metal4ColorTransform { + source_to_target: [[f32; 4]; 3], + source_luminance_and_exposure: [f32; 4], + curve: [f32; 4], + } + + #[repr(C, align(16))] + #[derive(Clone, Copy)] + struct Metal4ReductionParameters { + content_rect: [u32; 4], + output_and_format: [u32; 4], + source_rect: [f32; 4], + source_and_chroma_extent: [u32; 4], + color: [u32; 4], + operation: [u32; 4], + transform: Metal4ColorTransform, + } + + const _: () = { + assert!(size_of::() == 176); + assert!(align_of::() == 16); + }; + + impl Metal4ReductionParameters { + fn new(args: Args) -> Self { + Self { + content_rect: [0, 0, args.output.width, args.output.height], + output_and_format: [ + args.output.width, + args.output.height, + 0, + match args.filter { + Filter::Nearest => 0, + Filter::Bilinear => 1, + Filter::Area => 2, + }, + ], + source_rect: [ + 0.0, + 0.0, + args.source.width as f32, + args.source.height as f32, + ], + source_and_chroma_extent: [ + args.source.width, + args.source.height, + args.source.width, + args.source.height, + ], + color: [0; 4], + operation: [0; 4], + transform: Metal4ColorTransform { + source_to_target: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ], + source_luminance_and_exposure: [0.212_639, 0.715_168_65, 0.072_192_32, 1.0], + curve: [1.0; 4], + }, + } + } + } + + #[derive(Clone, Copy)] + struct Metal4CommitFeedbackSample { + gpu_started: f64, + gpu_completed: f64, + runtime_error: bool, + } + + #[repr(C)] + struct Metal4FeedbackBlockDescriptor { + reserved: c_ulong, + size: c_ulong, + } + + #[repr(C)] + struct Metal4FeedbackBlock { + isa: *const c_void, + flags: c_int, + reserved: c_int, + invoke: unsafe extern "C-unwind" fn(*mut Self, *mut AnyObject), + descriptor: *const Metal4FeedbackBlockDescriptor, + } + + // SAFETY: the block and descriptor are immutable static ABI records. + unsafe impl Sync for Metal4FeedbackBlock {} + + unsafe extern "C-unwind" { + static _NSConcreteGlobalBlock: c_void; + } + + static METAL4_FEEDBACK_DESCRIPTOR: Metal4FeedbackBlockDescriptor = + Metal4FeedbackBlockDescriptor { + reserved: 0, + size: size_of::() as c_ulong, + }; + static METAL4_FEEDBACK_SAMPLE: Mutex> = Mutex::new(None); + static METAL4_FEEDBACK_READY: Condvar = Condvar::new(); + static METAL4_FEEDBACK_SERIALIZER: Mutex<()> = Mutex::new(()); + static METAL4_FEEDBACK_BLOCK: Metal4FeedbackBlock = Metal4FeedbackBlock { + // SAFETY: the symbol is the Blocks runtime class for immutable global blocks. + isa: &raw const _NSConcreteGlobalBlock, + flags: (1 << 28) | (1 << 29), + reserved: 0, + invoke: receive_metal4_commit_feedback, + descriptor: &raw const METAL4_FEEDBACK_DESCRIPTOR, + }; + + unsafe extern "C-unwind" fn receive_metal4_commit_feedback( + _block: *mut Metal4FeedbackBlock, + feedback: *mut AnyObject, + ) { + // SAFETY: Metal invokes this block with a live MTL4CommitFeedback object. + let Some(feedback) = (unsafe { feedback.as_ref() }) else { + return; + }; + // SAFETY: Metal supplies an object conforming to MTL4CommitFeedback. + let gpu_started = unsafe { msg_send![feedback, GPUStartTime] }; + // SAFETY: Metal supplies an object conforming to MTL4CommitFeedback. + let gpu_completed = unsafe { msg_send![feedback, GPUEndTime] }; + // SAFETY: Metal supplies an object conforming to MTL4CommitFeedback. + let error: Option> = unsafe { msg_send![feedback, error] }; + if let Ok(mut sample) = METAL4_FEEDBACK_SAMPLE.lock() { + *sample = Some(Metal4CommitFeedbackSample { + gpu_started, + gpu_completed, + runtime_error: error.is_some(), + }); + METAL4_FEEDBACK_READY.notify_one(); + } + } + + fn prepare_metal4_commit_feedback() -> Result, Metal4RunError> + { + let serialization = METAL4_FEEDBACK_SERIALIZER.lock().map_err(|_| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 feedback serialization lock is poisoned", + ) + })?; + let mut sample = METAL4_FEEDBACK_SAMPLE.lock().map_err(|_| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 feedback sample lock is poisoned", + ) + })?; + *sample = None; + drop(sample); + Ok(serialization) + } + + fn wait_for_metal4_commit_feedback() -> Result { + let sample = METAL4_FEEDBACK_SAMPLE.lock().map_err(|_| { + Metal4RunError::new( + Metal4Failure::CommitFeedback, + "Metal 4 feedback sample lock is poisoned", + ) + })?; + let (mut sample, timeout) = METAL4_FEEDBACK_READY + .wait_timeout_while( + sample, + Duration::from_millis(METAL4_COMPLETION_TIMEOUT_MS), + |sample| sample.is_none(), + ) + .map_err(|_| { + Metal4RunError::new( + Metal4Failure::CommitFeedback, + "Metal 4 feedback wait lock is poisoned", + ) + })?; + if timeout.timed_out() && sample.is_none() { + return Err(Metal4RunError::new( + Metal4Failure::CommitFeedback, + "Metal 4 commit feedback timed out after 10 seconds", + )); + } + sample.take().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommitFeedback, + "Metal 4 commit feedback returned no sample", + ) + }) + } + + struct Metal4CommandContext { + allocator: Retained>, + queue: Retained>, + command_buffer: Retained>, + completion_event: Retained>, + pipeline: Retained>, + argument_table: Retained>, + residency_set: Retained>, + _parameters: Retained>, + signal_value: u64, + submitted: bool, + } + + impl Metal4CommandContext { + fn new( + device: &ProtocolObject, + source: &ProtocolObject, + output: &ProtocolObject, + pipeline: Retained>, + parameters: &Metal4ReductionParameters, + ) -> Result { + let allocator = device.newCommandAllocator().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 command allocator creation failed", + ) + })?; + let queue = device.newMTL4CommandQueue().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 command queue creation failed", + ) + })?; + let command_buffer = device.newCommandBuffer().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 command buffer creation failed", + ) + })?; + let completion_event = device.newSharedEvent().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 completion event creation failed", + ) + })?; + + let argument_descriptor = MTL4ArgumentTableDescriptor::new(); + argument_descriptor.setMaxBufferBindCount(1); + argument_descriptor.setMaxTextureBindCount(3); + argument_descriptor.setInitializeBindings(true); + let argument_table = device + .newArgumentTableWithDescriptor_error(&argument_descriptor) + .map_err(|error| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + format!( + "Metal 4 argument table creation failed: {}", + error.localizedDescription() + ), + ) + })?; + + // SAFETY: Metal copies exactly one fully initialized parameter + // structure into a shared buffer during this call. + let parameter_buffer = unsafe { + device.newBufferWithBytes_length_options( + NonNull::from(parameters).cast::(), + size_of::(), + MTLResourceOptions::StorageModeShared, + ) + }; + let parameter_buffer = parameter_buffer.ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 parameter buffer creation failed", + ) + })?; + // SAFETY: each binding index falls within the descriptor bounds, + // and all resource IDs belong to the same Metal device. + unsafe { + argument_table.setAddress_atIndex(parameter_buffer.gpuAddress(), 0); + argument_table.setTexture_atIndex(source.gpuResourceID(), 0); + argument_table.setTexture_atIndex(source.gpuResourceID(), 1); + argument_table.setTexture_atIndex(output.gpuResourceID(), 2); + } + + let residency_descriptor = MTLResidencySetDescriptor::new(); + // SAFETY: three is the exact number of retained allocations. + unsafe { + residency_descriptor.setInitialCapacity(3); + } + let residency_set = device + .newResidencySetWithDescriptor_error(&residency_descriptor) + .map_err(|error| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + format!( + "Metal 4 residency set creation failed: {}", + error.localizedDescription() + ), + ) + })?; + residency_set.addAllocation(ProtocolObject::from_ref(source)); + residency_set.addAllocation(ProtocolObject::from_ref(output)); + residency_set.addAllocation(ProtocolObject::from_ref(&*parameter_buffer)); + residency_set.commit(); + + Ok(Self { + allocator, + queue, + command_buffer, + completion_event, + pipeline, + argument_table, + residency_set, + _parameters: parameter_buffer, + signal_value: 0, + submitted: false, + }) + } + + fn dispatch(&mut self, output: Extent) -> Result { + if self.submitted { + self.allocator.reset(); + } + self.command_buffer + .beginCommandBufferWithAllocator(&self.allocator); + self.command_buffer.useResidencySet(&self.residency_set); + let encoder = self.command_buffer.computeCommandEncoder().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 compute encoder creation failed", + ) + })?; + encoder.setComputePipelineState(&self.pipeline); + encoder.setArgumentTable(Some(&self.argument_table)); + encoder.dispatchThreads_threadsPerThreadgroup( + MTLSize { + width: output.width as usize, + height: output.height as usize, + depth: 1, + }, + MTLSize { + width: 8, + height: 8, + depth: 1, + }, + ); + encoder.endEncoding(); + self.command_buffer.endCommandBuffer(); + + let feedback_serialization = prepare_metal4_commit_feedback()?; + let commit_options = MTL4CommitOptions::new(); + let feedback_block = std::ptr::from_ref(&METAL4_FEEDBACK_BLOCK) + .cast_mut() + .cast::(); + // SAFETY: the pointer names an immutable global Objective-C block + // whose callback ABI accepts one MTL4CommitFeedback object. + unsafe { + let _: () = msg_send![&*commit_options, addFeedbackHandler: feedback_block]; + } + let mut command_buffers = [NonNull::from(&*self.command_buffer)]; + let command_buffer_count = command_buffers.len(); + // SAFETY: the array contains exactly one retained Metal 4 command + // buffer and remains alive for the synchronous commit call. + unsafe { + self.queue.commit_count_options( + NonNull::from(&mut command_buffers[0]), + command_buffer_count, + &commit_options, + ); + } + self.signal_value = self.signal_value.checked_add(1).ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::DispatchOrCompletion, + "Metal 4 completion event sequence exhausted", + ) + })?; + self.queue.signalEvent_value( + ProtocolObject::from_ref(&*self.completion_event), + self.signal_value, + ); + if !self + .completion_event + .waitUntilSignaledValue_timeoutMS(self.signal_value, METAL4_COMPLETION_TIMEOUT_MS) + { + return Err(Metal4RunError::new( + Metal4Failure::DispatchOrCompletion, + "Metal 4 completion event timed out after 10 seconds", + )); + } + self.submitted = true; + let feedback = wait_for_metal4_commit_feedback()?; + drop(feedback_serialization); + if feedback.runtime_error { + return Err(Metal4RunError::new( + Metal4Failure::DispatchOrCompletion, + "Metal 4 commit feedback reported a GPU runtime error", + )); + } + gpu_interval_nanoseconds(feedback.gpu_started, feedback.gpu_completed) + .map_err(|error| Metal4RunError::new(Metal4Failure::CommitFeedback, error)) + } + } + + fn measure_metal4( + device: &wgpu::Device, + imported: &hypercolor_macos_gpu_interop::ImportedMacosScreenFrame, + target: &hypercolor_macos_gpu_interop::MacosNativeReductionTarget, + args: Args, + ) -> Result { + // SAFETY: the HAL device is borrowed only for immediate Metal object + // creation and remains bounded by the owning wgpu device. + let hal_device = unsafe { device.as_hal::() }.ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::MetalDeviceAccess, + "Metal 4 prototype has no Metal HAL device", + ) + })?; + let raw_device = hal_device.raw_device(); + let library = raw_device + .newLibraryWithSource_options_error(&NSString::from_str(NATIVE_REDUCTION_SHADER), None) + .map_err(|error| { + Metal4RunError::new( + Metal4Failure::ShaderCompilation, + format!( + "Metal 4 reduction shader compilation failed: {}", + error.localizedDescription() + ), + ) + })?; + let function = library + .newFunctionWithName(&NSString::from_str("hypercolor_reduce")) + .ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::ShaderEntryPoint, + "Metal 4 reduction shader has no hypercolor_reduce entry point", + ) + })?; + let pipeline = raw_device + .newComputePipelineStateWithFunction_error(&function) + .map_err(|error| { + Metal4RunError::new( + Metal4Failure::PipelineCreation, + format!( + "Metal 4 reduction pipeline creation failed: {}", + error.localizedDescription() + ), + ) + })?; + // SAFETY: the target was allocated by this exact Metal-backed wgpu + // device and is borrowed only while commands are encoded and completed. + let target_texture = unsafe { target.texture().as_hal::() } + .ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::TargetTextureAccess, + "Metal 4 target has no Metal texture", + ) + })?; + let parameters = Metal4ReductionParameters::new(args); + let source = imported.planes().first().ok_or_else(|| { + Metal4RunError::new( + Metal4Failure::SourcePlaneAccess, + "Metal 4 fixture has no imported source plane", + ) + })?; + source + .with_metal_texture(|source_texture| { + let mut context = Metal4CommandContext::new( + raw_device, + source_texture, + target_texture.raw_handle(), + pipeline, + ¶meters, + )?; + let mut samples = Vec::new(); + samples.try_reserve_exact(args.iterations).map_err(|_| { + Metal4RunError::new( + Metal4Failure::CommandSetup, + "Metal 4 timing sample allocation failed", + ) + })?; + for _ in 0..args.warmup { + let _ = context.dispatch(args.output)?; + } + for _ in 0..args.iterations { + samples.push(context.dispatch(args.output)?); + } + percentiles(&samples) + .map_err(|error| Metal4RunError::new(Metal4Failure::Metal4Percentile, error)) + }) + .map_err(|error| { + Metal4RunError::new(Metal4Failure::SourcePlaneAccess, error.to_string()) + })? + } + fn reduce_scalar( frame: &MacosCaptureFrame, output_extent: Extent, @@ -646,8 +1628,8 @@ mod macos { frame: &MacosCaptureFrame, adapter: &wgpu::AdapterInfo, cpu: Percentiles, - gpu: Percentiles, - metal4: MacosMetal4CapabilityProbe, + wgpu: &WgpuMeasurement, + metal4: Metal4Report, ) { println!("benchmark=bench_macos_reduction"); println!("fixture=synthetic_iosurface"); @@ -672,33 +1654,50 @@ mod macos { println!("cpu_metric=scalar_reduction_wall_time"); println!("cpu_p50_ns={}", cpu.p50_ns); println!("cpu_p95_ns={}", cpu.p95_ns); - println!("wgpu_metric=wgpu_encode_submit_to_completion_wall_time"); - println!("wgpu_p50_ns={}", gpu.p50_ns); - println!("wgpu_p95_ns={}", gpu.p95_ns); + println!("wgpu_completion_metric=reduction_completion_wall_time_diagnostic_only"); + println!("wgpu_completion_p50_ns={}", wgpu.completion.p50_ns); + println!("wgpu_completion_p95_ns={}", wgpu.completion.p95_ns); + println!("wgpu_adoption_metric={}", super::GPU_REDUCTION_METRIC); + println!("wgpu_gpu_interval=command_buffer_gpu_start_to_end"); + println!("wgpu_gpu_p50_ns={}", wgpu.gpu.p50_ns); + println!("wgpu_gpu_p95_ns={}", wgpu.gpu.p95_ns); println!("output_parity=exact"); - println!("metal4_registry_id={}", metal4.metal_registry_id); - println!("metal4_family={}", metal4.metal4_family); - println!("metal4_command_allocator={}", metal4.command_allocator); - println!("metal4_command_queue={}", metal4.command_queue); - println!("metal4_command_buffer={}", metal4.command_buffer); - println!("metal4_residency_set={}", metal4.residency_set); - match super::metal4_decision(metal4) { - super::Metal4Decision::NotQualified { missing_facilities } => { - let missing = missing_facilities - .into_iter() - .flatten() - .collect::>() - .join(","); - println!("metal4_status=not_qualified"); - println!("metal4_missing_facilities={missing}"); - } - super::Metal4Decision::NotImplemented => { - println!("metal4_status=not_implemented"); - println!( - "metal4_reason=direct_command_allocator_and_residency_set_prototype_not_implemented" - ); + if let Some(measurement) = metal4.measurement { + println!("metal4_adoption_metric={}", super::GPU_REDUCTION_METRIC); + println!("metal4_gpu_interval=commit_feedback_gpu_start_to_end"); + println!("metal4_gpu_p50_ns={}", measurement.timings.p50_ns); + println!("metal4_gpu_p95_ns={}", measurement.timings.p95_ns); + println!( + "metal4_output_parity={}", + if measurement.output_parity { + "exact" + } else { + "mismatch" + } + ); + if let Some(improvement) = + super::p95_improvement_basis_points(wgpu.gpu.p95_ns, measurement.timings.p95_ns) + { + println!("metal4_p95_improvement_basis_points={improvement}"); } } + print_metal4_artifact(metal4.probe, metal4.evaluation); + } + + fn print_metal4_artifact(probe: MacosMetal4CapabilityProbe, evaluation: Metal4Evaluation) { + let stdout = io::stdout(); + let mut output = stdout.lock(); + if let Err(error) = + write_metal4_artifact(&mut output, probe, evaluation).and_then(|()| output.flush()) + { + eprintln!("bench_macos_reduction: could not emit decision artifact: {error}"); + } + } + + fn flush_stdout() { + if let Err(error) = io::stdout().flush() { + eprintln!("bench_macos_reduction: could not flush decision artifact: {error}"); + } } struct WgpuFixture { @@ -754,6 +1753,30 @@ mod tests { Args::parse_from(arguments.iter().map(ToString::to_string)) } + fn qualified_probe() -> hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { + hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { + metal_registry_id: 9, + metal4_family: true, + command_allocator: true, + command_queue: true, + command_buffer: true, + argument_table: true, + residency_set: true, + shared_event: true, + commit_feedback: true, + } + } + + fn rendered_artifact( + probe: hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe, + evaluation: Metal4Evaluation, + ) -> String { + let mut output = Vec::new(); + write_metal4_artifact(&mut output, probe, evaluation) + .expect("in-memory artifact output succeeds"); + String::from_utf8(output).expect("artifact output is UTF-8") + } + #[test] fn parser_accepts_every_bounded_option() { assert_eq!( @@ -766,9 +1789,9 @@ mod tests { "--filter", "bilinear", "--iterations", - "41", + "100", "--warmup", - "7", + "10", ]), Ok(Args { source: Extent { @@ -780,8 +1803,8 @@ mod tests { height: 480, }, filter: Filter::Bilinear, - iterations: 41, - warmup: 7, + iterations: 100, + warmup: 10, }) ); } @@ -791,7 +1814,9 @@ mod tests { assert!(parse(&["bench", "--source", "16384x16384"]).is_err()); assert!(parse(&["bench", "--source", "8193x1"]).is_err()); assert!(parse(&["bench", "--iterations", "10001"]).is_err()); + assert!(parse(&["bench", "--iterations", "99"]).is_err()); assert!(parse(&["bench", "--warmup", "1001"]).is_err()); + assert!(parse(&["bench", "--warmup", "9"]).is_err()); assert!(parse(&["bench", "--filter", "magic"]).is_err()); assert!(parse(&["bench", "--output"]).is_err()); assert!(parse(&["bench", "--mystery", "1"]).is_err()); @@ -805,9 +1830,9 @@ mod tests { "--filter", "area", "--iterations", - "1", + "100", "--warmup", - "0", + "10", "--source", "1x1", ]) @@ -826,27 +1851,221 @@ mod tests { }) ); assert!(percentiles(&[]).is_err()); + assert_eq!(p95_improvement_basis_points(1_000, 900), Some(1_000)); + assert_eq!(p95_improvement_basis_points(1_000, 1_100), Some(-1_000)); + assert_eq!(p95_improvement_basis_points(0, 0), None); } #[test] - fn metal4_decision_never_calls_ordinary_metal_a_comparison() { - let qualified = hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { - metal_registry_id: 9, - metal4_family: true, - command_allocator: true, - command_queue: true, - command_buffer: true, - residency_set: true, - }; - assert_eq!(metal4_decision(qualified), Metal4Decision::NotImplemented); + fn gpu_interval_conversion_rejects_unavailable_or_invalid_feedback() { + assert_eq!(gpu_interval_nanoseconds(1.0, 1.000_001), Ok(1_000)); + assert!(gpu_interval_nanoseconds(0.0, 1.0).is_err()); + assert!(gpu_interval_nanoseconds(2.0, 1.0).is_err()); + assert!(gpu_interval_nanoseconds(f64::NAN, 1.0).is_err()); + assert!(gpu_interval_nanoseconds(1.0, f64::INFINITY).is_err()); + } + + #[test] + fn metal4_decision_requires_qualification_measurement_and_exact_parity() { + let qualified = qualified_probe(); + assert_eq!( + metal4_decision(qualified, Metal4Evaluation::NotRun), + Metal4Decision::NotMeasured(None) + ); + assert_eq!( + metal4_decision( + qualified, + Metal4Evaluation::Measured(Metal4Evidence { + output_parity: false, + wgpu_gpu_p95_ns: 1_000, + metal4_gpu_p95_ns: 800, + }) + ), + Metal4Decision::Reject(Metal4Rejection::OutputMismatch) + ); assert_eq!( - metal4_decision(hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { - command_queue: false, - ..qualified - }), + metal4_decision( + hypercolor_macos_gpu_interop::MacosMetal4CapabilityProbe { + command_queue: false, + ..qualified + }, + Metal4Evaluation::NotRun, + ), Metal4Decision::NotQualified { - missing_facilities: [None, None, Some("command_queue"), None, None] + missing_facilities: [ + None, + None, + Some("command_queue"), + None, + None, + None, + None, + None + ] + } + ); + } + + #[test] + fn metal4_decision_enforces_the_ten_percent_p95_gate() { + let qualified = qualified_probe(); + let evidence = |wgpu_gpu_p95_ns, metal4_gpu_p95_ns| { + Metal4Evaluation::Measured(Metal4Evidence { + output_parity: true, + wgpu_gpu_p95_ns, + metal4_gpu_p95_ns, + }) + }; + assert_eq!( + metal4_decision(qualified, evidence(1_000, 900)), + Metal4Decision::Adopt + ); + assert_eq!( + metal4_decision(qualified, evidence(1_000, 901)), + Metal4Decision::Reject(Metal4Rejection::InsufficientP95Improvement) + ); + assert_eq!( + metal4_decision(qualified, evidence(0, 0)), + Metal4Decision::Reject(Metal4Rejection::BaselineMetricUnavailable) + ); + let maximum = u128::MAX; + let adoption_ceiling = maximum - maximum.div_ceil(10); + assert_eq!( + metal4_decision(qualified, evidence(maximum, adoption_ceiling)), + Metal4Decision::Adopt + ); + assert_eq!( + metal4_decision(qualified, evidence(maximum, adoption_ceiling + 1)), + Metal4Decision::Reject(Metal4Rejection::InsufficientP95Improvement) + ); + } + + #[test] + fn metal4_artifact_distinguishes_unmeasured_hardware_from_measured_rejection() { + let qualified = qualified_probe(); + assert_eq!( + metal4_artifact(qualified, Metal4Evaluation::NotRun), + Metal4Artifact { + hardware_attempted: false, + hardware_run: false, + status: "not_measured", + decision: "not_evaluated", + reason: "qualified_hardware_run_missing", + failure: None, + missing_facilities: [None; 8], + } + ); + assert_eq!( + metal4_artifact( + qualified, + Metal4Evaluation::Measured(Metal4Evidence { + output_parity: false, + wgpu_gpu_p95_ns: 1_000, + metal4_gpu_p95_ns: 800, + }), + ), + Metal4Artifact { + hardware_attempted: true, + hardware_run: true, + status: "measured", + decision: "reject", + reason: "output_parity_mismatch", + failure: None, + missing_facilities: [None; 8], } ); + assert_eq!( + metal4_artifact( + qualified, + Metal4Evaluation::Failed(Metal4Failure::ShaderCompilation), + ), + Metal4Artifact { + hardware_attempted: true, + hardware_run: false, + status: "not_measured", + decision: "not_evaluated", + reason: "shader_compilation_failed", + failure: Some("shader_compilation_failed"), + missing_facilities: [None; 8], + } + ); + assert_eq!( + metal4_artifact( + qualified, + Metal4Evaluation::Failed(Metal4Failure::OutputReadback), + ) + .hardware_run, + true + ); + assert!( + !metal4_artifact( + qualified, + Metal4Evaluation::Failed(Metal4Failure::CommandSetup), + ) + .hardware_run + ); + } + + #[test] + fn every_qualified_failure_phase_emits_a_typed_bounded_artifact() { + let qualified = qualified_probe(); + let before_metal4_dispatch = [ + Metal4Failure::QualifiedSetup, + Metal4Failure::Warmup, + Metal4Failure::BaselineMeasurement, + Metal4Failure::WgpuGpuIntervalUnavailable, + Metal4Failure::BaselineReadback, + Metal4Failure::BaselineParity, + Metal4Failure::BaselinePercentile, + Metal4Failure::TargetCreation, + Metal4Failure::ShaderCompilation, + Metal4Failure::ShaderEntryPoint, + Metal4Failure::PipelineCreation, + Metal4Failure::MetalDeviceAccess, + Metal4Failure::TargetTextureAccess, + Metal4Failure::SourcePlaneAccess, + Metal4Failure::CommandSetup, + ]; + let after_metal4_dispatch = [ + Metal4Failure::DispatchOrCompletion, + Metal4Failure::CommitFeedback, + Metal4Failure::OutputReadback, + Metal4Failure::Metal4Percentile, + ]; + + for failure in before_metal4_dispatch { + let artifact = metal4_artifact(qualified, Metal4Evaluation::Failed(failure)); + assert!(artifact.hardware_attempted, "{}", failure.code()); + assert!(!artifact.hardware_run, "{}", failure.code()); + assert_eq!(artifact.status, "not_measured"); + assert_eq!(artifact.decision, "not_evaluated"); + assert_eq!(artifact.reason, failure.code()); + assert_eq!(artifact.failure, Some(failure.code())); + assert_eq!(artifact.missing_facilities, [None; 8]); + let output = rendered_artifact(qualified, Metal4Evaluation::Failed(failure)); + assert!(output.lines().count() <= 16, "{}", failure.code()); + assert!(output.contains("metal4_hardware_attempted=true\n")); + assert!(output.contains("metal4_hardware_run=false\n")); + assert!(output.contains("metal4_status=not_measured\n")); + assert!(output.contains("metal4_decision=not_evaluated\n")); + assert!(output.contains(&format!("metal4_failure={}\n", failure.code()))); + } + for failure in after_metal4_dispatch { + let artifact = metal4_artifact(qualified, Metal4Evaluation::Failed(failure)); + assert!(artifact.hardware_attempted, "{}", failure.code()); + assert!(artifact.hardware_run, "{}", failure.code()); + assert_eq!(artifact.status, "not_measured"); + assert_eq!(artifact.decision, "not_evaluated"); + assert_eq!(artifact.reason, failure.code()); + assert_eq!(artifact.failure, Some(failure.code())); + assert_eq!(artifact.missing_facilities, [None; 8]); + let output = rendered_artifact(qualified, Metal4Evaluation::Failed(failure)); + assert!(output.lines().count() <= 16, "{}", failure.code()); + assert!(output.contains("metal4_hardware_attempted=true\n")); + assert!(output.contains("metal4_hardware_run=true\n")); + assert!(output.contains("metal4_status=not_measured\n")); + assert!(output.contains("metal4_decision=not_evaluated\n")); + assert!(output.contains(&format!("metal4_failure={}\n", failure.code()))); + } } } diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index eb4a8172e..41590a60b 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -21,8 +21,8 @@ use objc2_io_surface::{ kIOSurfaceHeight, kIOSurfacePixelFormat, kIOSurfaceWidth, }; use objc2_metal::{ - MTLCreateSystemDefaultDevice, MTLDevice, MTLGPUFamily, MTLPixelFormat, MTLResource, - MTLStorageMode, MTLTexture, MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, + MTL4CommitOptions, MTLCreateSystemDefaultDevice, MTLDevice, MTLGPUFamily, MTLPixelFormat, + MTLResource, MTLStorageMode, MTLTexture, MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, }; use thiserror::Error; @@ -58,8 +58,14 @@ pub struct MacosMetal4CapabilityProbe { pub command_queue: bool, /// Whether the active device exposes Metal 4 command buffers. pub command_buffer: bool, + /// Whether the active device exposes Metal 4 argument tables. + pub argument_table: bool, /// Whether the active device exposes residency-set creation. pub residency_set: bool, + /// Whether the active device exposes shared events for completion timing. + pub shared_event: bool, + /// Whether the active device exposes command-buffer GPU interval feedback. + pub commit_feedback: bool, } /// Identity and family facts for the system-default Metal device. @@ -81,12 +87,15 @@ impl MacosMetal4CapabilityProbe { && self.command_allocator && self.command_queue && self.command_buffer + && self.argument_table && self.residency_set + && self.shared_event + && self.commit_feedback } /// Missing facilities in a stable order, padded with `None`. #[must_use] - pub const fn missing_facilities(self) -> [Option<&'static str>; 5] { + pub const fn missing_facilities(self) -> [Option<&'static str>; 8] { [ if self.metal4_family { None @@ -108,11 +117,26 @@ impl MacosMetal4CapabilityProbe { } else { Some("command_buffer") }, + if self.argument_table { + None + } else { + Some("argument_table") + }, if self.residency_set { None } else { Some("residency_set") }, + if self.shared_event { + None + } else { + Some("shared_event") + }, + if self.commit_feedback { + None + } else { + Some("commit_feedback") + }, ] } } @@ -127,13 +151,24 @@ pub fn probe_macos_metal4_capabilities( let hal_device = unsafe { device.as_hal::() } .ok_or(MacosGpuInteropError::MissingWgpuMetalDevice)?; let raw_device = hal_device.raw_device(); + let metal4_family = raw_device.supportsFamily(MTLGPUFamily::Metal4); + let command_queue = raw_device.respondsToSelector(sel!(newMTL4CommandQueue)); + let commit_feedback = metal4_family + && command_queue + && raw_device.newMTL4CommandQueue().is_some_and(|queue| { + queue.respondsToSelector(sel!(commit:count:options:)) + && MTL4CommitOptions::new().respondsToSelector(sel!(addFeedbackHandler:)) + }); Ok(MacosMetal4CapabilityProbe { metal_registry_id, - metal4_family: raw_device.supportsFamily(MTLGPUFamily::Metal4), + metal4_family, command_allocator: raw_device.respondsToSelector(sel!(newCommandAllocator)), - command_queue: raw_device.respondsToSelector(sel!(newMTL4CommandQueue)), + command_queue, command_buffer: raw_device.respondsToSelector(sel!(newCommandBuffer)), + argument_table: raw_device.respondsToSelector(sel!(newArgumentTableWithDescriptor:error:)), residency_set: raw_device.respondsToSelector(sel!(newResidencySetWithDescriptor:error:)), + shared_event: raw_device.respondsToSelector(sel!(newSharedEvent)), + commit_feedback, }) } diff --git a/crates/hypercolor-macos-gpu-interop/src/stubs.rs b/crates/hypercolor-macos-gpu-interop/src/stubs.rs index 107f29a4d..8b5cdf46f 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -18,8 +18,14 @@ pub struct MacosMetal4CapabilityProbe { pub command_queue: bool, /// Whether the active device exposes Metal 4 command buffers. pub command_buffer: bool, + /// Whether the active device exposes Metal 4 argument tables. + pub argument_table: bool, /// Whether the active device exposes residency-set creation. pub residency_set: bool, + /// Whether the active device exposes shared events for completion timing. + pub shared_event: bool, + /// Whether the active device exposes command-buffer GPU interval feedback. + pub commit_feedback: bool, } impl MacosMetal4CapabilityProbe { @@ -30,12 +36,15 @@ impl MacosMetal4CapabilityProbe { && self.command_allocator && self.command_queue && self.command_buffer + && self.argument_table && self.residency_set + && self.shared_event + && self.commit_feedback } /// Missing facilities in a stable order, padded with `None`. #[must_use] - pub const fn missing_facilities(self) -> [Option<&'static str>; 5] { + pub const fn missing_facilities(self) -> [Option<&'static str>; 8] { [ if self.metal4_family { None @@ -57,11 +66,26 @@ impl MacosMetal4CapabilityProbe { } else { Some("command_buffer") }, + if self.argument_table { + None + } else { + Some("argument_table") + }, if self.residency_set { None } else { Some("residency_set") }, + if self.shared_event { + None + } else { + Some("shared_event") + }, + if self.commit_feedback { + None + } else { + Some("commit_feedback") + }, ] } } diff --git a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs index 665c231b8..e23566e6c 100644 --- a/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs +++ b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs @@ -17,10 +17,13 @@ fn metal4_probe_requires_every_facility() { command_allocator: true, command_queue: true, command_buffer: true, + argument_table: true, residency_set: true, + shared_event: true, + commit_feedback: true, }; assert!(complete.all_required_facilities()); - assert_eq!(complete.missing_facilities(), [None; 5]); + assert_eq!(complete.missing_facilities(), [None; 8]); let missing_command_buffer = MacosMetal4CapabilityProbe { command_buffer: false, @@ -29,7 +32,37 @@ fn metal4_probe_requires_every_facility() { assert!(!missing_command_buffer.all_required_facilities()); assert_eq!( missing_command_buffer.missing_facilities(), - [None, None, None, Some("command_buffer"), None] + [ + None, + None, + None, + Some("command_buffer"), + None, + None, + None, + None + ] + ); + + let missing_completion = MacosMetal4CapabilityProbe { + argument_table: false, + shared_event: false, + commit_feedback: false, + ..complete + }; + assert!(!missing_completion.all_required_facilities()); + assert_eq!( + missing_completion.missing_facilities(), + [ + None, + None, + None, + None, + Some("argument_table"), + None, + Some("shared_event"), + Some("commit_feedback"), + ] ); } From 8ad3232bd111a334e13a99e48d76e7380f3b32f3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 13:29:47 -0700 Subject: [PATCH 099/144] ci(macos): gate releases on native acceptance Make generated-client drift and native macOS lint coverage hard release prerequisites. Document the signed physical acceptance checkpoint and align the packaging design with the first-class macOS 15.2 architecture matrix. Co-Authored-By: Nova (GPT-5) --- .github/workflows/ci.yml | 22 ++++++++++++-- docs/design/46-cross-platform-packaging.md | 34 +++++++++++++++------- docs/development/RELEASING.md | 30 ++++++++++++++++++- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39bb9ecb6..662733167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,6 +316,22 @@ jobs: --test macos_screen_capture_tests \ -- -D warnings + - name: Clippy macOS host input and ownership + run: | + ./scripts/cargo-cache-build.sh \ + cargo clippy --locked \ + -p hypercolor-macos-input --all-targets \ + -- -D warnings + ./scripts/cargo-cache-build.sh \ + cargo clippy --locked \ + -p hypercolor-macos-owner --all-targets \ + -- -D warnings + ./scripts/cargo-cache-build.sh \ + cargo clippy --locked \ + -p hypercolor-daemon --no-default-features \ + --bin hypercolor-daemon --test macos_owner_tests \ + -- -D warnings + - name: Run macOS interop fixtures run: >- ./scripts/cargo-cache-build.sh @@ -1269,7 +1285,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] + needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] strategy: fail-fast: false matrix: @@ -1616,7 +1632,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets] + needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] strategy: fail-fast: false matrix: @@ -1860,7 +1876,7 @@ jobs: if: >- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && startsWith(github.ref, 'refs/tags/') - needs: [build-release, build-native-app] + needs: [build-release, build-native-app, python, python-generated] runs-on: ubuntu-latest permissions: contents: write diff --git a/docs/design/46-cross-platform-packaging.md b/docs/design/46-cross-platform-packaging.md index 3ef5afe4e..cea4da7cb 100644 --- a/docs/design/46-cross-platform-packaging.md +++ b/docs/design/46-cross-platform-packaging.md @@ -882,11 +882,12 @@ bootstrapper). Unsigned for early alpha; signed for v1. "targets": ["dmg", "app"], "macOS": { "frameworks": [], - "minimumSystemVersion": "11.0", + "minimumSystemVersion": "15.2", "exceptionDomain": "", "signingIdentity": "Developer ID Application: Stefanie Jane (TEAMID)", "providerShortName": "TEAMID", "entitlements": "entitlements.plist", + "infoPlist": "Info.plist", "dmg": { "background": "icons/dmg-background.png", "windowSize": { "width": 660, "height": 400 }, @@ -914,19 +915,30 @@ bootstrapper). Unsigned for early alpha; signed for v1. com.apple.security.device.usb + + +``` + +Privacy purpose strings belong in `Info.plist`, not the entitlement profile: + +```xml + + NSMicrophoneUsageDescription Hypercolor uses your microphone for audio-reactive lighting effects. - NSAppleEventsUsageDescription - Hypercolor uses input events for keyboard-reactive lighting effects. + NSScreenCaptureUsageDescription + Hypercolor captures your screen to create screen-reactive lighting effects. ``` -> Screen recording permission has no Info.plist key — TCC-managed, prompted at first -> capture attempt. Walk users through it in [§12.3](#123-macos-permissions). +The bundle must not declare `NSAppleEventsUsageDescription`. Native keyboard +and pointer capture uses Input Monitoring rather than Apple Events. Walk users +through the TCC permissions in [§12.3](#123-macos-permissions). **Output**: `Hypercolor-0.1.0-arm64.dmg` (Apple Silicon) and -`Hypercolor-0.1.0-x86_64.dmg` (Intel, courtesy build). +`Hypercolor-0.1.0-x86_64.dmg` (Intel). Both architectures are first-class +release targets under the macOS 15.2 support floor. **Homebrew Cask** (separate from existing CLI Homebrew formula): @@ -1146,8 +1158,8 @@ Walk the user through each permission with deep links: | Permission | When needed | Deep link | |---|---|---| | Microphone | Audio-reactive effects | Triggered automatically on first capture; no deep link needed | -| Screen Recording | Screen capture effects | `x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture` | -| Accessibility | Keyboard-reactive effects | `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility` | +| Screen Recording | Screen capture effects | `x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture` | +| Input Monitoring | Keyboard-reactive effects | `x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent` | | LaunchAgent (autostart) | Login | Automatic, no permission | | USB device access | HID devices | Automatic for HID; no deep link | @@ -1212,10 +1224,10 @@ Add Tauri build deps to CI runners: **Per-OS bundle artifacts** uploaded on release tag: ```yaml -# .github/workflows/release.yml additions +# .github/workflows/ci.yml release jobs - ubuntu-latest: hypercolor-app-x86_64.AppImage (v1.1) -- macos-14: Hypercolor-arm64.dmg -- macos-13: Hypercolor-x86_64.dmg (Intel courtesy) +- macos-26: Hypercolor-arm64.dmg +- macos-26-intel: Hypercolor-x86_64.dmg - windows-latest: Hypercolor_x64-setup.exe (NSIS) ``` diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index 832e43c54..3a1de64bf 100644 --- a/docs/development/RELEASING.md +++ b/docs/development/RELEASING.md @@ -10,7 +10,8 @@ AI-generated notes, and registry publishes. 2. Enter the version without the leading `v` (e.g. `0.3.0` or `0.3.0-rc.1`). 3. Leave **dry run** checked for the first pass. Review the `release-preview-v` artifact (release notes + changelog). -4. Re-run with dry run unchecked to ship. +4. Complete the signed macOS acceptance checkpoint below. +5. Re-run with dry run unchecked to ship. What the Release workflow does, in order: @@ -43,6 +44,33 @@ Release with the committed notes, publishes `hypercolor` + dist-tag), publishes the Python client to PyPI (stable only), and updates the Homebrew tap and AUR metadata (stable only). +## Signed macOS acceptance checkpoint + +Spec 76 acceptance is a manual release checkpoint until the physical-hardware +harness is automated. Before shipping a release that includes macOS screen +capture or host input changes, run the signed packaged release candidate on +the required Apple Silicon and Intel hardware and retain one acceptance bundle +covering: + +- the signed TCC owner matrix and selected capability topology, including the + broker decision; +- keyboard, pointer, SDR, HDR, picker, lifecycle, and teardown acceptance for + the rows supported by each machine; +- the Section 19 latency, cadence, zero-copy, byte-reconciliation, and + 30-minute results, plus the Section 18.5 four-hour combined soak; and +- one Metal 4 qualification and adoption artifact for every active device that + exposes the required facilities. + +Record the immutable artifact location and checksum in the release checklist. +CI fixtures, unsigned local runs, and a successful build do not replace this +evidence. If the signed bundle does not exist or any required row fails, stop +after the dry run. The repository does not currently contain a completed +physical-acceptance bundle. + +The native and standalone artifact jobs also wait for the Python OpenAPI and +WebSocket drift checks. GitHub Release creation cannot run unless both checks +and both artifact lanes succeed. + ## Required configuration | What | Where | Used for | From d09b2f209a0661dda665ea12ac17e7cc0b9ed78d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 14:45:33 -0700 Subject: [PATCH 100/144] docs(macos): gate native capture release claims Keep public installation guidance bounded by the signed physical acceptance matrix required by Spec 76. Replace Spec 14's unimplemented universal fallback with the platform-native architecture that ships today. Co-Authored-By: Nova (GPT-5) --- docs/content/guide/choose-your-install.md | 30 ++-- docs/content/guide/installation.md | 17 ++- docs/specs/14-screen-capture.md | 177 ++++++---------------- 3 files changed, 68 insertions(+), 156 deletions(-) diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 7e0ad6b96..1657526b0 100644 --- a/docs/content/guide/choose-your-install.md +++ b/docs/content/guide/choose-your-install.md @@ -102,22 +102,18 @@ Download `Hypercolor--arm64.dmg` (Apple Silicon) or `-x86_64.dmg` Current builds are ad-hoc signed but not notarized, so Gatekeeper will block the app on first launch. Right-click the app and choose **Open** to confirm. {% end %} -macOS supports screen-reactive effects through ScreenCaptureKit and Apple's -system picker. Screen Recording permission is requested only after an explicit -capture action. Audio-reactive effects also work; system audio needs a loopback -device as described in [Audio setup](@/guide/audio-setup.md). - -Apple Silicon supports the native HDR screen pipeline. Intel Macs use the SDR -pipeline; HDR capture is reported as unsupported instead of silently falling -back. On macOS 26 Tahoe, a compatible selection can provide paired SDR and HDR -reference diagnostics. Other selections use the single SDR reference path. - -The desktop app normally owns the daemon as an app sidecar. Direct launchd, -Homebrew service, and standalone daemon topologies are also supported, but only -one can own the per-user daemon guard at a time. Use the Settings session panel -or `hypercolor service choose-owner` to switch the persistent owner. Hypercolor -reports an owner conflict or an offline selected service with the exact local -remedy instead of starting a second daemon. +The native ScreenCaptureKit, host-input, HDR, and multi-owner implementations +are present, but they are not release-qualified until the signed macOS physical +acceptance matrix ships with the release provenance. Development builds do not +establish durable TCC grants or hardware support claims. Screen Recording is +requested only after an explicit local capture action. Audio-reactive effects +still need the loopback setup described in [Audio setup](@/guide/audio-setup.md). + +The pending qualification matrix covers the app sidecar, direct launchd, +Homebrew service, and standalone daemon as distinct TCC identities. It also +covers Apple Silicon HDR, Intel SDR, and Tahoe paired-reference diagnostics. +Until those signed receipts pass, use the packaged app sidecar for protected +macOS sources and treat the other topologies as experimental. ### Homebrew {#homebrew} @@ -131,7 +127,7 @@ brew install --cask hyperb1iss/tap/hypercolor-app brew install hyperb1iss/tap/hypercolor ``` -The formula covers macOS arm64 plus Linux amd64 and arm64; the cask is the full desktop app for either Mac architecture. +The formula covers macOS arm64 and x86_64 plus Linux amd64 and arm64; the cask is the full desktop app for either Mac architecture. The formula selects the Homebrew service topology when managed with `brew services`. Install the cask when protected macOS permissions or the diff --git a/docs/content/guide/installation.md b/docs/content/guide/installation.md index 092a56c3a..33f562dd9 100644 --- a/docs/content/guide/installation.md +++ b/docs/content/guide/installation.md @@ -133,14 +133,15 @@ support). Both update automatically on every tagged release. ### macOS screen capture support Screen capture is off until an explicit authorization or source-selection -action. Hypercolor uses Input Monitoring for keyboard and pointer capture and -Screen Recording for ScreenCaptureKit. The settings page links directly to the -matching System Settings privacy pane when manual remediation is needed. - -Apple Silicon supports the native HDR screen pipeline. Intel Macs use SDR and -report HDR as unsupported. On macOS 26 Tahoe, compatible selections can expose -paired SDR and HDR reference diagnostics. An SDR-only selection uses one SDR -reference image and is never relabeled as paired HDR. +action. Keyboard capture uses Input Monitoring. Passive pointer capture does +not use a TCC service. ScreenCaptureKit uses Screen Recording. The settings +page links directly to the matching System Settings privacy pane when manual +remediation is needed. + +The native Apple Silicon HDR, Intel SDR, and Tahoe paired-reference paths are +implemented but remain release-gated by the signed physical acceptance matrix. +Development builds can exercise pure fixtures and native mechanics, but they +do not establish durable TCC or hardware qualification. The CLI exposes the same explicit actions when the active process topology can perform them: diff --git a/docs/specs/14-screen-capture.md b/docs/specs/14-screen-capture.md index 715e45141..430b0618c 100644 --- a/docs/specs/14-screen-capture.md +++ b/docs/specs/14-screen-capture.md @@ -60,7 +60,7 @@ pub trait InputSource: Send + Sync { ```rust pub struct ScreenCaptureInput { - /// Active capture backend (PipeWire, XShm, or xcap). + /// Active platform-native capture backend. backend: Box, /// Translates screen geometry into LED sampling regions. @@ -89,7 +89,7 @@ impl InputSource for ScreenCaptureInput { fn name(&self) -> &str { "screen_capture" } fn sample(&mut self) -> Result { - // 1. Capture frame (backend-specific: DMA-BUF, XShm, or xcap) + // 1. Capture frame from the platform-native backend let frame = self.backend.capture_frame(&mut self.staging)?; // 2. Detect letterboxing (updates internal state over N frames) @@ -127,8 +127,8 @@ impl InputSource for ScreenCaptureInput { | Event | Behavior | | ---------------------- | -------------------------------------------------------------------------------------------- | -| **Construction** | Auto-detect backend, request portal permissions (Wayland), allocate staging buffer | -| **First sample** | PipeWire: blocks until first frame arrives or 5s timeout. XShm/xcap: immediate | +| **Construction** | Register the platform-native backend and allocate its admitted buffers | +| **First sample** | The native stream waits for a validated frame or returns a bounded startup error | | **Steady state** | Non-blocking reads from backend's frame buffer (double/triple buffered) | | **Monitor disconnect** | Backend emits `CaptureError::MonitorLost`, input source signals the render loop to fall back | | **Drop** | Release PipeWire stream, detach XShm segment, close portal session | @@ -151,8 +151,7 @@ pub trait CaptureBackendTrait: Send { fn capture_frame(&mut self, staging: &mut Vec) -> Result; /// Apply a quality adjustment (resolution/fps change) from the adaptive - /// quality controller. PipeWire renegotiates stream params; xcap changes - /// its downsample factor. + /// quality controller. Streaming backends renegotiate their native request. fn apply_quality_adjustment(&mut self, adj: QualityAdjustment) -> Result<()>; /// Backend identifier for logging/diagnostics. @@ -286,115 +285,37 @@ pub struct XShmCapture { **Multi-monitor:** X11 captures the root window, which spans all monitors. The region mapper uses `XRRGetScreenResources` / `XRRGetCrtcInfo` to determine per-monitor geometry and maps virtual canvas coordinates accordingly. -### 2.4 xcap Crate Fallback +### 2.4 Platform-Native Backends -**Feature gate:** Always available (pure Rust, cross-platform) +Hypercolor does not use a universal screenshot fallback. Each supported +platform owns a streaming backend with explicit resource lifetimes and native +failure semantics: -The `xcap` crate provides a universal fallback. On Linux it uses XShm internally for X11 and PipeWire for Wayland. On Windows and macOS it uses native APIs (DXGI/WGC and SCKit respectively). This is the only backend available on non-Linux platforms. +- Linux uses the XDG Desktop Portal and PipeWire. +- Windows uses DXGI Desktop Duplication through + `hypercolor-windows-capture`. +- macOS uses ScreenCaptureKit through `hypercolor-macos-capture` and publishes + retained IOSurfaces for exact CPU or Metal processing. -```rust -pub struct XcapCapture { - /// Cached monitor handle. Refreshed on hot-plug events. - monitor: xcap::Monitor, - - /// Target capture size — xcap captures at native res, - /// we downsample immediately to avoid holding large buffers. - target_size: (u32, u32), -} - -impl CaptureBackendTrait for XcapCapture { - fn capture_frame(&mut self, staging: &mut Vec) -> Result { - // xcap returns image::RgbaImage at native resolution - let screenshot = self.monitor.capture_image() - .map_err(|e| CaptureError::BackendFailed(e.to_string()))?; - - // Downsample immediately — don't hold a 4K RGBA buffer around - let small = image::imageops::resize( - &screenshot, - self.target_size.0, - self.target_size.1, - image::imageops::Triangle, // bilinear — fast, good enough - ); - - // Copy into staging buffer (RGBA8 row-major) - staging.clear(); - staging.extend_from_slice(small.as_raw()); - - Ok(CapturedFrame { - data: staging.as_ptr(), - width: self.target_size.0, - height: self.target_size.1, - stride: self.target_size.0 * 4, - pixel_format: PixelFormat::Rgba8, - timestamp: Instant::now(), - capture_duration: /* measured */, // wall-clock time of capture_image + resize - }) - } - - fn backend_name(&self) -> &str { "xcap" } -} -``` +An unavailable native backend leaves the source unavailable. Hypercolor never +silently substitutes a weaker screenshot path because doing so would change +permission, color, cadence, and memory contracts. -**Trade-offs:** No streaming mode — each call is a discrete screenshot. Higher latency than PipeWire streaming. But it works everywhere and has zero setup complexity. +### 2.5 Backend Registration -### 2.5 Backend Auto-Detection +The daemon registers exactly one platform implementation at compile time. +Runtime configuration selects a source exposed by that implementation, not a +different backend family. Linux portal selection, Windows monitor identifiers, +and macOS picker or stable display selections therefore retain their native +meaning without a cross-platform backend override. -```rust -pub fn auto_detect_backend(config: &CaptureConfig) -> Result> { - // 1. User-specified override in config - if let Some(ref forced) = config.forced_backend { - return match forced.as_str() { - "pipewire" => Ok(Box::new(PipeWireCapture::new(config)?)), - "xshm" => Ok(Box::new(XShmCapture::new(config)?)), - "xcap" => Ok(Box::new(XcapCapture::new(config)?)), - other => Err(CaptureError::UnknownBackend(other.into())), - }; - } +### 2.6 Crate Matrix - // 2. Auto-detect from environment - #[cfg(target_os = "linux")] - { - if std::env::var("WAYLAND_DISPLAY").is_ok() { - match PipeWireCapture::new(config) { - Ok(pw) => return Ok(Box::new(pw)), - Err(e) => { - tracing::warn!("PipeWire unavailable ({e}), falling back to xcap"); - } - } - } - - if std::env::var("DISPLAY").is_ok() { - match XShmCapture::new(config) { - Ok(xshm) => return Ok(Box::new(xshm)), - Err(e) => { - tracing::warn!("XShm unavailable ({e}), falling back to xcap"); - } - } - } - } - - // 3. Universal fallback - Ok(Box::new(XcapCapture::new(config)?)) -} -``` - -### 2.6 Feature Flag Matrix - -| Feature Flag | Platforms | Dependencies | What it enables | -| ----------------- | ---------- | ------------------------------------------- | --------------------------------------- | -| `screen-pipewire` | Linux only | `libpipewire-0.3`, `zbus`, `wayland-client` | `PipeWireCapture` with DMA-BUF + portal | -| `screen-x11` | Linux only | `x11`, `xcb` (XShm extension) | `XShmCapture` shared memory capture | -| _(default)_ | All | `xcap`, `image` | `XcapCapture` universal fallback | - -```toml -# Cargo.toml feature definitions -[features] -default = ["screen-capture"] -screen-capture = ["dep:xcap", "dep:image"] -screen-pipewire = ["screen-capture", "dep:pipewire", "dep:zbus"] -screen-x11 = ["screen-capture", "dep:x11"] -screen-full = ["screen-pipewire", "screen-x11"] -``` +| Platform | Capture implementation | Native transport | +| -------- | -------------------------------- | -------------------------------- | +| Linux | `core::input::screen::wayland` | XDG Portal plus PipeWire | +| Windows | `hypercolor-windows-capture` | DXGI Desktop Duplication | +| macOS | `hypercolor-macos-capture` | ScreenCaptureKit plus IOSurface | --- @@ -404,10 +325,6 @@ Runtime configuration for the capture pipeline. Loaded from TOML config, overrid ```rust pub struct CaptureConfig { - // ── Backend selection ────────────────────────────────────── - /// "auto", "pipewire", "xshm", "xcap". Default: "auto". - pub forced_backend: Option, - // ── Monitor targeting ───────────────────────────────────── /// Which display(s) to capture. pub monitor: MonitorSelect, @@ -935,8 +852,8 @@ The capture pipeline reduces data volume in three stages: Stage 1: Backend resolution negotiation Native (e.g., 2560x1440) → Requested (640x360) Reduction: ~16x - Who: Compositor GPU scaler (PipeWire) or CPU resize (xcap) - Cost: Near-zero for PipeWire, ~0.5ms for xcap + Who: Native compositor negotiation or admitted CPU/GPU reduction + Cost: Measured per backend and reported through capture diagnostics Stage 2: Sector grid computation 640x360 (230,400 px) → 64x36 (2,304 sectors) @@ -1422,10 +1339,10 @@ The screen capture pipeline must not visibly affect system performance, especial | Stage | Target | Hard Limit | Notes | | ----------------------------------- | ----------- | ---------- | ---------------------------------------- | -| Frame capture (PipeWire DMA-BUF) | ~0.1ms | 1.0ms | Zero-copy — just a pointer swap | +| Frame capture (PipeWire DMA-BUF) | ~0.1ms | 1.0ms | Zero-copy, just a pointer swap | | Frame capture (PipeWire MemPtr) | ~0.5ms | 2.0ms | Shared memory read | | Frame capture (XShm, 640x360) | ~0.5ms | 2.0ms | Shared memory blit at reduced resolution | -| Frame capture (xcap, 1080p→640x360) | ~2.0ms | 4.0ms | Full capture + CPU resize | +| Frame capture (DXGI or SCKit) | Measured | Admitted | Native retained resource publication | | Sector grid computation (GPU) | ~0.2ms | 0.5ms | Compute shader, 9KB readback | | Sector grid computation (CPU) | ~0.3ms | 1.0ms | Box filter over 230K pixels | | Region mapping | ~0.05ms | 0.1ms | Trivial arithmetic over ~200 zones | @@ -1433,7 +1350,7 @@ The screen capture pipeline must not visibly affect system performance, especial | Temporal smoothing | ~0.02ms | 0.05ms | EMA over ~200 color values | | **Total (PipeWire + GPU)** | **~0.42ms** | **1.85ms** | **<0.5% CPU at 30fps** | | **Total (XShm + CPU)** | **~0.92ms** | **3.35ms** | **<3% CPU at 30fps** | -| **Total (xcap + CPU)** | **~2.42ms** | **6.35ms** | **<5% CPU at 30fps** | +| **Native CPU or GPU route** | Measured | Admitted | Must satisfy the platform acceptance gate | ### 8.2 Memory Budget @@ -1594,8 +1511,9 @@ At every tier, the ambient lighting quality remains perceptually good. The human ## 10. Cross-Platform Strategy -Hypercolor is a Linux-first project. Windows capture shipped as a first-class -backend; macOS is still unimplemented. +Hypercolor has first-class Wayland, Windows, and native macOS capture backends. +The macOS design, physical acceptance gates, and release claims are governed by +[Spec 76](76-macos-screen-capture-and-host-input.md). The `xcap` fallback this section originally specified was never built. Windows uses a purpose-built DXGI Desktop Duplication backend instead, in the @@ -1607,12 +1525,12 @@ and it lets the readback subsample during the copy rather than after it. | Capability | Linux (Wayland) | Linux (X11) | Windows | macOS | | --------------------- | ----------------------------- | --------------------- | ------------------------------ | --------------- | -| **Primary backend** | PipeWire + Portal | XShm (unimplemented) | DXGI Desktop Duplication | Unimplemented | -| **DMA-BUF zero-copy** | Yes | No | No (staging readback) | n/a | -| **Streaming mode** | Yes (PipeWire) | No | Yes (duplication is a stream) | n/a | -| **Permission model** | Portal dialog + restore token | None (open access) | None required | Screen Recording| -| **Multi-monitor** | Portal multi-select | Root window spans all | `capture.source = "monitor:N"` | n/a | -| **Enabled by default**| No (portal picker is consent) | No | Yes (nothing to consent to) | No (TCC prompt) | +| **Primary backend** | PipeWire + Portal | XShm (unimplemented) | DXGI Desktop Duplication | ScreenCaptureKit | +| **DMA-BUF zero-copy** | Yes | No | No (staging readback) | IOSurface import | +| **Streaming mode** | Yes (PipeWire) | No | Yes (duplication is a stream) | Yes (SCStream) | +| **Permission model** | Portal dialog + restore token | None (open access) | None required | Screen Recording | +| **Multi-monitor** | Portal multi-select | Root window spans all | `capture.source = "monitor:N"` | System picker | +| **Enabled by default**| No (portal picker is consent) | No | Yes (nothing to consent to) | No (TCC consent) | ### Windows @@ -1654,6 +1572,8 @@ HDR color processing, diagnostics, and release acceptance for macOS. pub mod wayland; #[cfg(target_os = "windows")] pub mod windows; +#[cfg(target_os = "macos")] +pub mod macos; ``` Registration lives in the daemon's `build_input_manager`, which adds the @@ -1667,9 +1587,8 @@ matching source when `capture.enabled` is set. pipewire = { version = "0.8", optional = true } x11 = { version = "2.21", optional = true } -# All platforms — universal fallback +# Shared CPU image processing [dependencies] -xcap = "0.0.13" image = "0.25" # Dev profile: minimal dependencies for fast iteration @@ -1685,10 +1604,6 @@ opt-level = 2 # Optimize deps even in debug (image processing is slow at -O0) pub enum CaptureError { /// No suitable backend found for the current environment. NoBackendAvailable, - /// User-specified backend not compiled in (missing feature flag). - BackendNotCompiled(String), - /// Unknown backend name in config. - UnknownBackend(String), /// PipeWire portal denied screen access. PortalDenied, /// PipeWire portal timed out waiting for user approval. From 859df1addb365f5972a20f2c76199789fac04956 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 12 Aug 2026 14:57:38 -0700 Subject: [PATCH 101/144] feat(macos): harden capture control transactions Keep protected capture actions local and preserve private source identities. Picker persistence now waits for a revision-fenced choice. Capture and host source reconfiguration preserve the last viable runtime across rejected replacements and failed rollback. Publish canonical capture REST contracts through OpenAPI, generated Python clients, UI decoding, and operator documentation. Co-Authored-By: Nova (GPT-5.4) --- crates/hypercolor-core/src/config/mod.rs | 9 + crates/hypercolor-core/src/input/mod.rs | 224 ++++++ crates/hypercolor-daemon/src/api/capture.rs | 423 +++++++++-- crates/hypercolor-daemon/src/api/config.rs | 700 ++++++++++++++++-- crates/hypercolor-daemon/src/api/mod.rs | 25 +- crates/hypercolor-daemon/src/api/openapi.rs | 97 ++- crates/hypercolor-daemon/src/api/security.rs | 47 +- crates/hypercolor-daemon/src/api/system.rs | 188 ++++- .../hypercolor-daemon/src/startup/services.rs | 68 +- .../src/startup/services/tests.rs | 104 ++- .../hypercolor-daemon/tests/openapi_tests.rs | 117 +++ .../tests/security_api_tests.rs | 102 ++- crates/hypercolor-types/src/api/capture.rs | 39 + crates/hypercolor-types/src/api/mod.rs | 1 + .../tests/api_capture_tests.rs | 42 ++ crates/hypercolor-ui/src/api/config.rs | 14 +- .../src/components/settings_sections.rs | 2 +- docs/content/api/openapi.md | 39 +- docs/content/api/rest.md | 29 +- .../_generated/api/assets/__init__.py | 1 + .../_generated/api/assets/delete_asset.py | 120 +++ .../_generated/api/assets/get_asset.py | 120 +++ .../_generated/api/assets/get_asset_blob.py | 120 +++ .../api/assets/get_asset_thumbnail.py | 120 +++ .../_generated/api/assets/list_assets.py | 103 +++ .../_generated/api/assets/update_asset.py | 120 +++ .../_generated/api/assets/upload_asset.py | 103 +++ .../_generated/api/capture/__init__.py | 1 + .../api/capture/authorize_input_monitoring.py | 138 ++++ .../api/capture/authorize_screen_recording.py | 138 ++++ .../api/capture/list_capture_monitors.py | 150 ++++ .../api/capture/pick_capture_source.py | 144 ++++ .../api/diagnostics/memory_diagnostics.py | 103 +++ .../api/effects/get_effect_screenshot.py | 103 +++ .../hypercolor/_generated/models/__init__.py | 28 + ...response_capture_authorization_response.py | 82 ++ ...nse_capture_authorization_response_data.py | 71 ++ .../api_response_capture_picker_response.py | 82 ++ ...i_response_capture_picker_response_data.py | 71 ++ .../api_response_vec_capture_monitor.py | 90 +++ ..._response_vec_capture_monitor_data_item.py | 109 +++ .../models/capture_authorization_response.py | 71 ++ .../_generated/models/capture_monitor.py | 109 +++ .../models/capture_picker_response.py | 71 ++ .../models/protected_source_grant_owner.py | 14 + 45 files changed, 4439 insertions(+), 213 deletions(-) create mode 100644 crates/hypercolor-types/src/api/capture.rs create mode 100644 crates/hypercolor-types/tests/api_capture_tests.rs create mode 100644 python/src/hypercolor/_generated/api/assets/__init__.py create mode 100644 python/src/hypercolor/_generated/api/assets/delete_asset.py create mode 100644 python/src/hypercolor/_generated/api/assets/get_asset.py create mode 100644 python/src/hypercolor/_generated/api/assets/get_asset_blob.py create mode 100644 python/src/hypercolor/_generated/api/assets/get_asset_thumbnail.py create mode 100644 python/src/hypercolor/_generated/api/assets/list_assets.py create mode 100644 python/src/hypercolor/_generated/api/assets/update_asset.py create mode 100644 python/src/hypercolor/_generated/api/assets/upload_asset.py create mode 100644 python/src/hypercolor/_generated/api/capture/__init__.py create mode 100644 python/src/hypercolor/_generated/api/capture/authorize_input_monitoring.py create mode 100644 python/src/hypercolor/_generated/api/capture/authorize_screen_recording.py create mode 100644 python/src/hypercolor/_generated/api/capture/list_capture_monitors.py create mode 100644 python/src/hypercolor/_generated/api/capture/pick_capture_source.py create mode 100644 python/src/hypercolor/_generated/api/diagnostics/memory_diagnostics.py create mode 100644 python/src/hypercolor/_generated/api/effects/get_effect_screenshot.py create mode 100644 python/src/hypercolor/_generated/models/api_response_capture_authorization_response.py create mode 100644 python/src/hypercolor/_generated/models/api_response_capture_authorization_response_data.py create mode 100644 python/src/hypercolor/_generated/models/api_response_capture_picker_response.py create mode 100644 python/src/hypercolor/_generated/models/api_response_capture_picker_response_data.py create mode 100644 python/src/hypercolor/_generated/models/api_response_vec_capture_monitor.py create mode 100644 python/src/hypercolor/_generated/models/api_response_vec_capture_monitor_data_item.py create mode 100644 python/src/hypercolor/_generated/models/capture_authorization_response.py create mode 100644 python/src/hypercolor/_generated/models/capture_monitor.py create mode 100644 python/src/hypercolor/_generated/models/capture_picker_response.py create mode 100644 python/src/hypercolor/_generated/models/protected_source_grant_owner.py diff --git a/crates/hypercolor-core/src/config/mod.rs b/crates/hypercolor-core/src/config/mod.rs index 80731c4b6..69fa3212c 100644 --- a/crates/hypercolor-core/src/config/mod.rs +++ b/crates/hypercolor-core/src/config/mod.rs @@ -448,6 +448,15 @@ impl ConfigManager { writer.applied_capture = Some(capture.clone()); } + /// Forget which capture config the installed runtime source graph represents. + pub fn invalidate_capture_runtime_applied(&self) { + let mut writer = self + .write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + writer.applied_capture = None; + } + /// Whether the installed capture runtime was built from this exact config. #[must_use] pub fn capture_runtime_matches(&self, capture: &CaptureConfig) -> bool { diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index 5f588c4fa..4b79c0900 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -75,6 +75,7 @@ use hypercolor_types::sensor::SystemSnapshot; use std::ops::{Deref, DerefMut}; use std::sync::{Arc, LazyLock}; use std::time::Instant; +use thiserror::Error; use tokio::sync::watch; use tracing::{error, info}; @@ -233,6 +234,38 @@ pub enum ScreenReconfigurationConflict { InvalidReplacement, } +/// Rejected host-input source swap. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum HostReconfigurationError { + /// More than one host source violates the manager's replacement invariant. + #[error("more than one host input source is registered")] + SourceTopologyChanged, + /// The candidate is not a running host interaction source. + #[error("prepared host input replacement is invalid")] + InvalidReplacement, +} + +/// Host sources detached by an atomic graph commit. +#[must_use = "retired host sources must be stopped outside the input manager lock"] +pub struct HostRuntimeRetirement { + source: Option, + source_graph_generation: u64, +} + +impl HostRuntimeRetirement { + /// Stop the detached source and retire its status handle. + pub fn retire(mut self) { + let Some(source) = &mut self.source else { + return; + }; + source.stop(); + if let Err(error) = source.retire_source_status(self.source_graph_generation) { + error!(source = source.name(), %error, "Failed to retire host input source status"); + } + info!(source = source.name(), "Retired host input source"); + } +} + /// Screen sources detached by an atomic graph commit. #[must_use = "retired screen sources must be stopped outside the input manager lock"] pub struct ScreenRuntimeRetirement { @@ -393,6 +426,17 @@ impl ManagedInputSource { self.slot.status().clone() } + fn mark_prestarted_compatibility_live(&mut self) { + let Some(status) = &mut self.compatibility_status else { + return; + }; + let session = status + .begin_session() + .expect("validated compatibility host source can begin its session") + .expect("manager-bound compatibility host source creates a session"); + session.mark_event_driven_live_without_deadline(1); + } + fn set_source_graph_generation(&mut self, source_graph_generation: u64) { self.source .set_source_graph_generation(source_graph_generation); @@ -1846,6 +1890,69 @@ impl InputManager { .any(|source| source.is_host_capture_source()) } + /// Atomically replace the registered host source with one pre-started candidate. + /// + /// The detached source remains running in the returned retirement owner until + /// the caller releases it outside the input-manager lock. A rejected candidate + /// leaves the current source and graph generation unchanged. + /// + /// # Errors + /// + /// Returns an error when the manager does not contain exactly zero or one host + /// source, or when the supplied candidate is not a running host interaction + /// source. + pub fn swap_host_capture_source( + &mut self, + replacement: &mut Option>, + ) -> Result { + let mut host_indices = self + .sources + .iter() + .enumerate() + .filter_map(|(index, source)| source.is_host_capture_source().then_some(index)); + let current_index = host_indices.next(); + if host_indices.next().is_some() { + return Err(HostReconfigurationError::SourceTopologyChanged); + } + if replacement.as_ref().is_some_and(|source| { + !source.is_host_capture_source() + || !source.is_interaction_source() + || !source.is_running() + }) { + return Err(HostReconfigurationError::InvalidReplacement); + } + if current_index.is_none() && replacement.is_none() { + return Ok(HostRuntimeRetirement { + source: None, + source_graph_generation: self.source_graph_generation, + }); + } + + let source_graph_generation = self.bump_source_graph_generation(); + let prepared = replacement.take().map(|source| { + let mut prepared = self.create_managed_source(source, source_graph_generation); + prepared.mark_prestarted_compatibility_live(); + prepared + }); + let retired = match (current_index, prepared) { + (Some(index), Some(prepared)) => { + Some(std::mem::replace(&mut self.sources[index], prepared)) + } + (Some(index), None) => Some(self.sources.remove(index)), + (None, Some(prepared)) => { + self.sources.push(prepared); + None + } + (None, None) => unreachable!("empty host swap returned before graph mutation"), + }; + self.interaction_capture_active = None; + self.publish_source_status_registry(); + Ok(HostRuntimeRetirement { + source: retired, + source_graph_generation, + }) + } + /// Stop and remove only host hardware capture sources. /// /// Leaves the browser injection source in place so disabling host @@ -2471,3 +2578,120 @@ impl Default for InputManager { Self::new() } } + +#[cfg(test)] +mod host_source_swap_tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::{HostReconfigurationError, InputData, InputManager, InputSource, SourceState}; + + struct HostSource { + name: &'static str, + running: bool, + stopped: Arc, + } + + impl HostSource { + fn new(name: &'static str, stopped: Arc) -> Self { + Self { + name, + running: false, + stopped, + } + } + } + + impl InputSource for HostSource { + fn name(&self) -> &'static str { + self.name + } + + fn start(&mut self) -> anyhow::Result<()> { + self.running = true; + Ok(()) + } + + fn stop(&mut self) { + self.running = false; + self.stopped.store(true, Ordering::Release); + } + + fn sample(&mut self) -> anyhow::Result { + Ok(InputData::None) + } + + fn is_running(&self) -> bool { + self.running + } + + fn is_interaction_source(&self) -> bool { + true + } + + fn is_host_capture_source(&self) -> bool { + true + } + } + + #[test] + fn successful_host_swap_defers_old_source_retirement() { + let old_stopped = Arc::new(AtomicBool::new(false)); + let candidate_stopped = Arc::new(AtomicBool::new(false)); + let mut old = Box::new(HostSource::new("old-host", Arc::clone(&old_stopped))); + old.start().expect("old host source starts"); + let mut manager = InputManager::new(); + manager.add_source(old); + let initial_generation = manager.source_graph_generation(); + + let mut candidate: Option> = Some(Box::new(HostSource::new( + "candidate-host", + Arc::clone(&candidate_stopped), + ))); + candidate + .as_mut() + .expect("candidate exists") + .start() + .expect("candidate host source starts"); + let retirement = manager + .swap_host_capture_source(&mut candidate) + .expect("running candidate swaps atomically"); + + assert!(candidate.is_none()); + assert_eq!(manager.source_names(), ["candidate-host"]); + assert!(manager.source_graph_generation() > initial_generation); + assert_eq!( + manager.source_status_registry().snapshot().statuses()[0].state, + SourceState::Live + ); + assert!(!old_stopped.load(Ordering::Acquire)); + assert!(!candidate_stopped.load(Ordering::Acquire)); + + retirement.retire(); + assert!(old_stopped.load(Ordering::Acquire)); + assert!(!candidate_stopped.load(Ordering::Acquire)); + } + + #[test] + fn nonrunning_candidate_preserves_last_good_host_source() { + let old_stopped = Arc::new(AtomicBool::new(false)); + let mut old = Box::new(HostSource::new("old-host", Arc::clone(&old_stopped))); + old.start().expect("old host source starts"); + let mut manager = InputManager::new(); + manager.add_source(old); + let initial_generation = manager.source_graph_generation(); + let mut candidate: Option> = Some(Box::new(HostSource::new( + "failed-candidate", + Arc::new(AtomicBool::new(false)), + ))); + + assert!(matches!( + manager.swap_host_capture_source(&mut candidate), + Err(HostReconfigurationError::InvalidReplacement) + )); + assert!(candidate.is_some()); + assert_eq!(manager.source_names(), ["old-host"]); + assert_eq!(manager.source_graph_generation(), initial_generation); + assert!(!old_stopped.load(Ordering::Acquire)); + } +} diff --git a/crates/hypercolor-daemon/src/api/capture.rs b/crates/hypercolor-daemon/src/api/capture.rs index 1831aef2a..67a9d7f7e 100644 --- a/crates/hypercolor-daemon/src/api/capture.rs +++ b/crates/hypercolor-daemon/src/api/capture.rs @@ -1,39 +1,55 @@ //! Screen capture endpoints — `/api/v1/capture/*`. use std::sync::Arc; +#[cfg(target_os = "macos")] +use std::sync::atomic::Ordering; -use axum::extract::State; +use axum::extract::{Extension, State}; use axum::response::Response; use tracing::{info, warn}; use hypercolor_core::input::{ - MacosCapabilityOwner, ProtectedSourceActionOwner, ResolvedProtectedSourceAction, + MacosCapabilityOwner, ProtectedSourceActionOwner, ResolvedProtectedSourceAction, SourceKind, +}; +#[cfg(target_os = "macos")] +use hypercolor_core::input::{MacosSelectionState, SourcePlatformStatus, SourceStatusHandle}; +use hypercolor_types::api::capture::{ + CaptureAuthorizationResponse, CaptureMonitor, CapturePickerResponse, ProtectedSourceGrantOwner, }; use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; +use crate::api::security::RequestLocality; + +fn local_request_rejection(locality: RequestLocality) -> Option { + (!locality.is_loopback()).then(|| { + ApiError::forbidden( + "Protected capture actions and monitor enumeration require a local request", + ) + }) +} -const fn grant_owner_name(owner: MacosCapabilityOwner) -> &'static str { +const fn grant_owner(owner: MacosCapabilityOwner) -> ProtectedSourceGrantOwner { match owner { - MacosCapabilityOwner::AppSidecar => "app_sidecar", - MacosCapabilityOwner::App => "app", - MacosCapabilityOwner::LaunchdService => "launchd_service", - MacosCapabilityOwner::HomebrewService => "homebrew_service", - MacosCapabilityOwner::Broker => "broker", - MacosCapabilityOwner::Standalone => "standalone", + MacosCapabilityOwner::AppSidecar => ProtectedSourceGrantOwner::AppSidecar, + MacosCapabilityOwner::App => ProtectedSourceGrantOwner::App, + MacosCapabilityOwner::LaunchdService => ProtectedSourceGrantOwner::LaunchdService, + MacosCapabilityOwner::HomebrewService => ProtectedSourceGrantOwner::HomebrewService, + MacosCapabilityOwner::Broker => ProtectedSourceGrantOwner::Broker, + MacosCapabilityOwner::Standalone => ProtectedSourceGrantOwner::Standalone, } } -const fn protected_action_owner_name(owner: ProtectedSourceActionOwner) -> &'static str { +const fn protected_action_owner(owner: ProtectedSourceActionOwner) -> ProtectedSourceGrantOwner { match owner { - ProtectedSourceActionOwner::Macos(owner) => grant_owner_name(owner), - ProtectedSourceActionOwner::PlatformBackend => "platform_backend", + ProtectedSourceActionOwner::Macos(owner) => grant_owner(owner), + ProtectedSourceActionOwner::PlatformBackend => ProtectedSourceGrantOwner::PlatformBackend, } } fn requires_app_ui_details(active_owner: MacosCapabilityOwner) -> serde_json::Value { serde_json::json!({ - "active_owner": grant_owner_name(active_owner), + "active_owner": grant_owner(active_owner), "remedy": { "kind": "requires_app_ui" }, }) } @@ -45,8 +61,116 @@ fn requires_app_ui(action: &str, active_owner: MacosCapabilityOwner) -> Response ) } +#[cfg(target_os = "macos")] +fn macos_selection(status: &SourceStatusHandle) -> Option<(u64, MacosSelectionState)> { + let status = status.snapshot(); + let SourcePlatformStatus::MacosScreen(platform) = status.platform.as_deref()? else { + return None; + }; + Some((platform.selection_revision, platform.selection.clone())) +} + +#[cfg(target_os = "macos")] +fn persisted_macos_selection(selection: &MacosSelectionState) -> Option { + match selection { + MacosSelectionState::None => None, + MacosSelectionState::Display { source_id } => Some(source_id.to_string()), + MacosSelectionState::SessionScoped { .. } => Some("session_scoped".to_owned()), + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug, PartialEq, Eq)] +enum MacosPickerPersistenceDecision { + Wait, + Persist(String), + Cancel, +} + +#[cfg(target_os = "macos")] +fn macos_picker_persistence_decision( + baseline_revision: u64, + selection_revision: u64, + selection: &MacosSelectionState, +) -> MacosPickerPersistenceDecision { + if selection_revision <= baseline_revision { + return MacosPickerPersistenceDecision::Wait; + } + persisted_macos_selection(selection).map_or( + MacosPickerPersistenceDecision::Cancel, + MacosPickerPersistenceDecision::Persist, + ) +} + +#[cfg(target_os = "macos")] +async fn persist_next_macos_selection( + status: SourceStatusHandle, + baseline_revision: u64, + configured_source: String, + persistence: crate::startup::services::CaptureConfigPersistenceGate, +) { + let mut subscription = status.subscribe(); + loop { + let Some((revision, selection)) = macos_selection(&status) else { + return; + }; + match macos_picker_persistence_decision(baseline_revision, revision, &selection) { + MacosPickerPersistenceDecision::Wait => {} + MacosPickerPersistenceDecision::Persist(resolved) => { + persistence.publish_macos_selection(configured_source, resolved); + return; + } + MacosPickerPersistenceDecision::Cancel => return, + } + if subscription.changed().await.is_none() { + return; + } + } +} + +#[cfg(target_os = "macos")] +fn install_macos_picker_persistence_task( + current: &mut Option<(u64, tokio::task::JoinHandle<()>)>, + request_epoch: u64, + spawn: impl FnOnce() -> tokio::task::JoinHandle<()>, +) { + if current + .as_ref() + .is_some_and(|(current_epoch, _)| *current_epoch >= request_epoch) + { + return; + } + let task = spawn(); + if let Some((_, previous)) = current.replace((request_epoch, task)) { + previous.abort(); + } +} + /// `POST /api/v1/input/authorize` — Request macOS Input Monitoring. -pub async fn authorize_input_monitoring(State(state): State>) -> Response { +#[utoipa::path( + post, + path = "/api/v1/input/authorize", + responses( + ( + status = 200, + description = "Input Monitoring authorization result", + body = crate::api::envelope::ApiResponse + ), + ( + status = 403, + description = "Local request required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn authorize_input_monitoring( + State(state): State>, + Extension(locality): Extension, +) -> Response { + if let Some(response) = local_request_rejection(locality) { + return response; + } let Some(manager) = state.config_manager.as_ref() else { return ApiError::internal("Config manager unavailable in this runtime"); }; @@ -72,10 +196,10 @@ pub async fn authorize_input_monitoring(State(state): State>) -> R match tokio::task::spawn_blocking(move || action.execute()).await { Ok(Ok(authorized)) => { info!(authorized, "Input Monitoring authorization requested"); - ApiResponse::ok(serde_json::json!({ - "authorized": authorized, - "grant_owner": protected_action_owner_name(grant_owner), - })) + ApiResponse::ok(CaptureAuthorizationResponse { + authorized, + grant_owner: protected_action_owner(grant_owner), + }) } Ok(Err(error)) => { warn!(%error, "Input Monitoring authorization failed"); @@ -88,7 +212,30 @@ pub async fn authorize_input_monitoring(State(state): State>) -> R } /// `POST /api/v1/capture/authorize` — Request macOS Screen Recording. -pub async fn authorize_screen_recording(State(state): State>) -> Response { +#[utoipa::path( + post, + path = "/api/v1/capture/authorize", + responses( + ( + status = 200, + description = "Screen Recording authorization result", + body = crate::api::envelope::ApiResponse + ), + ( + status = 403, + description = "Local request required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn authorize_screen_recording( + State(state): State>, + Extension(locality): Extension, +) -> Response { + if let Some(response) = local_request_rejection(locality) { + return response; + } let Some(manager) = state.config_manager.as_ref() else { return ApiError::internal("Config manager unavailable in this runtime"); }; @@ -113,10 +260,10 @@ pub async fn authorize_screen_recording(State(state): State>) -> R match tokio::task::spawn_blocking(move || action.execute()).await { Ok(Ok(authorized)) => { info!(authorized, "Screen Recording authorization requested"); - ApiResponse::ok(serde_json::json!({ - "authorized": authorized, - "grant_owner": protected_action_owner_name(grant_owner), - })) + ApiResponse::ok(CaptureAuthorizationResponse { + authorized, + grant_owner: protected_action_owner(grant_owner), + }) } Ok(Err(error)) => { warn!(%error, "Screen Recording authorization failed"); @@ -130,28 +277,57 @@ pub async fn authorize_screen_recording(State(state): State>) -> R /// `POST /api/v1/capture/source/pick` — Re-open the portal source picker. /// -/// Drops the persisted restore token so the desktop portal prompts for a -/// fresh source selection. The new choice is persisted automatically once -/// the user confirms the picker. -pub async fn pick_capture_source(State(state): State>) -> Response { +/// The accepted choice is persisted according to the platform source grammar. +#[utoipa::path( + post, + path = "/api/v1/capture/source/pick", + responses( + ( + status = 200, + description = "Capture source picker dispatched", + body = crate::api::envelope::ApiResponse + ), + ( + status = 403, + description = "Local request required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn pick_capture_source( + State(state): State>, + Extension(locality): Extension, +) -> Response { + if let Some(response) = local_request_rejection(locality) { + return response; + } let Some(manager) = state.config_manager.as_ref() else { return ApiError::internal("Config manager unavailable in this runtime"); }; - if !manager.get().capture.enabled { + let expected = manager.get(); + if !expected.capture.enabled { return ApiError::validation( "Screen capture is disabled; enable capture.enabled before picking a source", ); } - let action = { + let (action, screen_status) = { let input_manager = state.input_manager.lock().await; if !input_manager.has_screen_source() { return ApiError::validation( "No screen capture source is registered; restart the daemon or re-enable capture", ); } - input_manager.resolved_screen_source_picker_action() + let status = input_manager + .source_status_registry() + .snapshot() + .handles() + .iter() + .find(|status| status.snapshot().kind == SourceKind::Screen) + .cloned(); + (input_manager.resolved_screen_source_picker_action(), status) }; let Some(action) = action else { return ApiError::validation("No detached screen source picker action is available"); @@ -162,39 +338,70 @@ pub async fn pick_capture_source(State(state): State>) -> Response return requires_app_ui("Screen source picker", active_owner); } }; + #[cfg(target_os = "macos")] + let Some(macos_status) = screen_status else { + return ApiError::internal("macOS screen source status is unavailable"); + }; + #[cfg(target_os = "macos")] + let Some((baseline_revision, _)) = macos_selection(&macos_status) else { + return ApiError::internal("macOS screen source status is unavailable"); + }; + #[cfg(target_os = "macos")] + let macos_persistence = + match crate::startup::services::CaptureConfigPersistenceGate::for_macos_picker( + Arc::clone(manager), + &expected, + macos_status.clone(), + ) { + Ok(persistence) => persistence, + Err(error) => { + return ApiError::conflict(format!( + "Capture configuration changed before picker dispatch: {error}" + )); + } + }; + #[cfg(target_os = "macos")] + let configured_source = expected.capture.source.clone(); + #[cfg(target_os = "macos")] + let request_epoch = state + .capture_picker_request_epoch + .fetch_add(1, Ordering::Relaxed) + .checked_add(1) + .expect("macOS picker request epoch exhausted"); + #[cfg(not(target_os = "macos"))] + let _ = screen_status; let picker_result = tokio::task::spawn_blocking(move || action.execute()) .await .map_err(|error| anyhow::anyhow!("source picker task failed: {error}")) .and_then(|result| result); if let Err(error) = picker_result { + #[cfg(target_os = "macos")] + macos_persistence.revoke(); warn!(%error, "Failed to re-open screen source picker"); return ApiError::internal(format!("Failed to re-open source picker: {error}")); } - info!("Screen capture source picker requested"); - ApiResponse::ok(serde_json::json!({ - "picking": true, - "grant_owner": protected_action_owner_name(grant_owner), - })) -} + #[cfg(target_os = "macos")] + { + let mut current = state + .capture_picker_persistence_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + install_macos_picker_persistence_task(&mut current, request_epoch, || { + tokio::spawn(persist_next_macos_selection( + macos_status, + baseline_revision, + configured_source, + macos_persistence, + )) + }); + } -/// One display output the capture backend can address, for monitor pickers. -#[derive(Debug, serde::Serialize)] -pub struct CaptureMonitor { - /// Zero-based capture index. - pub index: usize, - /// Stable source id persisted in capture configuration. - pub id: String, - /// OS device name, e.g. `\\.\DISPLAY1`. - pub name: String, - /// Desktop width in pixels. - pub width: u32, - /// Desktop height in pixels. - pub height: u32, - /// Whether this output anchors the virtual desktop origin. - pub primary: bool, - /// Ready-to-store `capture.source` value selecting this output. - pub value: String, + info!("Screen capture source picker requested"); + ApiResponse::ok(CapturePickerResponse { + picking: true, + grant_owner: protected_action_owner(grant_owner), + }) } /// `GET /api/v1/capture/monitors` — Display outputs capture can address. @@ -202,7 +409,29 @@ pub struct CaptureMonitor { /// Empty on platforms where the backend picks its own source (the XDG /// portal on Linux); the UI uses emptiness to decide between a monitor /// dropdown and the portal picker button. -pub async fn list_capture_monitors() -> Response { +#[utoipa::path( + get, + path = "/api/v1/capture/monitors", + responses( + ( + status = 200, + description = "Addressable capture displays", + body = crate::api::envelope::ApiResponse> + ), + ( + status = 403, + description = "Local request required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn list_capture_monitors( + Extension(locality): Extension, +) -> Response { + if let Some(response) = local_request_rejection(locality) { + return response; + } let monitors: Vec = hypercolor_core::input::screen::available_monitors() .into_iter() .map(|monitor| CaptureMonitor { @@ -221,9 +450,22 @@ pub async fn list_capture_monitors() -> Response { #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] + use std::cell::Cell; + #[cfg(target_os = "macos")] + use std::sync::Arc; + + #[cfg(target_os = "macos")] + use hypercolor_core::input::MacosSelectionState; use hypercolor_core::input::{MacosCapabilityOwner, ProtectedSourceActionOwner}; + use hypercolor_types::api::capture::ProtectedSourceGrantOwner; - use super::{grant_owner_name, protected_action_owner_name, requires_app_ui_details}; + #[cfg(target_os = "macos")] + use super::{ + MacosPickerPersistenceDecision, install_macos_picker_persistence_task, + macos_picker_persistence_decision, + }; + use super::{grant_owner, protected_action_owner, requires_app_ui_details}; #[test] fn protected_grant_owner_names_are_stable_and_process_specific() { @@ -236,19 +478,19 @@ mod tests { MacosCapabilityOwner::Broker, MacosCapabilityOwner::Standalone, ] - .map(grant_owner_name), + .map(grant_owner), [ - "app_sidecar", - "app", - "launchd_service", - "homebrew_service", - "broker", - "standalone", + ProtectedSourceGrantOwner::AppSidecar, + ProtectedSourceGrantOwner::App, + ProtectedSourceGrantOwner::LaunchdService, + ProtectedSourceGrantOwner::HomebrewService, + ProtectedSourceGrantOwner::Broker, + ProtectedSourceGrantOwner::Standalone, ] ); assert_eq!( - protected_action_owner_name(ProtectedSourceActionOwner::PlatformBackend), - "platform_backend" + protected_action_owner(ProtectedSourceActionOwner::PlatformBackend), + ProtectedSourceGrantOwner::PlatformBackend ); assert_eq!( requires_app_ui_details(MacosCapabilityOwner::LaunchdService), @@ -258,4 +500,57 @@ mod tests { }) ); } + + #[cfg(target_os = "macos")] + #[test] + fn picker_persistence_requires_a_strictly_newer_accepted_selection() { + let display = MacosSelectionState::Display { + source_id: Arc::from("display:7a3f4954-3d72-47a6-a914-16ef68d02122"), + }; + let session = MacosSelectionState::SessionScoped { + content_style: Arc::from("application"), + }; + + assert_eq!( + macos_picker_persistence_decision(7, 7, &display), + MacosPickerPersistenceDecision::Wait + ); + assert_eq!( + macos_picker_persistence_decision(7, 8, &display), + MacosPickerPersistenceDecision::Persist( + "display:7a3f4954-3d72-47a6-a914-16ef68d02122".to_owned() + ) + ); + assert_eq!( + macos_picker_persistence_decision(7, 8, &session), + MacosPickerPersistenceDecision::Persist("session_scoped".to_owned()) + ); + assert_eq!( + macos_picker_persistence_decision(7, 8, &MacosSelectionState::None), + MacosPickerPersistenceDecision::Cancel + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn picker_observer_installation_preserves_newest_request_order() { + let mut current = None; + let spawn_count = Cell::new(0); + install_macos_picker_persistence_task(&mut current, 2, || { + spawn_count.set(spawn_count.get() + 1); + tokio::spawn(std::future::pending::<()>()) + }); + install_macos_picker_persistence_task(&mut current, 1, || { + spawn_count.set(spawn_count.get() + 1); + tokio::spawn(std::future::pending::<()>()) + }); + + assert_eq!(current.as_ref().map(|(epoch, _)| *epoch), Some(2)); + assert_eq!(spawn_count.get(), 1); + current + .take() + .expect("newer observer should remain") + .1 + .abort(); + } } diff --git a/crates/hypercolor-daemon/src/api/config.rs b/crates/hypercolor-daemon/src/api/config.rs index 3cf51c824..31be94cf7 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -188,6 +188,48 @@ pub async fn set_config_value( } } + if should_reconfigure_input(Some(&key)) { + match apply_host_input_config_transaction(&state, ¤t_snapshot, updated.input.clone()) + .await + { + Ok(live) => { + let effective_config = manager.get(); + let effective_root = match serde_json::to_value(&**effective_config) { + Ok(value) => value, + Err(error) => { + return ApiError::internal(format!( + "Failed to serialize canonicalized config: {error}" + )); + } + }; + let Some(effective_value) = get_json_path(&effective_root, &key).cloned() else { + return ApiError::internal(format!( + "Canonicalized config is missing expected key: {key}" + )); + }; + return ApiResponse::ok(serde_json::json!({ + "key": key, + "value": effective_value, + "live": live, + "path": manager.path().display().to_string(), + })); + } + Err(HostInputConfigTransactionError::Conflict) => { + return ApiError::conflict( + "Input config or source graph changed while its candidate was prepared; retry the update", + ); + } + Err(HostInputConfigTransactionError::Prepare(error)) => { + return ApiError::validation(format!( + "Failed to prepare live host input config: {error}" + )); + } + Err(HostInputConfigTransactionError::Persist(error)) => { + return ApiError::internal(format!("Failed to persist config: {error}")); + } + } + } + // Re-apply the validated key against the freshest config under the // manager's write lock, so a concurrent targeted writer (e.g. the // capture restore-token sink) is not clobbered by this handler's @@ -341,6 +383,40 @@ pub async fn reset_config_value( })); } + if normalized_key + .as_deref() + .is_some_and(|key| should_reconfigure_input(Some(key))) + { + let live = match apply_host_input_config_transaction( + &state, + ¤t_snapshot, + updated.input.clone(), + ) + .await + { + Ok(live) => live, + Err(HostInputConfigTransactionError::Conflict) => { + return ApiError::conflict( + "Input config or source graph changed while its candidate was prepared; retry the reset", + ); + } + Err(HostInputConfigTransactionError::Prepare(error)) => { + return ApiError::validation(format!( + "Failed to prepare live host input config: {error}" + )); + } + Err(HostInputConfigTransactionError::Persist(error)) => { + return ApiError::internal(format!("Failed to persist config: {error}")); + } + }; + return ApiResponse::ok(serde_json::json!({ + "key": normalized_key, + "reset": true, + "live": live, + "path": manager.path().display().to_string(), + })); + } + // Keyed resets re-apply the default at the key against the freshest // config under the write lock (same race protection as set); a full // reset replaces wholesale by design. @@ -711,14 +787,11 @@ async fn apply_capture_config_transaction( ))); }; #[cfg(target_os = "macos")] - if capture_diff_is_processing_only(&expected_config.capture, &capture) { - return apply_macos_capture_processing_transaction( - state, - manager, - expected_config, - capture, - ) - .await; + if capture_diff_is_live_compatible(&expected_config.capture, &capture) + && capture_runtime_matches(state, expected_config).await + { + return apply_macos_capture_live_transaction(state, manager, expected_config, capture) + .await; } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let (plan, capacity_plan, capacity_preparation, admission_coordinator) = { @@ -914,8 +987,19 @@ async fn apply_capture_config_transaction( } #[cfg(target_os = "macos")] -fn capture_diff_is_processing_only(previous: &CaptureConfig, next: &CaptureConfig) -> bool { +fn capture_diff_is_live_compatible(previous: &CaptureConfig, next: &CaptureConfig) -> bool { let mut normalized = previous.clone(); + normalized.capture_fps = next.capture_fps; + normalized.cadence = next.cadence; + normalized.grid_cols = next.grid_cols; + normalized.grid_rows = next.grid_rows; + normalized.smoothing = next.smoothing; + normalized.scene_cut_threshold = next.scene_cut_threshold; + normalized.letterbox = next.letterbox; + normalized.letterbox_threshold = next.letterbox_threshold; + normalized.saturation = next.saturation; + normalized.brightness = next.brightness; + normalized.gamma = next.gamma; normalized.target_led_white_x = next.target_led_white_x; normalized.target_led_white_y = next.target_led_white_y; normalized.target_led_reference_white_nits = next.target_led_reference_white_nits; @@ -925,7 +1009,7 @@ fn capture_diff_is_processing_only(previous: &CaptureConfig, next: &CaptureConfi } #[cfg(target_os = "macos")] -async fn apply_macos_capture_processing_transaction( +async fn apply_macos_capture_live_transaction( state: &Arc, manager: &Arc, expected_config: &Arc, @@ -940,7 +1024,7 @@ async fn apply_macos_capture_processing_transaction( return Err(CaptureConfigTransactionError::Conflict); } input_manager - .reconfigure_screen_processing(&next) + .reconfigure_screen_capture(&next) .map_err(CaptureConfigTransactionError::Prepare)?; let persisted = manager.modify_and_save_if_current(expected_config, |config| { config.capture.clone_from(&capture); @@ -948,21 +1032,23 @@ async fn apply_macos_capture_processing_transaction( match persisted { Ok(true) => {} Ok(false) => { - input_manager - .reconfigure_screen_processing(&previous) - .map_err(CaptureConfigTransactionError::Prepare)?; + if let Err(error) = input_manager.reconfigure_screen_capture(&previous) { + manager.invalidate_capture_runtime_applied(); + return Err(CaptureConfigTransactionError::Prepare(error)); + } return Err(CaptureConfigTransactionError::Conflict); } Err(error) => { - input_manager - .reconfigure_screen_processing(&previous) - .map_err(CaptureConfigTransactionError::Prepare)?; + if let Err(rollback_error) = input_manager.reconfigure_screen_capture(&previous) { + manager.invalidate_capture_runtime_applied(); + return Err(CaptureConfigTransactionError::Prepare(rollback_error)); + } return Err(CaptureConfigTransactionError::Persist(error)); } } manager.mark_capture_runtime_applied(&capture); drop(input_manager); - info!("Applied live macOS screen processing config without reopening capture"); + info!("Applied compatible macOS capture config without reopening the native stream"); Ok(()) } @@ -1063,6 +1149,142 @@ fn should_reconfigure_input(key: Option<&str>) -> bool { key.is_none_or(|value| value == "input" || value.starts_with("input.")) } +#[derive(Debug, thiserror::Error)] +enum HostInputConfigTransactionError { + #[error("input config identity or source topology changed during preparation")] + Conflict, + #[error(transparent)] + Prepare(anyhow::Error), + #[error(transparent)] + Persist(anyhow::Error), +} + +async fn apply_host_input_config_transaction( + state: &Arc, + expected_config: &Arc, + input: hypercolor_types::config::InputConfig, +) -> Result { + apply_host_input_config_transaction_with_builder( + state, + expected_config, + input, + crate::startup::services::build_interaction_source, + ) + .await +} + +async fn apply_host_input_config_transaction_with_builder( + state: &Arc, + expected_config: &Arc, + input: hypercolor_types::config::InputConfig, + build_source: impl FnOnce(&hypercolor_types::config::InputConfig) -> Option>, +) -> Result { + let Some(manager) = state.config_manager.as_ref() else { + return Err(HostInputConfigTransactionError::Prepare(anyhow::anyhow!( + "config manager unavailable" + ))); + }; + let route_snapshot = state.interaction_routing.snapshot(); + let route_changed = route_snapshot.daemon_policy != input.daemon_route + || route_snapshot.preview_policy != input.preview_route; + let host_changed = expected_config.input.enabled != input.enabled + || expected_config.input.keyboard != input.keyboard + || expected_config.input.mouse != input.mouse; + + let mut replacement = host_changed.then(|| build_source(&input)).flatten(); + if let Some(mut candidate) = replacement.take() { + candidate = tokio::task::spawn_blocking(move || { + candidate.start()?; + Ok::<_, anyhow::Error>(candidate) + }) + .await + .map_err(|error| { + HostInputConfigTransactionError::Prepare(anyhow::anyhow!( + "host input preparation task failed: {error}" + )) + })? + .map_err(HostInputConfigTransactionError::Prepare)?; + replacement = Some(candidate); + } + + let mut input_manager = state.input_manager.lock().await; + if !manager.is_current(expected_config) { + drop(input_manager); + stop_prepared_host_source(replacement).await; + return Err(HostInputConfigTransactionError::Conflict); + } + let persisted = match manager.modify_and_save_if_current(expected_config, |config| { + config.input.clone_from(&input); + }) { + Ok(persisted) => persisted, + Err(error) => { + drop(input_manager); + stop_prepared_host_source(replacement).await; + return Err(HostInputConfigTransactionError::Persist(error)); + } + }; + if !persisted { + drop(input_manager); + stop_prepared_host_source(replacement).await; + return Err(HostInputConfigTransactionError::Conflict); + } + let persisted_snapshot = Arc::clone(&manager.get()); + + let retirement = if host_changed { + match input_manager.swap_host_capture_source(&mut replacement) { + Ok(retirement) => Some(retirement), + Err(error) => { + let rollback = manager.modify_and_save_if_current(&persisted_snapshot, |config| { + config.input.clone_from(&expected_config.input); + }); + drop(input_manager); + stop_prepared_host_source(replacement).await; + match rollback { + Ok(true) => {} + Ok(false) => return Err(HostInputConfigTransactionError::Conflict), + Err(rollback_error) => { + return Err(HostInputConfigTransactionError::Persist(rollback_error)); + } + } + return Err(HostInputConfigTransactionError::Prepare(anyhow::anyhow!( + error + ))); + } + } + } else { + None + }; + drop(input_manager); + + if let Some(retirement) = retirement + && let Err(error) = tokio::task::spawn_blocking(move || retirement.retire()).await + { + warn!(%error, "Detached host input source retirement task failed"); + } + if route_changed { + state.interaction_routing.publish_policies( + route_snapshot + .config_generation + .checked_add(1) + .expect("interaction route config generation exhausted"), + input.daemon_route, + input.preview_route, + ); + } + info!( + host_changed, + route_changed, "Applied live host input config" + ); + Ok(host_changed || route_changed) +} + +async fn stop_prepared_host_source(source: Option>) { + let Some(mut source) = source else { + return; + }; + let _ = tokio::task::spawn_blocking(move || source.stop()).await; +} + /// Apply host-input config changes live. /// /// Enable/disable adds or removes the interaction source on the running @@ -1096,27 +1318,40 @@ async fn maybe_apply_input_config_change(state: &Arc, key: Option<&str return route_changed; } + let mut replacement = crate::startup::services::build_interaction_source(&input); + if let Some(mut candidate) = replacement.take() { + match tokio::task::spawn_blocking(move || { + candidate.start()?; + Ok::<_, anyhow::Error>(candidate) + }) + .await + { + Ok(Ok(candidate)) => replacement = Some(candidate), + Ok(Err(error)) => { + warn!(%error, "Failed to prepare live host input source; retaining last-good source"); + return route_changed; + } + Err(error) => { + warn!(%error, "Host input preparation task failed; retaining last-good source"); + return route_changed; + } + } + } + let mut input_manager = state.input_manager.lock().await; - // Only the host hardware source is consent-gated; the browser injection - // source is always registered and must survive enable/disable toggles. - let had_source = input_manager.has_host_capture_source(); - let replacement = crate::startup::services::build_interaction_source(&input); - - // Rebuild on any change so keyboard/mouse toggles apply, not just enable - // and disable. - input_manager.remove_host_capture_sources(); - let Some(mut source) = replacement else { - if had_source { - info!("Disabled host input capture live"); + let retirement = match input_manager.swap_host_capture_source(&mut replacement) { + Ok(retirement) => retirement, + Err(error) => { + drop(input_manager); + stop_prepared_host_source(replacement).await; + warn!(%error, "Host input graph changed; retaining last-good source"); + return route_changed; } - return had_source || route_changed; }; - - if let Err(error) = source.start() { - warn!(%error, "Failed to start live host input source"); - return had_source || route_changed; + drop(input_manager); + if let Err(error) = tokio::task::spawn_blocking(move || retirement.retire()).await { + warn!(%error, "Detached host input source retirement task failed"); } - input_manager.add_source(source); info!("Applied live host input capture config"); true } @@ -1296,13 +1531,16 @@ async fn sync_active_layout_canvas_size_workflow( #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use hypercolor_core::config::ConfigManager; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use hypercolor_core::input::screen::ScreenAdmissionCapacity; - use hypercolor_core::input::screen::{PixelExtent, ScreenCaptureDemand}; + use hypercolor_core::input::screen::{ + CaptureConfig as ScreenCaptureConfig, PixelExtent, ScreenCaptureDemand, + }; use hypercolor_core::input::{ InputData, InputManager, InputSource, ScreenReconfigurationConflict, SourceIssue, SourceKind, SourceState, SourceStatus, SourceStatusHandle, SourceStatusReporter, @@ -1310,10 +1548,11 @@ mod tests { use hypercolor_types::config::InteractionRoutePolicy; #[cfg(target_os = "macos")] - use super::capture_diff_is_processing_only; + use super::capture_diff_is_live_compatible; use super::{ CAPTURE_CALIBRATION_RESET_KEY, CaptureConfigTransactionError, ResetConfigRequest, - SetConfigRequest, apply_capture_config_transaction, canvas_dimensions_differ, + SetConfigRequest, apply_capture_config_transaction, + apply_host_input_config_transaction_with_builder, canvas_dimensions_differ, capture_statuses_match, maybe_apply_input_config_change, reset_config_value, reset_json_scope, set_config_value, validate_prepared_capture_status, }; @@ -1323,6 +1562,63 @@ mod tests { running: bool, demand: ScreenCaptureDemand, stopped: Arc, + reconfigurations: Option>>>, + reject_reconfiguration: Arc, + reject_after_first_reconfiguration: bool, + reconfiguration_attempts: usize, + } + + struct TestHostSource { + name: &'static str, + running: bool, + start_error: bool, + stopped: Arc, + } + + impl TestHostSource { + fn new(name: &'static str, start_error: bool, stopped: Arc) -> Self { + Self { + name, + running: false, + start_error, + stopped, + } + } + } + + impl InputSource for TestHostSource { + fn name(&self) -> &'static str { + self.name + } + + fn start(&mut self) -> anyhow::Result<()> { + if self.start_error { + anyhow::bail!("test host source start failed"); + } + self.running = true; + Ok(()) + } + + fn stop(&mut self) { + self.running = false; + self.stopped.store(true, Ordering::Release); + } + + fn sample(&mut self) -> anyhow::Result { + Ok(InputData::None) + } + + fn is_running(&self) -> bool { + self.running + } + + fn is_interaction_source(&self) -> bool { + true + } + + fn is_host_capture_source(&self) -> bool { + true + } } impl TestScreenSource { @@ -1331,6 +1627,41 @@ mod tests { running: false, demand: ScreenCaptureDemand::Inactive, stopped, + reconfigurations: None, + reject_reconfiguration: Arc::new(AtomicBool::new(false)), + reject_after_first_reconfiguration: false, + reconfiguration_attempts: 0, + } + } + + fn tracked( + stopped: Arc, + reconfigurations: Arc>>, + reject_reconfiguration: Arc, + ) -> Self { + Self { + running: false, + demand: ScreenCaptureDemand::Inactive, + stopped, + reconfigurations: Some(reconfigurations), + reject_reconfiguration, + reject_after_first_reconfiguration: false, + reconfiguration_attempts: 0, + } + } + + fn reject_rollback( + stopped: Arc, + reconfigurations: Arc>>, + ) -> Self { + Self { + running: false, + demand: ScreenCaptureDemand::Inactive, + stopped, + reconfigurations: Some(reconfigurations), + reject_reconfiguration: Arc::new(AtomicBool::new(false)), + reject_after_first_reconfiguration: true, + reconfiguration_attempts: 0, } } } @@ -1370,6 +1701,25 @@ mod tests { self.demand = demand; Ok(()) } + + fn reconfigure_screen_capture( + &mut self, + config: &ScreenCaptureConfig, + ) -> anyhow::Result<()> { + self.reconfiguration_attempts = self.reconfiguration_attempts.saturating_add(1); + if self.reject_reconfiguration.load(Ordering::Acquire) + || self.reject_after_first_reconfiguration && self.reconfiguration_attempts > 1 + { + anyhow::bail!("test source rejected capture config"); + } + if let Some(reconfigurations) = &self.reconfigurations { + reconfigurations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(config.clone()); + } + Ok(()) + } } fn test_screen_demand() -> ScreenCaptureDemand { @@ -1472,6 +1822,97 @@ mod tests { ); } + #[tokio::test] + async fn failed_host_candidate_preserves_last_good_source_and_config() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let manager = Arc::new( + ConfigManager::new(tempdir.path().join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| config.input.enabled = true); + let expected = Arc::clone(&manager.get()); + let old_stopped = Arc::new(AtomicBool::new(false)); + let mut old = Box::new(TestHostSource::new( + "last-good-host", + false, + Arc::clone(&old_stopped), + )); + old.start().expect("last-good host source starts"); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + state.input_manager.lock().await.add_source(old); + let state = Arc::new(state); + let mut next = expected.input.clone(); + next.keyboard = !next.keyboard; + + let result = + apply_host_input_config_transaction_with_builder(&state, &expected, next, |_| { + Some(Box::new(TestHostSource::new( + "failed-candidate", + true, + Arc::new(AtomicBool::new(false)), + ))) + }) + .await; + + assert!(matches!( + result, + Err(super::HostInputConfigTransactionError::Prepare(_)) + )); + assert_eq!(manager.get().input.keyboard, expected.input.keyboard); + assert!( + state + .input_manager + .lock() + .await + .source_names() + .contains(&"last-good-host".to_owned()) + ); + assert!(!old_stopped.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn successful_host_candidate_commits_before_retiring_last_good() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let manager = Arc::new( + ConfigManager::new(tempdir.path().join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| config.input.enabled = true); + let expected = Arc::clone(&manager.get()); + let old_stopped = Arc::new(AtomicBool::new(false)); + let candidate_stopped = Arc::new(AtomicBool::new(false)); + let mut old = Box::new(TestHostSource::new( + "last-good-host", + false, + Arc::clone(&old_stopped), + )); + old.start().expect("last-good host source starts"); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + state.input_manager.lock().await.add_source(old); + let state = Arc::new(state); + let mut next = expected.input.clone(); + next.keyboard = !next.keyboard; + + apply_host_input_config_transaction_with_builder(&state, &expected, next.clone(), |_| { + Some(Box::new(TestHostSource::new( + "candidate-host", + false, + Arc::clone(&candidate_stopped), + ))) + }) + .await + .expect("prepared host candidate commits"); + + assert_eq!(manager.get().input.keyboard, next.keyboard); + assert!(old_stopped.load(Ordering::Acquire)); + assert!(!candidate_stopped.load(Ordering::Acquire)); + let sources = state.input_manager.lock().await.source_names(); + assert!(sources.contains(&"candidate-host".to_owned())); + assert!(!sources.contains(&"last-good-host".to_owned())); + } + #[tokio::test] async fn demanded_starting_capture_times_out_instead_of_committing() { let error = validate_prepared_capture_status(starting_screen_status()) @@ -1579,7 +2020,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn macos_processing_only_diff_accepts_exactly_the_five_tone_fields() { + fn macos_live_compatible_diff_accepts_processing_and_acquisition_fields() { let original = hypercolor_types::config::CaptureConfig::default(); let mut calibration = original.clone(); calibration.target_led_white_x = 0.3000; @@ -1587,7 +2028,18 @@ mod tests { calibration.target_led_reference_white_nits = 180.0; calibration.target_led_peak_nits = 500.0; calibration.exposure_ev = 1.25; - assert!(capture_diff_is_processing_only(&original, &calibration)); + calibration.capture_fps += 1; + calibration.cadence = hypercolor_types::config::CaptureCadenceMode::NativeRefresh; + calibration.grid_cols += 1; + calibration.grid_rows += 1; + calibration.smoothing = 0.75; + calibration.scene_cut_threshold = 72.0; + calibration.letterbox = !original.letterbox; + calibration.letterbox_threshold = 0.08; + calibration.saturation = 1.2; + calibration.brightness = 1.1; + calibration.gamma = 1.3; + assert!(capture_diff_is_live_compatible(&original, &calibration)); for divergent in [ { @@ -1602,19 +2054,181 @@ mod tests { }, { let mut config = calibration.clone(); - config.capture_fps = original.capture_fps + 1; + config.publication_memory_bytes = Some(1_000_000); config }, { let mut config = calibration.clone(); - config.smoothing = 0.75; + config.restore_token = Some("other-session".to_owned()); config }, ] { - assert!(!capture_diff_is_processing_only(&original, &divergent)); + assert!(!capture_diff_is_live_compatible(&original, &divergent)); } } + #[cfg(target_os = "macos")] + #[tokio::test] + async fn compatible_macos_capture_update_keeps_source_and_session_scope() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let manager = Arc::new( + ConfigManager::new(tempdir.path().join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| { + config.capture.enabled = true; + config.capture.source = "session_scoped".to_owned(); + }); + let expected = Arc::clone(&manager.get()); + manager.mark_capture_runtime_applied(&expected.capture); + let stopped = Arc::new(AtomicBool::new(false)); + let reconfigurations = Arc::new(StdMutex::new(Vec::new())); + let reject = Arc::new(AtomicBool::new(false)); + let source = Box::new(TestScreenSource::tracked( + Arc::clone(&stopped), + Arc::clone(&reconfigurations), + reject, + )); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + { + let mut input_manager = state.input_manager.lock().await; + input_manager.add_source(source); + input_manager + .start_all() + .expect("last-good input graph starts"); + } + let state = Arc::new(state); + let mut capture = expected.capture.clone(); + capture.capture_fps += 1; + capture.cadence = hypercolor_types::config::CaptureCadenceMode::NativeRefresh; + capture.grid_cols += 1; + capture.grid_rows += 1; + capture.smoothing = 0.8; + capture.scene_cut_threshold = 65.0; + capture.letterbox = true; + capture.letterbox_threshold = 0.08; + capture.saturation = 1.2; + capture.brightness = 1.1; + capture.gamma = 1.3; + capture.target_led_white_x = 0.31; + capture.target_led_white_y = 0.33; + capture.target_led_reference_white_nits = 180.0; + capture.target_led_peak_nits = 500.0; + capture.exposure_ev = 1.0; + let expected_runtime = crate::startup::services::screen_capture_config_from(&capture) + .expect("compatible runtime config should build"); + + apply_capture_config_transaction(&state, &expected, capture.clone()) + .await + .expect("compatible update applies in place"); + + assert_eq!(manager.get().capture, capture); + assert_eq!(manager.get().capture.source, "session_scoped"); + assert_eq!( + state.input_manager.lock().await.source_names(), + ["BrowserInput", "test_screen"] + ); + assert!(!stopped.load(Ordering::Acquire)); + let applied = reconfigurations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(applied.as_slice(), [expected_runtime]); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn rejected_macos_live_update_preserves_last_good_config_and_source() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let manager = Arc::new( + ConfigManager::new(tempdir.path().join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| config.capture.enabled = true); + let expected = Arc::clone(&manager.get()); + manager.mark_capture_runtime_applied(&expected.capture); + let stopped = Arc::new(AtomicBool::new(false)); + let reject = Arc::new(AtomicBool::new(true)); + let source = Box::new(TestScreenSource::tracked( + Arc::clone(&stopped), + Arc::new(StdMutex::new(Vec::new())), + reject, + )); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + { + let mut input_manager = state.input_manager.lock().await; + input_manager.add_source(source); + input_manager + .start_all() + .expect("last-good input graph starts"); + } + let state = Arc::new(state); + let mut capture = expected.capture.clone(); + capture.capture_fps += 1; + + let result = apply_capture_config_transaction(&state, &expected, capture).await; + + assert!(matches!( + result, + Err(CaptureConfigTransactionError::Prepare(_)) + )); + assert_eq!(manager.get().capture, expected.capture); + assert!(state.input_manager.lock().await.has_screen_source()); + assert!(!stopped.load(Ordering::Acquire)); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn failed_macos_live_rollback_invalidates_runtime_fingerprint() { + let tempdir = tempfile::tempdir().expect("temporary config directory should build"); + let blocked_parent = tempdir.path().join("blocked-parent"); + std::fs::write(&blocked_parent, "not a directory") + .expect("persistence blocker should be created"); + let manager = Arc::new( + ConfigManager::new(blocked_parent.join("hypercolor.toml")) + .expect("test config manager should initialize"), + ); + manager.modify(|config| config.capture.enabled = true); + let expected = Arc::clone(&manager.get()); + manager.mark_capture_runtime_applied(&expected.capture); + let reconfigurations = Arc::new(StdMutex::new(Vec::new())); + let source = Box::new(TestScreenSource::reject_rollback( + Arc::new(AtomicBool::new(false)), + Arc::clone(&reconfigurations), + )); + let mut state = AppState::new(); + state.config_manager = Some(Arc::clone(&manager)); + { + let mut input_manager = state.input_manager.lock().await; + input_manager.add_source(source); + input_manager + .start_all() + .expect("last-good input graph starts"); + } + let state = Arc::new(state); + let mut capture = expected.capture.clone(); + capture.capture_fps += 1; + let candidate = capture.clone(); + + let result = apply_capture_config_transaction(&state, &expected, capture).await; + + assert!(matches!( + result, + Err(CaptureConfigTransactionError::Prepare(_)) + )); + assert_eq!(manager.get().capture, expected.capture); + assert!(!manager.capture_runtime_matches(&expected.capture)); + assert!(!manager.capture_runtime_matches(&candidate)); + assert_eq!( + reconfigurations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + 1 + ); + } + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[tokio::test] async fn calibration_reset_endpoint_commits_one_valid_capture_config() { diff --git a/crates/hypercolor-daemon/src/api/mod.rs b/crates/hypercolor-daemon/src/api/mod.rs index 909ce54ef..99b676915 100644 --- a/crates/hypercolor-daemon/src/api/mod.rs +++ b/crates/hypercolor-daemon/src/api/mod.rs @@ -41,8 +41,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::atomic::AtomicBool; +#[cfg(any(target_os = "macos", test))] +use std::sync::atomic::AtomicU64; #[cfg(test)] -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::Ordering; use std::time::Instant; use arc_swap::{ArcSwap, ArcSwapOption}; @@ -113,6 +115,9 @@ use crate::zone_layout_preview::ZoneLayoutPreviewStore; #[cfg(test)] static APP_STATE_TEST_DATA_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); +#[cfg(target_os = "macos")] +type CapturePickerPersistenceTask = Arc)>>>; + /// Shared application state injected into every API handler. /// /// All fields are wrapped in `Arc` or interior-mutable containers so @@ -198,6 +203,14 @@ pub struct AppState { /// Exact lock-free screen capacity policy and physical usage. pub screen_capacity_status: ScreenCapacityStatusHandle, + /// Monotonic request order for macOS picker-persistence observers. + #[cfg(target_os = "macos")] + pub(crate) capture_picker_request_epoch: Arc, + + /// Latest macOS picker-persistence observer, fenced by request order. + #[cfg(target_os = "macos")] + pub(crate) capture_picker_persistence_task: CapturePickerPersistenceTask, + /// Aggregate typed input demand shared with render and connection consumers. pub input_publication_demands: InputPublicationDemandHandle, @@ -600,6 +613,10 @@ impl AppState { api_extensions: Vec::new(), input_manager, screen_capacity_status, + #[cfg(target_os = "macos")] + capture_picker_request_epoch: Arc::new(AtomicU64::new(0)), + #[cfg(target_os = "macos")] + capture_picker_persistence_task: Arc::new(StdMutex::new(None)), input_publication_demands: InputPublicationDemandHandle::new(), #[cfg(all(target_os = "macos", feature = "wgpu", feature = "screen-capture"))] macos_screen_parity_diagnostics: None, @@ -687,6 +704,10 @@ impl AppState { api_extensions: daemon.api_extensions.clone(), input_manager: Arc::clone(&daemon.input_manager), screen_capacity_status: daemon.screen_capacity_status.clone(), + #[cfg(target_os = "macos")] + capture_picker_request_epoch: Arc::new(AtomicU64::new(0)), + #[cfg(target_os = "macos")] + capture_picker_persistence_task: Arc::new(StdMutex::new(None)), input_publication_demands: daemon .input_publication_demands() .expect("live API state requires a running input publication pump"), @@ -1548,7 +1569,7 @@ pub fn build_router(state: Arc, ui_dir: Option<&Path>) -> Router { ) // ── System ─────────────────────────────────────────────────── .route("/server", axum::routing::get(system::get_server)) - .route("/status", axum::routing::get(system::get_status)) + .route("/status", axum::routing::get(system::get_status_route)) .route("/system/sensors", axum::routing::get(system::get_sensors)) .route( "/system/sensors/{label}", diff --git a/crates/hypercolor-daemon/src/api/openapi.rs b/crates/hypercolor-daemon/src/api/openapi.rs index ecaaabf4a..579fa0b2b 100644 --- a/crates/hypercolor-daemon/src/api/openapi.rs +++ b/crates/hypercolor-daemon/src/api/openapi.rs @@ -10,8 +10,8 @@ use utoipa::{Modify, OpenApi}; use utoipa_swagger_ui::SwaggerUi; use crate::api::{ - config, controls, devices, drivers, effects, envelope, layers, output, profiles, scenes_zones, - settings, system, + capture, config, controls, devices, drivers, effects, envelope, layers, output, profiles, + scenes_zones, settings, system, }; #[derive(OpenApi)] @@ -20,6 +20,10 @@ use crate::api::{ system::health_check, system::get_server, system::get_status, + capture::authorize_input_monitoring, + capture::authorize_screen_recording, + capture::pick_capture_source, + capture::list_capture_monitors, drivers::list_drivers, drivers::get_driver_config, devices::list_devices, @@ -43,6 +47,9 @@ use crate::api::{ envelope::ApiErrorResponse, envelope::ApiResponse, envelope::ApiResponse, + envelope::ApiResponse, + envelope::ApiResponse, + envelope::ApiResponse>, envelope::ApiResponse, envelope::ApiResponse, envelope::ApiResponse, @@ -103,6 +110,10 @@ use crate::api::{ system::ServerInfo, system::HealthChecks, system::HealthResponse, + hypercolor_types::api::capture::ProtectedSourceGrantOwner, + hypercolor_types::api::capture::CaptureAuthorizationResponse, + hypercolor_types::api::capture::CapturePickerResponse, + hypercolor_types::api::capture::CaptureMonitor, drivers::DriverListResponse, drivers::DriverSummary, drivers::DriverConfigResponse, @@ -206,6 +217,7 @@ use crate::api::{ (name = "devices", description = "Tracked device inventory"), (name = "controls", description = "Generic control surfaces and typed value mutation"), (name = "effects", description = "Effect catalog and runtime control"), + (name = "assets", description = "Uploaded media assets"), (name = "displays", description = "Display devices, faces, and simulators"), (name = "attachments", description = "Physical attachment templates and bindings"), (name = "output", description = "Global output power state"), @@ -214,6 +226,7 @@ use crate::api::{ (name = "layouts", description = "Spatial layout CRUD and preview"), (name = "library", description = "Favorites, presets, and playlists"), (name = "settings", description = "Runtime settings and audio inputs"), + (name = "capture", description = "Protected host input and screen-capture actions"), (name = "config", description = "Daemon configuration inspection and mutation"), (name = "diagnostics", description = "Daemon diagnostics"), (name = "websocket", description = "Realtime WebSocket endpoint"), @@ -313,6 +326,48 @@ impl RouteSpec { } pub const ROUTES: &[RouteSpec] = &[ + RouteSpec::get( + "/api/v1/assets", + "list_assets", + "assets", + "List media assets", + ), + RouteSpec::post( + "/api/v1/assets", + "upload_asset", + "assets", + "Upload a media asset", + ), + RouteSpec::get( + "/api/v1/assets/{id}", + "get_asset", + "assets", + "Get one media asset", + ), + RouteSpec::put( + "/api/v1/assets/{id}", + "update_asset", + "assets", + "Update one media asset", + ), + RouteSpec::delete( + "/api/v1/assets/{id}", + "delete_asset", + "assets", + "Delete one media asset", + ), + RouteSpec::get( + "/api/v1/assets/{id}/blob", + "get_asset_blob", + "assets", + "Download media asset bytes", + ), + RouteSpec::get( + "/api/v1/assets/{id}/thumbnail", + "get_asset_thumbnail", + "assets", + "Get a media asset thumbnail", + ), RouteSpec::get( "/health", "health_check", @@ -331,6 +386,30 @@ pub const ROUTES: &[RouteSpec] = &[ "system", "Get daemon status", ), + RouteSpec::post( + "/api/v1/input/authorize", + "authorize_input_monitoring", + "capture", + "Request Input Monitoring authorization", + ), + RouteSpec::post( + "/api/v1/capture/authorize", + "authorize_screen_recording", + "capture", + "Request screen-capture authorization", + ), + RouteSpec::post( + "/api/v1/capture/source/pick", + "pick_capture_source", + "capture", + "Open the screen-capture source picker", + ), + RouteSpec::get( + "/api/v1/capture/monitors", + "list_capture_monitors", + "capture", + "List addressable capture displays", + ), RouteSpec::get( "/api/v1/drivers", "list_drivers", @@ -716,6 +795,12 @@ pub const ROUTES: &[RouteSpec] = &[ "effects", "Install effect", ), + RouteSpec::get( + "/api/v1/effects/screenshots", + "get_effect_screenshot", + "effects", + "Serve bundled effect screenshots", + ), RouteSpec::get( "/api/v1/effects/{id}", "get_effect", @@ -1155,6 +1240,12 @@ pub const ROUTES: &[RouteSpec] = &[ "diagnostics", "Run daemon diagnostics", ), + RouteSpec::post( + "/api/v1/diagnose/memory", + "memory_diagnostics", + "diagnostics", + "Run memory diagnostics", + ), RouteSpec::get( "/api/v1/ws", "ws_handler", @@ -1183,6 +1274,7 @@ impl Modify for SecurityAddon { impl Modify for RouteCatalogAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { for tag in [ + "assets", "displays", "controls", "attachments", @@ -1192,6 +1284,7 @@ impl Modify for RouteCatalogAddon { "layouts", "library", "settings", + "capture", "config", "diagnostics", "websocket", diff --git a/crates/hypercolor-daemon/src/api/security.rs b/crates/hypercolor-daemon/src/api/security.rs index 65cb1cbd2..f9e462850 100644 --- a/crates/hypercolor-daemon/src/api/security.rs +++ b/crates/hypercolor-daemon/src/api/security.rs @@ -54,6 +54,18 @@ pub(crate) struct RequestAuthContext { #[derive(Debug, Clone, Copy)] struct TrustedLocalControl; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RequestLocality { + loopback: bool, +} + +impl RequestLocality { + #[must_use] + pub(crate) const fn is_loopback(self) -> bool { + self.loopback + } +} + impl RequestAuthContext { #[must_use] pub(crate) const fn unsecured() -> Self { @@ -520,6 +532,10 @@ pub async fn enforce_security( next: Next, ) -> Response { let mut request = request; + let loopback = request_is_loopback(&request); + request + .extensions_mut() + .insert(RequestLocality { loopback }); if request .extensions_mut() @@ -795,11 +811,8 @@ fn request_is_loopback(request: &Request) -> bool { fn client_ip(request: &Request) -> Option { if let Some(socket_addr) = peer_socket_addr(request) { - if socket_addr.ip().is_loopback() - && let Some(forwarded_client) = forwarded_client_ip(request) - && let Ok(forwarded_ip) = forwarded_client.parse::() - { - return Some(forwarded_ip); + if socket_addr.ip().is_loopback() && forwarded_client_header_present(request) { + return forwarded_client_ip(request)?.parse::().ok(); } return Some(socket_addr.ip()); } @@ -815,28 +828,26 @@ fn peer_socket_addr(request: &Request) -> Option { } fn forwarded_client_ip(request: &Request) -> Option { - if let Some(forwarded) = request.headers().get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first) = value.split(',').next() - { + if let Some(forwarded) = request.headers().get("x-forwarded-for") { + let value = forwarded.to_str().ok()?; + let first = value.split(',').next()?; let trimmed = first.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_owned()); - } + return (!trimmed.is_empty()).then(|| trimmed.to_owned()); } - if let Some(real_ip) = request.headers().get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { + if let Some(real_ip) = request.headers().get("x-real-ip") { + let value = real_ip.to_str().ok()?; let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_owned()); - } + return (!trimmed.is_empty()).then(|| trimmed.to_owned()); } None } +fn forwarded_client_header_present(request: &Request) -> bool { + request.headers().contains_key("x-forwarded-for") || request.headers().contains_key("x-real-ip") +} + fn apply_rate_headers(response: &mut Response, decision: &RateDecision) { let headers = response.headers_mut(); insert_header(headers, HEADER_RATE_LIMIT_LIMIT, u64::from(decision.limit)); diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index 6eaa25d44..e115a3ecc 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::response::{IntoResponse, Response}; use hypercolor_core::engine::RenderLoopState; use hypercolor_core::input::screen::{ @@ -26,6 +26,7 @@ use utoipa::ToSchema; use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; +use crate::api::security::RequestLocality; use crate::api::settings; use crate::macos_owner::{MacosDaemonOwner, MacosHandoverPhase, MacosOwnerSnapshot}; use crate::performance::LatestFrameMetrics; @@ -727,9 +728,16 @@ pub struct ServerInfo { pub auth_required: bool, } -/// Build the canonical lock-free input health snapshot used by every status surface. +/// Build the redacted input health snapshot used by non-local status surfaces. #[must_use] pub(crate) fn input_status_snapshot(state: &AppState) -> InputStatus { + input_status_snapshot_with_privacy(state, false) +} + +fn input_status_snapshot_with_privacy( + state: &AppState, + include_private_selection_ids: bool, +) -> InputStatus { let now = Instant::now(); let registry = state.input_status.snapshot(); let statuses = registry @@ -742,7 +750,7 @@ pub(crate) fn input_status_snapshot(state: &AppState) -> InputStatus { .filter(|source| is_host_interaction_source(source)); let sources = statuses .iter() - .map(|source| input_source_status(source, now)) + .map(|source| input_source_status(source, now, include_private_selection_ids)) .collect(); InputStatus { @@ -822,7 +830,11 @@ pub(crate) fn actionable_input_diagnostics(input: &InputStatus) -> Vec InputSourceStatus { +fn input_source_status( + source: &SourceStatus, + now: Instant, + include_private_selection_ids: bool, +) -> InputSourceStatus { let lifecycle_issue = source.issue.as_ref().map(input_source_issue_status); let freshness_issue = source .freshness_issue @@ -853,10 +865,9 @@ fn input_source_status(source: &SourceStatus, now: Instant) -> InputSourceStatus issue, lifecycle_issue, freshness_issue, - platform: source - .platform - .as_deref() - .and_then(|platform| input_source_platform_status(platform, now)), + platform: source.platform.as_deref().and_then(|platform| { + input_source_platform_status(platform, now, include_private_selection_ids) + }), retired: source.retired, } } @@ -864,12 +875,15 @@ fn input_source_status(source: &SourceStatus, now: Instant) -> InputSourceStatus fn input_source_platform_status( platform: &SourcePlatformStatus, now: Instant, + include_private_selection_ids: bool, ) -> Option { match platform { SourcePlatformStatus::MacosInput(status) => Some(macos_input_platform_status(status, now)), - SourcePlatformStatus::MacosScreen(status) => { - Some(macos_screen_platform_status(status, now)) - } + SourcePlatformStatus::MacosScreen(status) => Some(macos_screen_platform_status( + status, + now, + include_private_selection_ids, + )), _ => None, } } @@ -917,6 +931,7 @@ fn macos_input_platform_status( fn macos_screen_platform_status( status: &MacosScreenPlatformStatus, now: Instant, + include_private_selection_ids: bool, ) -> InputSourcePlatformStatus { InputSourcePlatformStatus::MacosScreen { state: macos_protected_source_state(status.state), @@ -924,10 +939,9 @@ fn macos_screen_platform_status( owner: macos_capability_owner(status.owner), selection: macos_selection_state(&status.selection), tahoe: macos_tahoe_capabilities(&status.tahoe), - tahoe_selection: status - .tahoe_selection - .as_ref() - .map(macos_tahoe_selection_capabilities), + tahoe_selection: status.tahoe_selection.as_ref().map(|capabilities| { + macos_tahoe_selection_capabilities(capabilities, include_private_selection_ids) + }), owner_conflict: status .owner_conflict .as_deref() @@ -1138,9 +1152,16 @@ fn macos_selection_state(selection: &MacosSelectionState) -> MacosSelectionState fn macos_tahoe_selection_capabilities( capabilities: &MacosTahoeSelectionCapabilities, + include_private_selection_ids: bool, ) -> MacosTahoeSelectionCapabilitiesApiStatus { MacosTahoeSelectionCapabilitiesApiStatus { - source_id: capabilities.source_id.to_string(), + source_id: if include_private_selection_ids + || !capabilities.source_id.starts_with("macos:session:") + { + capabilities.source_id.to_string() + } else { + "session_scoped".to_owned() + }, capture_session_generation: capabilities.capture_session_generation, hdr_capture: capabilities.hdr_capture, dual_range_screenshots: capabilities.dual_range_screenshots, @@ -1241,6 +1262,20 @@ fn duration_ms(duration: Duration) -> u64 { tag = "system" )] pub async fn get_status(State(state): State>) -> Response { + get_status_with_privacy(state, true).await +} + +pub(crate) async fn get_status_route( + State(state): State>, + Extension(locality): Extension, +) -> Response { + get_status_with_privacy(state, locality.is_loopback()).await +} + +async fn get_status_with_privacy( + state: Arc, + include_private_selection_ids: bool, +) -> Response { let device_count = state.device_registry.len().await; let effect_count = state.effect_registry.read().await.len(); let scene_count = state.scene_manager.read().await.scene_count(); @@ -1400,7 +1435,7 @@ pub async fn get_status(State(state): State>) -> Response { }; let preview_runtime = preview_runtime_status(&state.preview_runtime); - let input_status = input_status_snapshot(&state); + let input_status = input_status_snapshot_with_privacy(&state, include_private_selection_ids); let screen_capture_capacity = { let capacity_snapshot = state.screen_capacity_status.snapshot(); let policy = capacity_snapshot.policy(); @@ -2142,8 +2177,9 @@ fn round_2(value: f64) -> f64 { #[cfg(test)] mod tests { use super::{ - get_sensor, get_sensors, get_status, input_source_status, macos_daemon_ownership, - macos_selection_state, us_to_ms_f64, + get_sensor, get_sensors, get_status, input_source_status, input_status_snapshot, + macos_daemon_ownership, macos_selection_state, macos_tahoe_selection_capabilities, + us_to_ms_f64, }; use crate::api::AppState; use crate::macos_owner::{ @@ -2160,10 +2196,11 @@ mod tests { use hypercolor_core::bus::CanvasFrame; use hypercolor_core::input::screen::ScreenAdmissionCapacity; use hypercolor_core::input::{ - MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, - MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, - MacosSelectionState, MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, - SourceFreshness, SourceKind, SourcePlatformStatus, SourceState, SourceStatus, + InputData, InputSource, MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosInputPlatformStatus, MacosProtectedSourceState, + MacosScreenPlatformStatus, MacosSelectionState, MacosTahoeCapabilities, + MacosTahoeSelectionCapabilities, SourceFreshness, SourceKind, SourcePlatformStatus, + SourceState, SourceStatus, SourceStatusHandle, SourceStatusReporter, }; use hypercolor_types::canvas::Canvas; use hypercolor_types::sensor::{SensorReading, SensorUnit, SystemSnapshot}; @@ -2173,6 +2210,59 @@ mod tests { use std::time::Instant; use tokio::sync::watch; + struct TestStatusSource { + status: SourceStatusReporter, + } + + impl TestStatusSource { + fn new(platform: SourcePlatformStatus) -> Self { + let mut status = SourceStatusReporter::new( + "test-screen", + SourceKind::Screen, + "test", + true, + true, + false, + ); + status + .set_platform(Some(platform)) + .expect("test platform status should publish"); + Self { status } + } + } + + impl InputSource for TestStatusSource { + fn name(&self) -> &str { + "test-screen" + } + + fn source_status_handle(&self) -> Option { + Some(self.status.handle()) + } + + fn source_status_reporter(&mut self) -> Option<&mut SourceStatusReporter> { + Some(&mut self.status) + } + + fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + fn stop(&mut self) {} + + fn sample(&mut self) -> anyhow::Result { + Ok(InputData::None) + } + + fn is_running(&self) -> bool { + false + } + + fn is_screen_source(&self) -> bool { + true + } + } + fn source_status_fixture(platform: Option) -> SourceStatus { SourceStatus { source_id: Arc::from("fixture:source"), @@ -2227,7 +2317,8 @@ mod tests { tap_reenabled: Some(3), state_gaps: Some(4), }); - let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); + let status = + input_source_status(&source_status_fixture(Some(platform)), Instant::now(), true); let value = serde_json::to_value(status).expect("input status should serialize"); assert_eq!( @@ -2302,8 +2393,8 @@ mod tests { ); } - #[test] - fn input_source_status_serializes_macos_screen_platform() { + #[tokio::test] + async fn input_source_status_serializes_macos_screen_platform() { let platform = SourcePlatformStatus::MacosScreen(MacosScreenPlatformStatus { state: MacosProtectedSourceState::Interrupted, tcc: MacosAuthorizationState::Denied, @@ -2312,6 +2403,7 @@ mod tests { content_style: Arc::from("multiple_windows"), }, selection_diagnostic_label: Some(Arc::from("multiple_windows")), + selection_revision: 17, tahoe: MacosTahoeCapabilities { host_architecture: MacosArchitecture::AppleSilicon, translated_process: true, @@ -2319,7 +2411,7 @@ mod tests { metal4: false, }, tahoe_selection: Some(MacosTahoeSelectionCapabilities { - source_id: Arc::from("session:23"), + source_id: Arc::from("macos:session:multiple-windows:w42:a18:com.secret.private"), capture_session_generation: 29, hdr_capture: true, dual_range_screenshots: true, @@ -2370,7 +2462,14 @@ mod tests { publication_total_ns: 500, publication_max_ns: 50, }); - let status = input_source_status(&source_status_fixture(Some(platform)), Instant::now()); + let state = AppState::new(); + state + .input_manager + .lock() + .await + .add_source(Box::new(TestStatusSource::new(platform.clone()))); + let source = source_status_fixture(Some(platform)); + let status = input_source_status(&source, Instant::now(), true); let value = serde_json::to_value(status).expect("screen status should serialize"); assert_eq!(value["active_consumer_count"], 2); @@ -2388,6 +2487,10 @@ mod tests { platform["tahoe_selection"]["capture_session_generation"], 29 ); + assert_eq!( + platform["tahoe_selection"]["source_id"], + "macos:session:multiple-windows:w42:a18:com.secret.private" + ); assert_eq!(platform["owner_conflict"]["contender"], "app"); let telemetry = &platform["telemetry"]; assert_eq!(telemetry["executable_architecture"], "intel"); @@ -2425,11 +2528,26 @@ mod tests { assert_eq!(telemetry["native_import_total_ns"], 600); assert_eq!(telemetry["native_reduction_submit_total_ns"], 800); assert_eq!(telemetry["publication_total_ns"], 500); + + let remote = input_source_status(&source, Instant::now(), false); + let remote = serde_json::to_value(remote).expect("remote screen status should serialize"); + assert_eq!( + remote["platform"]["tahoe_selection"]["source_id"], + "session_scoped" + ); + assert!(!remote.to_string().contains("com.secret.private")); + assert!(!remote.to_string().contains("w42")); + + let public = serde_json::to_value(input_status_snapshot(&state)) + .expect("public input status should serialize"); + assert!(!public.to_string().contains("com.secret.private")); + assert!(!public.to_string().contains("w42")); + assert!(public.to_string().contains("session_scoped")); } #[test] fn input_source_status_omits_absent_platform() { - let status = input_source_status(&source_status_fixture(None), Instant::now()); + let status = input_source_status(&source_status_fixture(None), Instant::now(), true); let value = serde_json::to_value(status).expect("source status should serialize"); assert!(value.get("platform").is_none()); @@ -2449,6 +2567,17 @@ mod tests { display, json!({ "type": "display", "source_id": "display:7a3f" }) ); + + let display_capabilities = macos_tahoe_selection_capabilities( + &MacosTahoeSelectionCapabilities { + source_id: Arc::from("display:7a3f"), + capture_session_generation: 1, + hdr_capture: false, + dual_range_screenshots: false, + }, + false, + ); + assert_eq!(display_capabilities.source_id, "display:7a3f"); } #[test] @@ -2566,6 +2695,7 @@ mod tests { performance.record_effect_fallback_applied(); let frame = LatestFrameMetrics { timestamp_ms: 40, + input_sampled: true, input_us: 100, deferred_sample_us: 40, producer_us: 500, diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index 275fb7a38..95efc2590 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -977,6 +977,11 @@ struct CaptureConfigPersistenceState { } enum CaptureConfigPersistenceUpdate { + #[cfg(target_os = "macos")] + MacosSource { + configured: String, + resolved: String, + }, #[cfg(target_os = "windows")] WindowsSource(ResolvedCaptureSource), #[cfg(target_os = "linux")] @@ -1022,6 +1027,25 @@ impl CaptureConfigPersistenceGate { state.source_status = Some(status); } + #[cfg(target_os = "macos")] + pub(crate) fn for_macos_picker( + config_manager: Arc, + expected: &Arc, + status: SourceStatusHandle, + ) -> Result { + let persistence = Self::new(config_manager, expected, true)?; + persistence.bind_source(status); + Ok(persistence) + } + + #[cfg(target_os = "macos")] + pub(crate) fn publish_macos_selection(&self, configured: String, resolved: String) { + self.publish(CaptureConfigPersistenceUpdate::MacosSource { + configured, + resolved, + }); + } + pub(crate) fn epoch(&self) -> CapturePersistenceEpoch { self.inner .state @@ -1039,7 +1063,7 @@ impl CaptureConfigPersistenceGate { source_identity(&state) } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn publish(&self, update: CaptureConfigPersistenceUpdate) { let persistence = { let mut state = self @@ -1130,7 +1154,7 @@ impl CaptureConfigPersistenceGate { self.inner.config_manager.revoke_capture_persistence(epoch); } - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn persist( &self, epoch: CapturePersistenceEpoch, @@ -1143,6 +1167,10 @@ impl CaptureConfigPersistenceGate { let config_manager = &self.inner.config_manager; let snapshot = Arc::clone(&config_manager.get()); let should_persist = match &update { + #[cfg(target_os = "macos")] + CaptureConfigPersistenceUpdate::MacosSource { configured, .. } => { + snapshot.capture.source == *configured + } #[cfg(target_os = "windows")] CaptureConfigPersistenceUpdate::WindowsSource(resolved) => { snapshot.capture.source == resolved.configured_source @@ -1165,6 +1193,10 @@ impl CaptureConfigPersistenceGate { } let mutate = |capture: &mut hypercolor_types::config::CaptureConfig| match update { + #[cfg(target_os = "macos")] + CaptureConfigPersistenceUpdate::MacosSource { resolved, .. } => { + capture.source = resolved; + } #[cfg(target_os = "windows")] CaptureConfigPersistenceUpdate::WindowsSource(resolved) => { capture.source = resolved.stable_source; @@ -1193,7 +1225,7 @@ impl CaptureConfigPersistenceGate { } } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn persist( &self, _epoch: CapturePersistenceEpoch, @@ -1219,14 +1251,16 @@ fn source_identity(state: &CaptureConfigPersistenceState) -> Option bool { match update { + #[cfg(target_os = "macos")] + CaptureConfigPersistenceUpdate::MacosSource { .. } => false, #[cfg(target_os = "windows")] CaptureConfigPersistenceUpdate::WindowsSource(_) => true, #[cfg(target_os = "linux")] @@ -1234,7 +1268,7 @@ fn requires_source_identity(update: &CaptureConfigPersistenceUpdate) -> bool { } } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] const fn requires_source_identity(_update: &CaptureConfigPersistenceUpdate) -> bool { false } @@ -1360,10 +1394,20 @@ pub(crate) fn screen_capture_config_from( capture .validate() .context("invalid screen capture configuration")?; - hypercolor_core::input::screen::CaptureCadence::new(capture.capture_fps) - .context("screen capture cadence is not representable by the runtime scheduler")?; + let acquisition_cadence = match capture.cadence { + hypercolor_types::config::CaptureCadenceMode::Fixed => { + hypercolor_core::input::screen::ScreenCaptureCadence::frames_per_second( + capture.capture_fps, + ) + .context("screen capture cadence is not representable by the runtime scheduler")? + } + hypercolor_types::config::CaptureCadenceMode::NativeRefresh => { + hypercolor_core::input::screen::ScreenCaptureCadence::NativeRefresh + } + }; Ok(ScreenCaptureConfig { target_fps: capture.capture_fps, + acquisition_cadence, grid_cols: capture.grid_cols, grid_rows: capture.grid_rows, analysis_memory_bytes: u64::MAX, diff --git a/crates/hypercolor-daemon/src/startup/services/tests.rs b/crates/hypercolor-daemon/src/startup/services/tests.rs index f68aac6a2..d5c9d4e7a 100644 --- a/crates/hypercolor-daemon/src/startup/services/tests.rs +++ b/crates/hypercolor-daemon/src/startup/services/tests.rs @@ -1,15 +1,15 @@ -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use std::sync::Arc; -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use hypercolor_core::config::ConfigManager; #[cfg(target_os = "windows")] use hypercolor_core::input::screen::ResolvedCaptureSource; use hypercolor_core::input::screen::{PixelExtent, ScreenAdmissionCapacity, ScreenCaptureDemand}; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "macos", target_os = "windows"))] use hypercolor_core::input::{SourceKind, SourceStatusHandle, SourceStatusReporter}; -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use super::CaptureConfigPersistenceGate; #[cfg(target_os = "linux")] use super::CaptureConfigPersistenceUpdate; @@ -100,7 +100,7 @@ fn persistence_gate( (persistence, expected) } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "macos", target_os = "windows"))] fn live_screen_status() -> SourceStatusHandle { let mut reporter = SourceStatusReporter::new("test-screen", SourceKind::Screen, "test", true, true, true); @@ -113,6 +113,100 @@ fn live_screen_status() -> SourceStatusHandle { reporter.handle() } +#[cfg(target_os = "macos")] +fn macos_picker_gate( + manager: &Arc, +) -> ( + CaptureConfigPersistenceGate, + Arc, +) { + let expected = Arc::clone(&manager.get()); + let persistence = CaptureConfigPersistenceGate::for_macos_picker( + Arc::clone(manager), + &expected, + live_screen_status(), + ) + .expect("picker persistence authority is reserved"); + (persistence, expected) +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_display_picker_selection_persists_stable_uuid() { + let directory = tempfile::tempdir().expect("test config directory is created"); + let path = directory.path().join("hypercolor.toml"); + let manager = Arc::new(ConfigManager::new(path.clone()).expect("config manager opens")); + let (persistence, expected) = macos_picker_gate(&manager); + + persistence.publish_macos_selection( + expected.capture.source.clone(), + "display:7a3f4954-3d72-47a6-a914-16ef68d02122".to_owned(), + ); + + assert_eq!( + manager.get().capture.source, + "display:7a3f4954-3d72-47a6-a914-16ef68d02122" + ); + drop(manager); + let restarted = ConfigManager::new(path).expect("config manager reopens"); + assert_eq!( + restarted.get().capture.source, + "display:7a3f4954-3d72-47a6-a914-16ef68d02122" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_window_picker_selection_persists_only_session_scope() { + let directory = tempfile::tempdir().expect("test config directory is created"); + let path = directory.path().join("hypercolor.toml"); + let manager = Arc::new(ConfigManager::new(path.clone()).expect("config manager opens")); + let (persistence, expected) = macos_picker_gate(&manager); + + persistence + .publish_macos_selection(expected.capture.source.clone(), "session_scoped".to_owned()); + + assert_eq!(manager.get().capture.source, "session_scoped"); + drop(persistence); + drop(manager); + let restarted = ConfigManager::new(path).expect("config manager reopens"); + assert_eq!(restarted.get().capture.source, "session_scoped"); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_picker_update_cannot_overwrite_newer_config() { + let directory = tempfile::tempdir().expect("test config directory is created"); + let manager = Arc::new( + ConfigManager::new(directory.path().join("hypercolor.toml")).expect("config manager opens"), + ); + let (persistence, expected) = macos_picker_gate(&manager); + manager.modify(|config| config.capture.source = "primary_display".to_owned()); + + persistence.publish_macos_selection( + expected.capture.source.clone(), + "display:7a3f4954-3d72-47a6-a914-16ef68d02122".to_owned(), + ); + + assert_eq!(manager.get().capture.source, "primary_display"); +} + +#[cfg(target_os = "macos")] +#[test] +fn revoked_macos_picker_gate_preserves_current_selection() { + let directory = tempfile::tempdir().expect("test config directory is created"); + let manager = Arc::new( + ConfigManager::new(directory.path().join("hypercolor.toml")).expect("config manager opens"), + ); + let (persistence, expected) = macos_picker_gate(&manager); + persistence.revoke(); + + persistence + .publish_macos_selection(expected.capture.source.clone(), "session_scoped".to_owned()); + + assert_eq!(manager.get().capture.source, expected.capture.source); +} + #[cfg(target_os = "windows")] #[test] fn resolved_windows_capture_source_survives_daemon_restart() { diff --git a/crates/hypercolor-daemon/tests/openapi_tests.rs b/crates/hypercolor-daemon/tests/openapi_tests.rs index a935fbd8f..927873e93 100644 --- a/crates/hypercolor-daemon/tests/openapi_tests.rs +++ b/crates/hypercolor-daemon/tests/openapi_tests.rs @@ -132,6 +132,27 @@ async fn openapi_json_is_served_with_expected_paths() { assert!(source_status["properties"]["freshness_remaining_ms"].is_object()); assert!(source_status["properties"]["denied_resource_count"].is_object()); assert!(body["components"]["schemas"]["InputSourceIssueStatus"].is_object()); + for (path, method) in [ + ("/api/v1/input/authorize", "post"), + ("/api/v1/capture/authorize", "post"), + ("/api/v1/capture/source/pick", "post"), + ("/api/v1/capture/monitors", "get"), + ] { + assert!( + body["paths"][path][method].is_object(), + "missing capture operation {} {path}", + method.to_uppercase() + ); + assert_eq!( + body["paths"][path][method]["responses"]["403"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/ApiErrorResponse" + ); + } + assert!(body["components"]["schemas"]["CaptureAuthorizationResponse"].is_object()); + assert!(body["components"]["schemas"]["CapturePickerResponse"].is_object()); + assert!(body["components"]["schemas"]["CaptureMonitor"].is_object()); + assert!(body["components"]["schemas"]["ProtectedSourceGrantOwner"].is_object()); for route in ROUTES { let operation = &body["paths"][route.path][route.method]; @@ -151,6 +172,102 @@ async fn openapi_json_is_served_with_expected_paths() { } } +fn balanced_call(input: &str) -> &str { + let mut depth = 0_usize; + let mut in_string = false; + let mut escaped = false; + let mut saw_open = false; + + for (index, character) in input.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + match character { + '"' => in_string = true, + '(' => { + saw_open = true; + depth += 1; + } + ')' => { + depth -= 1; + if saw_open && depth == 0 { + return &input[..=index]; + } + } + _ => {} + } + } + + panic!("unterminated router call: {input}"); +} + +fn quoted_path(call: &str) -> &str { + let start = call.find('"').expect("router call should contain a path") + 1; + let end = call[start..] + .find('"') + .expect("router path should have a closing quote"); + &call[start..start + end] +} + +fn router_operations() -> BTreeSet<(String, String)> { + let source = include_str!("../src/api/mod.rs"); + let mut router = source + .split_once("let api = Router::new()") + .expect("router construction should be present") + .1 + .split_once("let mut api = api;") + .expect("router construction should have a stable boundary") + .0; + let mut operations = BTreeSet::new(); + + while let Some(index) = router.find(".route(") { + let call = balanced_call(&router[index..]); + let path = format!("/api/v1{}", quoted_path(call)); + for method in ["get", "post", "put", "patch", "delete"] { + if call.contains(&format!("axum::routing::{method}(")) + || call.contains(&format!(".{method}(")) + { + operations.insert((method.to_owned(), path.clone())); + } + } + router = &router[index + call.len()..]; + } + + let screenshot_index = source + .find(".nest_service(") + .expect("effect screenshot service should be mounted"); + let screenshot_service = balanced_call(&source[screenshot_index..]); + operations.insert(( + "get".to_owned(), + format!("/api/v1{}", quoted_path(screenshot_service)), + )); + operations +} + +#[test] +fn every_static_router_operation_is_cataloged() { + let catalog = ROUTES + .iter() + .map(|route| (route.method.to_owned(), route.path.to_owned())) + .collect::>(); + let missing = router_operations() + .difference(&catalog) + .cloned() + .collect::>(); + + assert!( + missing.is_empty(), + "router operations missing from OpenAPI catalog: {missing:?}" + ); +} + #[test] fn route_catalog_operation_ids_are_unique() { let mut operation_ids = BTreeSet::new(); diff --git a/crates/hypercolor-daemon/tests/security_api_tests.rs b/crates/hypercolor-daemon/tests/security_api_tests.rs index 0ded9c568..814986585 100644 --- a/crates/hypercolor-daemon/tests/security_api_tests.rs +++ b/crates/hypercolor-daemon/tests/security_api_tests.rs @@ -1,9 +1,11 @@ //! Integration tests for daemon security middleware and CORS defaults. use std::sync::{Arc, LazyLock, Mutex}; +use std::{net::Ipv4Addr, net::SocketAddr}; use axum::body::Body; -use http::{Request, StatusCode, header}; +use axum::extract::ConnectInfo; +use http::{Method, Request, StatusCode, header}; use hypercolor_core::config::ConfigManager; use hypercolor_daemon::api::{self, AppState}; use hypercolor_types::config::HypercolorConfig; @@ -40,6 +42,18 @@ fn test_app_with_config(config: HypercolorConfig) -> axum::Router { api::build_router(Arc::new(state), None) } +fn request_from(ip: Ipv4Addr, method: Method, path: &str) -> Request { + let mut request = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from((ip, 9420)))); + request +} + #[tokio::test] async fn loopback_origin_receives_cors_headers() { let response = test_app() @@ -107,3 +121,89 @@ async fn configured_public_origin_is_ignored_without_api_auth() { .is_none() ); } + +#[tokio::test] +async fn protected_capture_routes_reject_remote_clients_before_dispatch() { + let app = test_app(); + for (method, path) in [ + (Method::POST, "/api/v1/input/authorize"), + (Method::POST, "/api/v1/capture/authorize"), + (Method::POST, "/api/v1/capture/source/pick"), + (Method::GET, "/api/v1/capture/monitors"), + ] { + let mut request = request_from(Ipv4Addr::new(203, 0, 113, 9), method.clone(), path); + request.headers_mut().insert( + "x-forwarded-for", + "127.0.0.1".parse().expect("header should parse"), + ); + let response = app + .clone() + .oneshot(request) + .await + .expect("protected request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + + let mut proxied = request_from(Ipv4Addr::LOCALHOST, method, path); + proxied.headers_mut().insert( + "x-forwarded-for", + "203.0.113.9".parse().expect("header should parse"), + ); + let response = app + .clone() + .oneshot(proxied) + .await + .expect("proxied protected request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN, "proxied {path}"); + } +} + +#[tokio::test] +async fn protected_capture_routes_reject_malformed_forwarded_clients() { + let app = test_app(); + for (method, path) in [ + (Method::POST, "/api/v1/input/authorize"), + (Method::POST, "/api/v1/capture/authorize"), + (Method::POST, "/api/v1/capture/source/pick"), + (Method::GET, "/api/v1/capture/monitors"), + ] { + let mut request = request_from(Ipv4Addr::LOCALHOST, method, path); + request.headers_mut().insert( + "x-forwarded-for", + "not-an-ip".parse().expect("header should parse"), + ); + let response = app + .clone() + .oneshot(request) + .await + .expect("malformed forwarded request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } +} + +#[tokio::test] +async fn protected_capture_routes_accept_trustworthy_loopback_clients() { + let app = test_app(); + let monitors = app + .clone() + .oneshot(request_from( + Ipv4Addr::LOCALHOST, + Method::GET, + "/api/v1/capture/monitors", + )) + .await + .expect("local monitor request should complete"); + let authorization = app + .oneshot(request_from( + Ipv4Addr::LOCALHOST, + Method::POST, + "/api/v1/input/authorize", + )) + .await + .expect("local authorization request should complete"); + + assert_eq!(monitors.status(), StatusCode::OK); + assert_ne!(authorization.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/hypercolor-types/src/api/capture.rs b/crates/hypercolor-types/src/api/capture.rs new file mode 100644 index 000000000..2219310f9 --- /dev/null +++ b/crates/hypercolor-types/src/api/capture.rs @@ -0,0 +1,39 @@ +//! Protected input and screen-capture REST contracts. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProtectedSourceGrantOwner { + AppSidecar, + App, + LaunchdService, + HomebrewService, + Broker, + Standalone, + PlatformBackend, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CaptureAuthorizationResponse { + pub authorized: bool, + pub grant_owner: ProtectedSourceGrantOwner, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CapturePickerResponse { + pub picking: bool, + pub grant_owner: ProtectedSourceGrantOwner, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CaptureMonitor { + pub index: usize, + pub id: String, + pub name: String, + pub width: u32, + pub height: u32, + pub primary: bool, + pub value: String, +} diff --git a/crates/hypercolor-types/src/api/mod.rs b/crates/hypercolor-types/src/api/mod.rs index a3d4f836f..8d96e1c75 100644 --- a/crates/hypercolor-types/src/api/mod.rs +++ b/crates/hypercolor-types/src/api/mod.rs @@ -18,6 +18,7 @@ //! does NOT — those shapes move fast with perf work, and clients consume //! tolerant subsets of them by design. +pub mod capture; pub mod common; pub mod devices; pub mod effects; diff --git a/crates/hypercolor-types/tests/api_capture_tests.rs b/crates/hypercolor-types/tests/api_capture_tests.rs new file mode 100644 index 000000000..d04824fd7 --- /dev/null +++ b/crates/hypercolor-types/tests/api_capture_tests.rs @@ -0,0 +1,42 @@ +use hypercolor_types::api::capture::{ + CaptureAuthorizationResponse, CaptureMonitor, CapturePickerResponse, ProtectedSourceGrantOwner, +}; + +#[test] +fn capture_action_contracts_serialize_stable_owner_names() { + let authorization = CaptureAuthorizationResponse { + authorized: true, + grant_owner: ProtectedSourceGrantOwner::AppSidecar, + }; + let picker = CapturePickerResponse { + picking: true, + grant_owner: ProtectedSourceGrantOwner::PlatformBackend, + }; + + assert_eq!( + serde_json::to_value(authorization).expect("authorization should serialize"), + serde_json::json!({"authorized": true, "grant_owner": "app_sidecar"}) + ); + assert_eq!( + serde_json::to_value(picker).expect("picker should serialize"), + serde_json::json!({"picking": true, "grant_owner": "platform_backend"}) + ); +} + +#[test] +fn capture_monitor_contract_round_trips() { + let monitor = CaptureMonitor { + index: 1, + id: "display:7a3f".to_owned(), + name: "Studio Display".to_owned(), + width: 5_120, + height: 2_880, + primary: true, + value: "display:7a3f".to_owned(), + }; + let value = serde_json::to_value(&monitor).expect("monitor should serialize"); + let decoded: CaptureMonitor = + serde_json::from_value(value).expect("monitor should deserialize"); + + assert_eq!(decoded, monitor); +} diff --git a/crates/hypercolor-ui/src/api/config.rs b/crates/hypercolor-ui/src/api/config.rs index 8324944fa..1975faa94 100644 --- a/crates/hypercolor-ui/src/api/config.rs +++ b/crates/hypercolor-ui/src/api/config.rs @@ -2,6 +2,8 @@ use serde::Deserialize; +pub use hypercolor_types::api::capture::CaptureMonitor; + use super::client; // ── Types ─────────────────────────────────────────────────────────────────── @@ -51,18 +53,6 @@ pub async fn reset_config_key(key: &str) -> Result<(), String> { .map_err(Into::into) } -/// One display output capture can address, from `/api/v1/capture/monitors`. -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -pub struct CaptureMonitor { - pub index: usize, - pub name: String, - pub width: u32, - pub height: u32, - pub primary: bool, - /// Ready-to-store `capture.source` value selecting this output. - pub value: String, -} - /// Display outputs the capture backend can address. Empty on portal /// platforms, which is the UI's cue to show the picker button instead. pub async fn fetch_capture_monitors() -> Result, String> { diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index 9188e3d39..02c641b8a 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -329,7 +329,7 @@ pub fn CaptureSection(
"Capture source"
- "Pick which screen or window to mirror; the choice persists across restarts" + "Pick a display, window, or app. Displays persist across restarts; windows and apps stay session scoped"
})} -
-
- "Capture health" -
- {move || match capture_status.get() { - None => view! { -
"Reading capture health…"
- }.into_any(), - Some(Err(error)) => view! { -
{format!("Capture health unavailable: {error}")}
- }.into_any(), - Some(Ok(status)) => screen_status_view(status.input).into_any(), - }} -
+ {move || { + let status = capture_status.get().and_then(Result::ok)?; + if !enabled.get() || macos_screen_needs_authorization(&status.input) { + return None; + } + screen_status_line(&status.input) + .map(|(tone, text)| input::status_line_view(tone, text)) + }}
@@ -572,7 +566,8 @@ pub(super) fn MacosCaptureOwnerRestartAction( owner, .. })) => { - let message = format!("{} restarted.", input::humanize(&owner)); + let _ = owner; + let message = "Capture service restarted.".to_owned(); crate::toasts::toast_success(&message); set_result_message.set(Some(message)); } @@ -593,7 +588,7 @@ pub(super) fn MacosCaptureOwnerRestartAction( )); } Ok(None) => set_result_message.set(Some( - "requires_app_ui: open Hypercolor.app to restart the capture owner.".to_owned(), + "Open Hypercolor.app to finish this restart.".to_owned(), )), Err(error) => { set_result_message.set(Some(format!("Capture owner restart failed: {error}"))) @@ -609,14 +604,11 @@ pub(super) fn MacosCaptureOwnerRestartAction(
"Restart capture owner"
- {format!( - "The grant is active, but {} must restart before capture can resume.", - input::humanize(&owner), - )} + "Permission granted. Hypercolor needs a quick restart of its capture service to start using it."
- "requires_app_ui: open Hypercolor.app to restart this process." + "Open Hypercolor.app to restart it."
{move || result_message.get().map(|message| view! { @@ -635,82 +627,6 @@ pub(super) fn MacosCaptureOwnerRestartAction( } } -fn screen_status_view(status: InputStatus) -> impl IntoView { - let screens = status - .sources - .into_iter() - .filter(|source| { - !source.retired - && (source.kind == "screen" - || matches!( - source.platform, - Some(InputSourcePlatformStatus::MacosScreen { .. }) - )) - }) - .collect::>(); - if screens.is_empty() { - view! { -
- "No screen-capture source is registered for this platform session." -
- } - .into_any() - } else { - view! { -
- {screens.into_iter().map(screen_source_view).collect_view()} -
- } - .into_any() - } -} - -fn screen_source_view(source: InputSourceStatus) -> impl IntoView { - let issue = primary_input_source_issue(&source); - let warning = issue.is_some() - || matches!(source.state.as_str(), "failed" | "degraded" | "unavailable") - || (source.demanded && source.freshness == "stale"); - let state_class = if warning { - "text-status-warning" - } else if source.demanded && source.state == "live" { - "text-status-success" - } else { - "text-fg-tertiary" - }; - let issue_message = issue.map(|issue| issue.message.clone()); - let source_remediation = issue.and_then(|issue| issue.remediation.clone()); - let consumer_summary = screen_consumer_summary(source.active_consumer_count); - - view! { -
-
-
-
{source.source_id}
-
{format!("{} · {consumer_summary}", source.backend)}
-
- - {input::humanize(&source.state)} - -
- {issue_message.map(|message| view! { -
{message}
- })} - {source_remediation.map(|message| view! { -
{message}
- })} - {source.platform.map(input::platform_status_view)} -
- } -} - -fn screen_consumer_summary(active_consumer_count: usize) -> String { - match active_consumer_count { - 0 => "no active consumers".to_owned(), - 1 => "1 active consumer".to_owned(), - count => format!("{count} active consumers"), - } -} - #[cfg(test)] mod macos_capture_tests { use crate::api::{ @@ -718,16 +634,7 @@ mod macos_capture_tests { SystemStatus, }; - use super::{ - macos_screen_restart_coordinates, screen_consumer_summary, validate_macos_restart_owner, - }; - - #[test] - fn screen_consumer_summary_reports_exact_committed_count() { - assert_eq!(screen_consumer_summary(0), "no active consumers"); - assert_eq!(screen_consumer_summary(1), "1 active consumer"); - assert_eq!(screen_consumer_summary(3), "3 active consumers"); - } + use super::{macos_screen_restart_coordinates, validate_macos_restart_owner}; fn system_status( input: InputStatus, diff --git a/crates/hypercolor-ui/src/components/settings_sections/input.rs b/crates/hypercolor-ui/src/components/settings_sections/input.rs index 1bab78fce..a40747627 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/input.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/input.rs @@ -2,16 +2,13 @@ use hypercolor_types::config::{HypercolorConfig, InteractionRoutePolicy}; use leptos::prelude::*; use super::{MacosCaptureOwnerRestartAction, read_config, validate_macos_restart_owner}; -use crate::api::{self, InputSourcePlatformStatus, InputSourceStatus, InputStatus}; +use crate::api::{self, InputSourcePlatformStatus, InputStatus}; use crate::app::WsContext; use crate::components::settings_controls::{ AdvancedDisclosure, SectionHeader, SectionReset, SettingDropdown, SettingToggle, }; use crate::icons::LuKeyboard; -use crate::input_access::{ - InputPipelineState, input_pipeline_state, input_status_epoch, input_status_remediation, - primary_input_source_issue, -}; +use crate::input_access::{StatusLineTone, input_status_epoch, input_status_line}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct MacosSystemSettingsRemedy { @@ -219,20 +216,14 @@ pub fn InputSection(
})} -
-
- "Source health" -
- {move || match input_status.get() { - None => view! { -
"Reading input health..."
- }.into_any(), - Some(Err(error)) => view! { -
{format!("Input health unavailable: {error}")}
- }.into_any(), - Some(Ok(status)) => input_status_view(status.input).into_any(), - }} -
+ {move || { + let status = input_status.get().and_then(Result::ok)?; + if macos_keyboard_needs_authorization(&status.input) { + return None; + } + input_status_line(&status.input) + .map(|(tone, text)| status_line_view(tone, text)) + }} impl IntoView { - let pipeline_state = input_pipeline_state(&status); - let (label, detail, class) = match pipeline_state { - InputPipelineState::ConsentOff => ( - "Consent off", - "No host input backend opens until access is enabled.", - "border-edge-subtle bg-surface-overlay/40 text-fg-tertiary", - ), - InputPipelineState::Live => ( - "Capturing", - "A demanded input source is live.", - "border-status-success/30 bg-status-success/10 text-status-success", - ), - InputPipelineState::Ready => ( - "Ready, idle", - "Permission is granted; capture starts only when an effect demands it.", - "border-status-info/30 bg-status-info/10 text-status-info", - ), - InputPipelineState::Degraded => ( - "Needs attention", - "A configured or demanded source is degraded.", - "border-status-warning/30 bg-status-warning/10 text-status-warning", - ), - InputPipelineState::Unavailable => ( - "Unavailable", - "No host input backend is available in this session.", - "border-status-error/30 bg-status-error/10 text-status-error", - ), +pub(super) fn status_line_view(tone: StatusLineTone, text: String) -> impl IntoView { + let (dot_class, text_class) = match tone { + StatusLineTone::Active => ("bg-status-success", "text-fg-secondary"), + StatusLineTone::Ready => ("bg-fg-tertiary/50", "text-fg-tertiary"), + StatusLineTone::Warn => ("bg-status-warning", "text-status-warning"), }; - let remediation = input_status_remediation(&status); - let sources = status - .sources - .into_iter() - .filter(|source| !source.retired && !is_screen_source(source)) - .collect::>(); - view! { -
-
- - {label} - - {detail} -
- {remediation.map(|message| view! { -
- {message} -
- })} - {if sources.is_empty() { - view! { -
- "No input sources are registered for this platform session." -
- }.into_any() - } else { - view! { -
- {sources.into_iter().map(input_source_view).collect_view()} -
- }.into_any() - }} -
- } -} - -pub(super) fn input_source_view(source: InputSourceStatus) -> impl IntoView { - let issue = primary_input_source_issue(&source); - let issue_message = issue.map(|issue| issue.message.clone()); - let source_remediation = issue.and_then(|issue| issue.remediation.clone()); - let state_class = if issue.is_some() - || matches!(source.state.as_str(), "failed" | "degraded" | "unavailable") - || (source.demanded && source.freshness == "stale") - { - "text-status-warning" - } else if source.demanded && source.state == "live" { - "text-status-success" - } else { - "text-fg-tertiary" - }; - let demand = if source.demanded { "demanded" } else { "idle" }; - let consent = if source.consented { - "consented" - } else { - "not consented" - }; - let configured = if source.configured { - "configured" - } else { - "disabled" - }; - let age = source - .last_sample_age_ms - .map(|age| format!(" · sample {age} ms ago")) - .unwrap_or_default(); - let platform = source.platform.clone(); - - view! { -
-
-
-
{source.source_id}
-
- {format!("{} · {}", source.kind, source.backend)} -
-
- - {humanize(&source.state)} - -
-
- {format!( - "{configured} · {consent} · {demand} · freshness {}{age}", - humanize(&source.freshness), - )} -
- {issue_message.map(|message| view! { -
{message}
- })} - {source_remediation.map(|message| view! { -
{message}
- })} - {platform.map(platform_status_view)} +
+ + {text}
} } -pub(super) fn platform_status_view(platform: InputSourcePlatformStatus) -> impl IntoView { - match platform { - InputSourcePlatformStatus::MacosInput { - keyboard, - pointer, - keyboard_tcc, - keyboard_owner, - pointer_owner, - owner_conflict, - } => { - let state = format!( - "Keyboard {} · pointer {} · Input Monitoring {}", - humanize_optional(keyboard.as_deref()), - humanize_optional(pointer.as_deref()), - humanize_optional(keyboard_tcc.as_deref()), - ); - let owners = format!( - "Keyboard owner {} · pointer owner {}", - humanize_optional(keyboard_owner.as_deref()), - humanize_optional(pointer_owner.as_deref()), - ); - view! { -
-
{state}
-
{owners}
- {owner_conflict.map(|conflict| view! { -
- {format!( - "Owner conflict: {} is active; {} also attempted startup.", - humanize_optional(conflict.active.as_deref()), - humanize_optional(conflict.contender.as_deref()), - )} -
- })} -
- } - .into_any() - } - InputSourcePlatformStatus::MacosScreen { - state, - tcc, - owner, - selection, - tahoe, - tahoe_selection, - owner_conflict, - } => { - let selection = selection - .as_ref() - .map(screen_selection_label) - .unwrap_or_else(|| "Unknown selection".to_owned()); - let range = tahoe_dynamic_range_label(tahoe.as_ref(), tahoe_selection.as_ref()); - let host = tahoe.as_ref().map(tahoe_host_label); - view! { -
-
{format!( - "Screen {} · Screen Recording {} · owner {}", - humanize_optional(state.as_deref()), - humanize_optional(tcc.as_deref()), - humanize_optional(owner.as_deref()), - )}
-
{format!("{selection} · {range}")}
- {host.map(|host| view! { -
{host}
- })} - {owner_conflict.map(|conflict| view! { -
- {format!( - "Owner conflict: {} is active; {} also attempted startup.", - humanize_optional(conflict.active.as_deref()), - humanize_optional(conflict.contender.as_deref()), - )} -
- })} -
- } - .into_any() - } - InputSourcePlatformStatus::Unknown => view! { -
- "Platform details require a newer Hypercolor app." -
- } - .into_any(), - } -} - -fn screen_selection_label(selection: &crate::api::MacosSelectionStatus) -> String { - match selection { - crate::api::MacosSelectionStatus::None => "No source selected".to_owned(), - crate::api::MacosSelectionStatus::Display { source_id } => { - source_id.as_deref().map_or_else( - || "Display selected".to_owned(), - |id| format!("Display {id}"), - ) - } - crate::api::MacosSelectionStatus::SessionScoped { content_style } => { - content_style.as_deref().map_or_else( - || "Session-scoped source".to_owned(), - |style| format!("Session-scoped {}", humanize(style)), - ) - } - crate::api::MacosSelectionStatus::Unknown => "Unknown selection".to_owned(), - } -} - -fn humanize_optional(value: Option<&str>) -> String { - value.map_or_else(|| "unknown".to_owned(), humanize) -} - -const fn capability_label(value: Option) -> &'static str { - match value { - Some(true) => "available", - Some(false) => "unavailable", - None => "unknown", - } -} - -const fn boolean_label(value: Option) -> &'static str { - match value { - Some(true) => "yes", - Some(false) => "no", - None => "unknown", - } -} - -fn tahoe_host_label(capabilities: &crate::api::MacosTahoeStatus) -> String { - format!( - "Host {} · Rosetta translated {} · Core Graphics tone mapping {} · Metal 4 {}", - humanize_optional(capabilities.host_architecture.as_deref()), - boolean_label(capabilities.translated_process), - capability_label(capabilities.content_tone_mapping_info), - capability_label(capabilities.metal4), - ) -} - -fn tahoe_dynamic_range_label( - host: Option<&crate::api::MacosTahoeStatus>, - selection: Option<&crate::api::MacosTahoeSelectionStatus>, -) -> &'static str { - let intel_host = - host.and_then(|capabilities| capabilities.host_architecture.as_deref()) == Some("intel"); - - match selection.and_then(|capabilities| capabilities.hdr_capture) { - Some(_) if intel_host => "HDR unsupported on Intel", - Some(true) => "HDR", - Some(false) => "SDR", - None => "Dynamic range pending", - } -} - pub(super) fn macos_keyboard_needs_authorization(status: &InputStatus) -> bool { status.sources.iter().any(|source| { if source.retired { @@ -564,14 +291,6 @@ fn macos_keyboard_restart_coordinates(status: &crate::api::SystemStatus) -> Opti }) } -fn is_screen_source(source: &InputSourceStatus) -> bool { - source.kind == "screen" - || matches!( - source.platform, - Some(InputSourcePlatformStatus::MacosScreen { .. }) - ) -} - fn route_value(route: InteractionRoutePolicy) -> String { match route { InteractionRoutePolicy::Host => "host", @@ -581,24 +300,16 @@ fn route_value(route: InteractionRoutePolicy) -> String { .to_owned() } -pub(super) fn humanize(value: &str) -> String { - let mut words = value.replace('_', " "); - if let Some(first) = words.get_mut(0..1) { - first.make_ascii_uppercase(); - } - words -} - #[cfg(test)] mod tests { use crate::api::{ InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, - MacosTahoeSelectionStatus, MacosTahoeStatus, SystemStatus, + SystemStatus, }; use super::{ macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates, - macos_system_settings_remedy, tahoe_dynamic_range_label, tahoe_host_label, + macos_system_settings_remedy, }; fn system_status( @@ -693,21 +404,6 @@ mod tests { assert!(!super::super::macos_screen_needs_authorization(&status)); } - #[test] - fn tahoe_host_label_reports_current_rosetta_translation_state() { - let capabilities = MacosTahoeStatus { - host_architecture: Some("apple_silicon".to_owned()), - translated_process: Some(false), - content_tone_mapping_info: Some(true), - metal4: Some(false), - }; - - assert_eq!( - tahoe_host_label(&capabilities), - "Host Apple silicon · Rosetta translated no · Core Graphics tone mapping available · Metal 4 unavailable" - ); - } - #[test] fn macos_permission_remedies_keep_exact_labels_and_deep_links() { let input = macos_system_settings_remedy( @@ -729,52 +425,6 @@ mod tests { ); } - #[test] - fn tahoe_dynamic_range_label_marks_intel_hdr_unsupported_and_tolerates_absence() { - let intel = MacosTahoeStatus { - host_architecture: Some("intel".to_owned()), - ..MacosTahoeStatus::default() - }; - let apple_silicon = MacosTahoeStatus { - host_architecture: Some("apple_silicon".to_owned()), - ..MacosTahoeStatus::default() - }; - let hdr = MacosTahoeSelectionStatus { - hdr_capture: Some(true), - ..MacosTahoeSelectionStatus::default() - }; - - assert_eq!( - tahoe_dynamic_range_label(Some(&intel), Some(&hdr)), - "HDR unsupported on Intel" - ); - let sdr = MacosTahoeSelectionStatus { - hdr_capture: Some(false), - ..MacosTahoeSelectionStatus::default() - }; - assert_eq!( - tahoe_dynamic_range_label(Some(&intel), Some(&sdr)), - "HDR unsupported on Intel" - ); - assert_eq!( - tahoe_dynamic_range_label(Some(&apple_silicon), Some(&hdr)), - "HDR" - ); - assert_eq!( - tahoe_dynamic_range_label(Some(&apple_silicon), Some(&sdr)), - "SDR" - ); - assert_eq!(tahoe_dynamic_range_label(None, Some(&hdr)), "HDR"); - assert_eq!( - tahoe_dynamic_range_label(Some(&apple_silicon), None), - "Dynamic range pending" - ); - assert_eq!( - tahoe_dynamic_range_label(None, None), - "Dynamic range pending" - ); - } - #[test] fn restart_coordinates_require_exact_state_owner_and_epoch() { let mut status = system_status( diff --git a/crates/hypercolor-ui/src/components/settings_sections/session.rs b/crates/hypercolor-ui/src/components/settings_sections/session.rs index 3bc6163f1..61ff3c916 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/session.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/session.rs @@ -186,12 +186,9 @@ fn MacosDaemonOwnershipPanel() -> impl IntoView { set_result_message.set(Some(message)); } Ok(None) => set_result_message.set(Some( - "requires_app_ui: open this page in Hypercolor.app to change daemon ownership." - .to_owned(), + "Open this page in Hypercolor.app to make this change.".to_owned(), )), - Err(error) => { - set_result_message.set(Some(format!("Daemon owner change failed: {error}"))) - } + Err(error) => set_result_message.set(Some(format!("The switch failed: {error}"))), } set_switching.set(None); ownership.refetch(); @@ -207,20 +204,15 @@ fn MacosDaemonOwnershipPanel() -> impl IntoView { leptos::task::spawn_local(async move { match tauri_bridge::execute_macos_daemon_owner_offline_remedy(&remedy).await { Ok(Some(outcome)) => { - let message = format!( - "{} started successfully.", - humanize_owner(&outcome.owner), - ); + let message = + format!("{} started successfully.", humanize_owner(&outcome.owner),); toasts::toast_success(&message); set_offline_message.set(Some(message)); } Ok(None) => set_offline_message.set(Some( - "requires_app_ui: open this page in Hypercolor.app to start the selected owner." - .to_owned(), + "Open this page in Hypercolor.app to start it.".to_owned(), )), - Err(error) => set_offline_message.set(Some(format!( - "Selected daemon owner could not start: {error}" - ))), + Err(error) => set_offline_message.set(Some(format!("It could not start: {error}"))), } set_starting_offline.set(false); ownership.refetch(); @@ -230,15 +222,20 @@ fn MacosDaemonOwnershipPanel() -> impl IntoView { view! { {move || match ownership.get() { - Some(Ok(Some(status))) => view! { - - }.into_any(), + Some(Ok(Some(status))) + if status.conflict.is_some() || status.recovery_required.is_some() => + { + view! { + + } + .into_any() + } _ => ().into_any(), }} {move || match offline.get() { @@ -254,7 +251,7 @@ fn MacosDaemonOwnershipPanel() -> impl IntoView { Some(Err(error)) if native_available => view! {
- {format!("Daemon owner status unavailable: {error}")} + {format!("Could not read the engine status: {error}")}
}.into_any(), @@ -285,7 +282,7 @@ fn MacosDaemonOwnerOfflinePanel(
-
"Selected daemon owner is offline"
+
"Hypercolor's lighting engine isn't running"
{format!( "{} is selected. {}", @@ -321,56 +318,37 @@ fn MacosDaemonOwnershipStatusPanel( #[prop(into)] result_message: Signal>, on_choose: Callback, ) -> impl IntoView { - let owner = status - .active_owner - .as_deref() - .map_or_else(|| "Unknown owner".to_owned(), humanize_owner); - let epoch = status - .owner_epoch - .map(|epoch| format!("epoch {epoch}")) - .unwrap_or_else(|| "epoch pending".to_owned()); let conflict = status.conflict.clone(); let choices = macos_owner_choices(&status); let has_choices = !choices.is_empty(); - let recovery = status.recovery_required.clone(); + let recovery_pending = status.recovery_required.is_some(); view! {
-
-
-
"macOS daemon owner"
-
{format!("{owner} · {epoch}")}
-
- - "local only" - -
{conflict.map(|conflict| view! {
- {format!( - "{} is active; {} also attempted startup.", - conflict.active.as_deref().map_or_else( - || "Unknown".to_owned(), - humanize_owner, - ), - conflict.contender.as_deref().map_or_else( - || "Unknown".to_owned(), - humanize_owner, - ), - )} +
"Two copies of Hypercolor are trying to run your lights."
+
+ {format!( + "{} is running now; {} also tried to start. Pick which one should own your lighting.", + conflict.active.as_deref().map_or_else( + || "An unknown install".to_owned(), + humanize_owner, + ), + conflict.contender.as_deref().map_or_else( + || "another install".to_owned(), + humanize_owner, + ), + )} +
})} - {recovery.map(|recovery| view! { +
- {format!( - "Owner recovery is pending at {} while moving from {} to {}.", - recovery.phase.as_deref().map_or("an unknown phase".to_owned(), humanize_owner), - recovery.prior_owner.as_deref().map_or("an unknown owner".to_owned(), humanize_owner), - recovery.requested_owner.as_deref().map_or("an unknown owner".to_owned(), humanize_owner), - )} + "A switch between Hypercolor installs was interrupted. Hypercolor is recovering; check back in a moment."
- })} +
{choices.clone().into_iter().map(|choice| { @@ -393,7 +371,7 @@ fn MacosDaemonOwnershipStatusPanel(
- "requires_app_ui: open Hypercolor.app to choose the active daemon owner." + "Open Hypercolor.app to make this choice."
@@ -440,43 +418,28 @@ const fn owner_choice_label(owner: MacosDaemonOwnerChoice) -> &'static str { MacosDaemonOwnerChoice::AppSidecar => "Use Hypercolor.app", MacosDaemonOwnerChoice::DirectLaunchd => "Use launchd service", MacosDaemonOwnerChoice::Homebrew => "Use Homebrew service", - MacosDaemonOwnerChoice::Standalone => "Use terminal daemon", + MacosDaemonOwnerChoice::Standalone => "Use the terminal daemon", } } fn macos_owner_outcome_message(outcome: &MacosOwnerCoordinatorOutcome) -> String { match outcome { - MacosOwnerCoordinatorOutcome::Active { owner, owner_epoch } => format!( - "{} now owns the daemon at epoch {owner_epoch}.", - humanize_owner(owner), - ), - MacosOwnerCoordinatorOutcome::PendingStandalone { - requested_owner, - remedy, - } => format!( - "{} is pending. {}", - humanize_owner(requested_owner), - owner_remedy_label(remedy), - ), - MacosOwnerCoordinatorOutcome::RolledBack { - prior_owner, - failure, - } => format!( - "The handover failed and {} was restored: {failure}", - humanize_owner(prior_owner), - ), - MacosOwnerCoordinatorOutcome::RecoveryRequired { - requested_owner, - prior_owner, - phase, - } => format!( - "Recovery is required at {} while moving from {} to {}.", - humanize_owner(phase), + MacosOwnerCoordinatorOutcome::Active { owner, .. } => { + format!("{} now runs your lighting.", humanize_owner(owner)) + } + MacosOwnerCoordinatorOutcome::PendingStandalone { remedy, .. } => { + format!("Almost there. {}", owner_remedy_label(remedy)) + } + MacosOwnerCoordinatorOutcome::RolledBack { prior_owner, .. } => format!( + "The switch did not complete, so {} kept running your lighting.", humanize_owner(prior_owner), - humanize_owner(requested_owner), ), + MacosOwnerCoordinatorOutcome::RecoveryRequired { .. } => { + "The switch was interrupted. Hypercolor is recovering; check back in a moment." + .to_owned() + } MacosOwnerCoordinatorOutcome::Unknown => { - "The native app returned a newer owner result. Refresh status for the authoritative state." + "This version of Hypercolor.app could not read the result. Refresh to see the current state." .to_owned() } } @@ -485,14 +448,12 @@ fn macos_owner_outcome_message(outcome: &MacosOwnerCoordinatorOutcome) -> String fn owner_remedy_label(remedy: &MacosOwnerRemedy) -> String { match remedy { MacosOwnerRemedy::StopStandaloneOwner { pid } => { - format!("Stop standalone process {pid}, then retry the handover.") + format!("Quit the terminal-launched daemon (process {pid}), then try again.") } MacosOwnerRemedy::StartAppSidecar => "Start Hypercolor.app.".to_owned(), MacosOwnerRemedy::StartLaunchdService => "Start the launchd service.".to_owned(), MacosOwnerRemedy::StartHomebrewService => "Start the Homebrew service.".to_owned(), - MacosOwnerRemedy::Unknown => { - "Follow the action shown by a newer Hypercolor app.".to_owned() - } + MacosOwnerRemedy::Unknown => "Update Hypercolor.app to finish this step.".to_owned(), } } @@ -508,10 +469,10 @@ const fn owner_remedy_button_label(remedy: &MacosOwnerRemedy) -> &'static str { fn humanize_owner(owner: &str) -> String { match owner { - "app_sidecar" => "Hypercolor.app sidecar".to_owned(), + "app_sidecar" => "Hypercolor.app".to_owned(), "launchd_service" | "direct_launchd" => "launchd service".to_owned(), "homebrew_service" | "homebrew" => "Homebrew service".to_owned(), - "standalone" => "terminal daemon".to_owned(), + "standalone" => "a terminal-launched daemon".to_owned(), value => { let mut value = value.replace('_', " "); if let Some(first) = value.get_mut(0..1) { @@ -702,7 +663,7 @@ fn WindowsDaemonServiceStatusPanel(
- {format!("Using the {} SCM daemon service", status.service_name)} + {format!("Hypercolor is running as the {} Windows service", status.service_name)}
}